diff --git a/.gitignore b/.gitignore index e6f07625..3f8ff926 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,9 @@ projects/intel_x86/linux/gcc/aether-client-cpp *.vs .updated + +# Local Android / desktop probe builds +/build-android*/ +/build-windows*/ +*.log + diff --git a/CMakeLists.txt b/CMakeLists.txt index 90aaa722..85efe3f4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,6 +44,11 @@ project(aether VERSION ${AE_PROJECT_VERSION} LANGUAGES CXX C) set(TARGET_NAME "${PROJECT_NAME}") +# Android shared-library consumers require PIC for static archives. +if(ANDROID) + set(CMAKE_POSITION_INDEPENDENT_CODE ON) +endif() + if("${CMAKE_CURRENT_BINARY_DIR}" STREQUAL "${CMAKE_BINARY_DIR}") set(AE_ROOT_PORJECT On) else() @@ -56,6 +61,7 @@ option(AE_INSTALL "Install aether" ${AE_ROOT_PORJECT}) option(AE_BUILD_TOOLS "Build tools" ${AE_ROOT_PORJECT}) option(AE_BUILD_EXAMPLES "Build examples" ${AE_ROOT_PORJECT}) option(AE_BUILD_TESTS "Build tests" ${AE_ROOT_PORJECT}) +option(AE_BUILD_ANDROID_SMOKE "Build Android NDK smoke shared library and runner" Off) option(AE_ADDRESS_SANITIZE "Enable address sanitizer" Off) option(AE_NO_STRIP_ALL "Do not apply --strip_all, useful for bloaty and similar tools " Off) @@ -72,6 +78,7 @@ message(STATUS "Aether build options: AE_BUILD_TOOLS=${AE_BUILD_TOOLS} AE_BUILD_EXAMPLES=${AE_BUILD_EXAMPLES} AE_BUILD_TESTS=${AE_BUILD_TESTS} + AE_BUILD_ANDROID_SMOKE=${AE_BUILD_ANDROID_SMOKE} AE_ADDRESS_SANITIZE=${AE_ADDRESS_SANITIZE} AE_NO_STRIP_ALL=${AE_NO_STRIP_ALL} UTM_ID=${UTM_ID} @@ -188,8 +195,9 @@ message(STATUS "Aether build for CMAKE_SYSTEM_NAME: ${CMAKE_SYSTEM_NAME}") if(CMAKE_SYSTEM_NAME STREQUAL "Linux" OR CMAKE_SYSTEM_NAME STREQUAL "Darwin" OR CMAKE_SYSTEM_NAME MATCHES ".*BSD.*" - OR CMAKE_SYSTEM_NAME STREQUAL "Windows" ) - # for desktop projects add c-ares + OR CMAKE_SYSTEM_NAME STREQUAL "Windows" + OR CMAKE_SYSTEM_NAME STREQUAL "Android" ) + # for desktop and Android projects add c-ares CPMAddPackage( NAME c-ares GIT_REPOSITORY "https://github.com/c-ares/c-ares.git" @@ -388,6 +396,17 @@ if(AE_BUILD_TESTS) add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/tests ${CMAKE_BINARY_DIR}/tests) endif() +if(ANDROID AND AE_BUILD_ANDROID_SMOKE) + message(STATUS "Aether builds Android NDK smoke targets!") + # Shared-library consumers require PIC for aether and static dependencies. + set_property(TARGET ${TARGET_NAME} PROPERTY POSITION_INDEPENDENT_CODE ON) + if(TARGET c-ares) + set_property(TARGET c-ares PROPERTY POSITION_INDEPENDENT_CODE ON) + endif() + add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/tests/android_ndk_smoke + ${CMAKE_BINARY_DIR}/android_ndk_smoke) +endif() + if(AE_INSTALL) include(CMakePackageConfigHelpers) include(GNUInstallDirs) diff --git a/aether/CMakeLists.txt b/aether/CMakeLists.txt index 96a67142..2e0da7ba 100644 --- a/aether/CMakeLists.txt +++ b/aether/CMakeLists.txt @@ -198,7 +198,7 @@ list(APPEND aether_srcs list(APPEND aether_srcs "server_connections/client_server_connection.cpp" - "server_connections/channel_connection.cpp" + "server_connections/channel_select_action.cpp" "server_connections/server_connection.cpp") list(APPEND aether_srcs diff --git a/aether/channels/ethernet_channel.cpp b/aether/channels/ethernet_channel.cpp index a04fdf19..17d93980 100644 --- a/aether/channels/ethernet_channel.cpp +++ b/aether/channels/ethernet_channel.cpp @@ -36,9 +36,11 @@ namespace ethernet_access_point_internal { using ResolveSender = ex::AnySender), ex::set_error_t(int)>; -ResolveSender ResolveAddress(Ptr const& resolver, - NamedAddr const& addr, std::uint16_t port, - Protocol protocol) { +ResolveSender ResolveAddress( + [[maybe_unused]] Ptr const& resolver, + [[maybe_unused]] NamedAddr const& addr, + [[maybe_unused]] std::uint16_t port, + [[maybe_unused]] Protocol protocol) { #if AE_SUPPORT_CLOUD_DNS return resolver->Resolve(addr, port, protocol); #else diff --git a/aether/cloud_connections/cloud_server_connections.cpp b/aether/cloud_connections/cloud_server_connections.cpp index e3273f68..7d7586dd 100644 --- a/aether/cloud_connections/cloud_server_connections.cpp +++ b/aether/cloud_connections/cloud_server_connections.cpp @@ -25,7 +25,7 @@ #include "aether/server.h" #include "aether/server_connections/server_connection.h" -#include "aether/cloud_connections/cloud_connections_tele.h" +#include "aether/cloud_connections/cloud_connections_tele.h" // IWYU pragma: keep namespace ae { @@ -149,15 +149,23 @@ void CloudServerConnections::SubscribeToServerState( if (conn == nullptr) { return; } + + if (conn->stream_info().link_state == LinkState::kLinked) { + AE_TELED_DEBUG("CLOUD_SERVER_LINKED server_id={} priority={}", + server_connection.server()->server_id, + server_connection.priority()); + } + auto const key = reinterpret_cast(&server_connection); auto& subs = server_subs_[key] = {}; subs.state_sub = conn->stream_update_event().Subscribe( [this, sc{&server_connection}, conn]() { if (conn->stream_info().link_state == LinkState::kLinkError) { QuarantineServer(*sc); - return true; + } else if (conn->stream_info().link_state == LinkState::kLinked) { + AE_TELED_DEBUG("CLOUD_SERVER_LINKED server_id={} priority={}", + sc->server()->server_id, sc->priority()); } - return false; }); subs.error_sub = conn->server_connection().server_error_event().Subscribe( [this, sc{&server_connection}]() { QuarantineServer(*sc); }); @@ -182,7 +190,7 @@ void CloudServerConnections::QuarantineServer( if (server_connection.quarantine()) { return; } - AE_TELED_DEBUG("Quarantine server server_id={} priority={}", + AE_TELED_DEBUG("CLOUD_SERVER_QUARANTINED server_id={} priority={}", server_connection.server()->server_id, server_connection.priority()); UnsubscribeFromServerState(server_connection); @@ -193,14 +201,21 @@ void CloudServerConnections::QuarantineServer( server_connection.SetPriority(server_connections_.size()); server_connection.SetQuarantine(true); server_quarantined_event_.Emit(&server_connection); - server_subs_[key].quarantine_sub = ae_context_.scheduler().DelayedTask( + + // One delayed release: Disconnect + clear quarantine + reconcile. Do not + // Disconnect on the error-callback stack. + auto& quarantine_sub = server_subs_[key].quarantine_sub; + quarantine_sub = ae_context_.scheduler().DelayedTask( [this, sc{&server_connection}, key]() { ReleaseQuarantinedServer(*sc, key); }, kCloudServerQuarantineTime); - if (!server_subs_[key].quarantine_sub) { - assert(false && "Failed to schedule quarantine release task"); + + if (!quarantine_sub) { + AE_TELED_ERROR("CLOUD_QUARANTINE_RELEASE_ALLOC_FAILED"); + assert(false && "failed to schedule quarantine release"); } + ScheduleReconcileServers(); } @@ -209,7 +224,7 @@ void CloudServerConnections::ReleaseQuarantinedServer( if (!server_connection.quarantine()) { return; } - AE_TELED_DEBUG("Release quarantined server server_id={} priority={}", + AE_TELED_DEBUG("CLOUD_SERVER_RELEASED server_id={} priority={}", server_connection.server()->server_id, server_connection.priority()); server_quarantine_release_event_.Emit(&server_connection); @@ -222,6 +237,7 @@ void CloudServerConnections::ReleaseQuarantinedServer( server_subs_.erase(it); } } + ScheduleReconcileServers(); } @@ -233,6 +249,11 @@ void CloudServerConnections::ScheduleReconcileServers() { defer_sub_.Reset(); ReconcileServers(); }); + if (!defer_sub_) { + AE_TELED_ERROR( + "CLOUD_SCHEDULE_RECONCILE_ALLOC_FAILED; pending until release"); + assert(false && "failed to schedule reconcile servers"); + } } void CloudServerConnections::ReconcileServers() { @@ -252,6 +273,8 @@ void CloudServerConnections::ReconcileServers() { break; } candidate->SetPriority(selected_servers_.size()); + AE_TELED_DEBUG("CLOUD_SERVER_RECONNECT_ATTEMPT server_id={} priority={}", + candidate->server()->server_id, candidate->priority()); candidate->Connect(); auto* conn = candidate->client_connection(); if (conn == nullptr || @@ -284,8 +307,8 @@ void CloudServerConnections::UpdateSelectedPriorities() { } } -std::vector -CloudServerConnections::ReplacementCandidates() { +auto CloudServerConnections::ReplacementCandidates() + -> std::vector { std::vector servers; servers.reserve(server_connections_.size()); for (auto& s : server_connections_) { diff --git a/aether/server_connections/channel_connection.cpp b/aether/server_connections/channel_connection.cpp deleted file mode 100644 index 09b43acb..00000000 --- a/aether/server_connections/channel_connection.cpp +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2025 Aethernet Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "aether/server_connections/channel_connection.h" - -#include -#include - -#include "aether-miscpp/misc/override.h" - -#include "aether/tele.h" - -namespace ae { -ChannelConnection::ChannelConnection(AeContext const& ae_context) - : ae_context_{ae_context} {} - -ByteIStream* ChannelConnection::stream() const { - return transport_stream_.get(); -} - -void ChannelConnection::BuildTransport( - Ptr const& channel, ConnectionStateCb&& connection_state_cb) { - transport_build_start_ = Now(); - auto sender = channel->TransportBuilder(); - transport_waiter_.emplace( - ae_context_, - std::move(sender) | - ex::with_timeout(ae_context_, channel->TransportBuildTimeout()), - [&, c_ = PtrView{channel}, - cb_ = std::move(connection_state_cb)](auto&& result) { - // the result must exists - assert(!!result && "The result must exists"); - if (result->IsOk()) { - UpdateTransportBuildTime(c_); - transport_stream_ = std::move(result->value()); - assert(transport_stream_ && "Transport should be created"); - cb_(Ok{*transport_stream_}); - } else { - std::visit(Override{ - [&](ex::TimeoutError) { - AE_TELED_ERROR("Transport build timeout"); - cb_(Error{-1}); - }, - [&](int e) { - AE_TELED_ERROR( - "Transport build failed with error code: {}", e); - cb_(Error{e}); - }, - }, - result->error()); - } - transport_waiter_.reset(); - }); -} - -void ChannelConnection::UpdateTransportBuildTime(PtrView const& c) { - auto channel = c.Lock(); - assert(channel && "Channel not loaded"); - auto build_time = - std::chrono::duration_cast(Now() - transport_build_start_); - AE_TELED_INFO("Transport built for {:%S}", build_time); - channel->channel_statistics().AddConnectionTime(build_time); -} - -} // namespace ae diff --git a/aether/server_connections/channel_connection.h b/aether/server_connections/channel_connection.h deleted file mode 100644 index ba305049..00000000 --- a/aether/server_connections/channel_connection.h +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2025 Aethernet Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#ifndef AETHER_SERVER_CONNECTIONS_CHANNEL_CONNECTION_H_ -#define AETHER_SERVER_CONNECTIONS_CHANNEL_CONNECTION_H_ - -#include "aether/common.h" -#include "aether/ptr/ptr.h" -#include "aether/ae_context.h" -#include "aether/channels/channel.h" -#include "aether/stream_api/istream.h" -#include "aether/executors/executors.h" -#include "aether-miscpp/types/small_function.h" - -namespace ae { -class Channel; -class ChannelConnection { - public: - using ConnectionStateCb = - SmallFunction result)>; - - explicit ChannelConnection(AeContext const& ae_context); - - AE_CLASS_NO_COPY_MOVE(ChannelConnection) - - void BuildTransport(Ptr const& channel, - ConnectionStateCb&& connection_state_cb); - ByteIStream* stream() const; - - private: - void UpdateTransportBuildTime(PtrView const& c); - - AeContext ae_context_; - - std::optional< - ex::AnyWaiter), - ex::set_error_t(int), ex::set_error_t(ex::TimeoutError)>> - transport_waiter_; - TimePoint transport_build_start_; - std::unique_ptr transport_stream_; -}; -} // namespace ae - -#endif // AETHER_SERVER_CONNECTIONS_CHANNEL_CONNECTION_H_ diff --git a/aether/server_connections/channel_select_action.cpp b/aether/server_connections/channel_select_action.cpp new file mode 100644 index 00000000..6c9a6029 --- /dev/null +++ b/aether/server_connections/channel_select_action.cpp @@ -0,0 +1,88 @@ +/* + * Copyright 2026 Aethernet Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "aether/server_connections/channel_select_action.h" + +#include "aether/channels/channel.h" +#include "aether/ptr/ptr_view.h" +#include "aether/server_connections/server_connection.h" + +#include "aether/tele.h" + +namespace ae { +void ChannelSelectAction::CbHandle::operator()( + std::optional, + std::variant>>&& res) + const noexcept { + auto channel = self->attempted_channel_->channel.Lock(); + assert(channel && "Channel is null"); + + if (res && res->IsOk()) { + // update transport build time on success + auto build_time = std::chrono::duration_cast(Now() - start_time); + AE_TELED_INFO("Transport built for {:%S}", build_time); + channel->channel_statistics().AddConnectionTime(build_time); + } + + // emit the res, but convert all errors to int + constexpr int kStopped = -2; + self->result_event_.Emit( + std::move(res) + .value_or(Error>(kStopped)) + .Else([&](auto&& verr) noexcept + -> Result, int> { + return std::visit( + [&](auto const& e) noexcept { return Error{HandleError(e)}; }, + std::forward(verr)); + })); + self->Finish(); +} + +int ChannelSelectAction::CbHandle::HandleError( + ex::TimeoutError const&) noexcept { + AE_TELED_ERROR("Transport build timeout"); + return -1; +} + +int ChannelSelectAction::CbHandle::HandleError(int e) noexcept { + AE_TELED_ERROR("Transport build failed with error code: {}", e); + return e; +} + +ChannelSelectAction::ChannelSelectAction( + AeContext const& ae_context, ChannelEntry& attempted_channel) noexcept + : ae_context_{ae_context}, attempted_channel_{&attempted_channel} {} + +void ChannelSelectAction::Start() { + auto channel = attempted_channel_->channel.Lock(); + assert(channel && "Channel is null"); + + auto s = channel->TransportBuilder() | + ex::with_timeout(ae_context_, channel->TransportBuildTimeout()); + + async_waiter_.emplace(ae_context_, std::move(s), + CbHandle{.self = this, .start_time = Now()}); +} + +auto ChannelSelectAction::result_event() noexcept -> ResultEvent::Subscriber { + return EventSubscriber{result_event_}; +} + +ChannelEntry& ChannelSelectAction::attempted_channel() noexcept { + assert(attempted_channel_ != nullptr); + return *attempted_channel_; +} +} // namespace ae diff --git a/aether/server_connections/channel_select_action.h b/aether/server_connections/channel_select_action.h new file mode 100644 index 00000000..2bf24868 --- /dev/null +++ b/aether/server_connections/channel_select_action.h @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Aethernet Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef AETHER_SERVER_CONNECTIONS_CHANNEL_SELECT_ACTION_H_ +#define AETHER_SERVER_CONNECTIONS_CHANNEL_SELECT_ACTION_H_ + +#include +#include + +#include "aether-miscpp/types/result.h" + +#include "aether/actions/action.h" +#include "aether/ae_context.h" +#include "aether/channels/channel.h" +#include "aether/clock.h" +#include "aether/events/events.h" +#include "aether/executors/executors.h" +#include "aether/stream_api/istream.h" + +namespace ae { +struct ChannelEntry; + +class ChannelSelectAction final : public Action { + struct CbHandle { + void operator()(std::optional, + std::variant>>&& + res) const noexcept; + + static int HandleError(ex::TimeoutError const& te) noexcept; + static int HandleError(int e) noexcept; + + ChannelSelectAction* self; + TimePoint start_time; + }; + + public: + using ResultEvent = Event, int>)>; + + ChannelSelectAction(AeContext const& ae_context, + ChannelEntry& attempted_channel) noexcept; + + void Start(); + ResultEvent::Subscriber result_event() noexcept; + ChannelEntry& attempted_channel() noexcept; + + private: + void ChannelSelected(); + void ChannelFailed(); + + AeContext ae_context_; + ChannelEntry* attempted_channel_; + ResultEvent result_event_; + + std::optional, + CbHandle>> + async_waiter_; +}; +} // namespace ae + +#endif // AETHER_SERVER_CONNECTIONS_CHANNEL_SELECT_ACTION_H_ diff --git a/aether/server_connections/server_connection.cpp b/aether/server_connections/server_connection.cpp index 37fe61e6..8afc277d 100644 --- a/aether/server_connections/server_connection.cpp +++ b/aether/server_connections/server_connection.cpp @@ -26,43 +26,13 @@ #include "aether/tele.h" namespace ae { -ServerConnection::ChannelSelectAction::ChannelSelectAction( - AeContext const& ae_context, ChannelEntry& top_channel) noexcept - : ae_context_{ae_context}, top_channel_{&top_channel} { - auto channel = top_channel_->channel.Lock(); - assert(channel && "Channel is null"); - - top_channel_->connection.BuildTransport(channel, [this](auto&& res) { - if (res) { - ChannelSelected(); - } else { - ChannelFailed(); - } - }); -} - -auto ServerConnection::ChannelSelectAction::result_event() noexcept - -> ResultEvent::Subscriber { - return EventSubscriber{result_event_}; -} - -void ServerConnection::ChannelSelectAction::ChannelSelected() { - task_sub_ = ae_context_.scheduler().Task([&]() noexcept { - result_event_.Emit(Ok{*top_channel_}); - Finish(); - }); -} - -void ServerConnection::ChannelSelectAction::ChannelFailed() { - task_sub_ = ae_context_.scheduler().Task([&]() noexcept { - result_event_.Emit(Error{1}); - Finish(); - }); -} ServerConnection::ServerConnection(AeContext const& ae_context, Ptr const& server) - : ae_context_{ae_context}, server_{server}, full_connected_{false} { + : ae_context_{ae_context}, + server_{server}, + full_connected_{false}, + top_channel_{nullptr} { InitChannels(); SelectChannel(); } @@ -72,10 +42,9 @@ WriteAction& ServerConnection::Write(DataBuffer&& in_data) { assert(stream_info_.is_writable && "Channel is not writable"); assert((top_channel_ != nullptr) && "channel connection is not available"); - auto* stream = top_channel_->connection.stream(); - assert((stream != nullptr) && "channel stream is not available"); + assert(!!stream_ && "channel stream is not available"); - return stream->Write(std::move(in_data)); + return stream_->Write(std::move(in_data)); } ServerConnection::StreamUpdateEvent::Subscriber @@ -148,17 +117,17 @@ void ServerConnection::InitChannels() { channels_.reserve(channels.size()); for (auto const& c : channels) { - channels_.emplace_back(std::make_unique(ae_context_, c)); + channels_.emplace_back(c); } } -ServerConnection::ChannelEntry* ServerConnection::TopChannel() { +ChannelEntry* ServerConnection::TopChannel() { auto it = std::find_if(std::begin(channels_), std::end(channels_), - [](auto const& entry) { return !entry->failed; }); + [](auto const& entry) { return !entry.failed; }); if (it == std::end(channels_)) { return nullptr; } - return it->get(); + return &*it; } void ServerConnection::SelectChannel() { @@ -182,34 +151,52 @@ void ServerConnection::SelectChannel() { channel_select_action_.emplace(ae_context_, *top); channel_select_action_->result_event().Subscribe([this](auto&& res) noexcept { if (res) { - ChannelUpdated(res.value()); + ChannelUpdated(channel_select_action_->attempted_channel(), + std::forward(res).value()); } else { - ChannelError(); + // Mark the channel that was actually being built. top_channel_ is only + // set after a successful build and must not be used here. + ChannelBuildFailed(channel_select_action_->attempted_channel()); } }); + // Subscribe before Start so a synchronous TransportBuilder result is not + // lost. + channel_select_action_->Start(); AE_TELED_DEBUG("New channel selected"); channel_changed_.Emit(); stream_update_event_.Emit(); } -void ServerConnection::ChannelUpdated(ChannelEntry& new_channel) { +void ServerConnection::DeferSelectChannel() { + // Must not call SelectChannel() from inside ChannelSelectAction::result + // callback: recreating transport_waiter on that stack is unsafe even though + // the action is already finished. + defer_sub_ = ae_context_.scheduler().Task([this]() { + AE_TELED_DEBUG("SERVER_CHANNEL_RESELECT_SCHEDULED"); + SelectChannel(); + }); + assert(!!defer_sub_); +} + +void ServerConnection::ChannelUpdated(ChannelEntry& new_channel, + std::unique_ptr&& stream) { AE_TELED_DEBUG("Channel updated"); top_channel_ = &new_channel; - auto& stream = *top_channel_->connection.stream(); - assert(stream.stream_info().link_state == LinkState::kLinked && + stream_ = std::move(stream); + assert(stream_->stream_info().link_state == LinkState::kLinked && "New channel should be linked"); - // track channel stream link error + // track channel stream_ link error channel_stream_update_sub_ = - stream.stream_update_event().Subscribe([this, s_ = &stream]() { + stream_->stream_update_event().Subscribe([this, s_ = stream_.get()]() { auto info = s_->stream_info(); if (info.link_state == LinkState::kLinkError) { ChannelError(); } }); - channel_stream_out_data_sub_ = stream.out_data_event().Subscribe( + channel_stream_out_data_sub_ = stream_->out_data_event().Subscribe( MethodPtr<&ServerConnection::OnRead>{this}); auto channel = top_channel_->channel.Lock(); @@ -221,14 +208,28 @@ void ServerConnection::ChannelUpdated(ChannelEntry& new_channel) { stream_info_.rec_element_size = channel_props.rec_packet_size; stream_info_.max_element_size = channel_props.max_packet_size; - // now it's safe to write to server stream + // now it's safe to write to server stream_ stream_info_.link_state = LinkState::kLinked; stream_info_.is_writable = true; stream_update_event_.Emit(); } +void ServerConnection::ChannelBuildFailed(ChannelEntry& attempted_channel) { + AE_TELED_ERROR("SERVER_CHANNEL_BUILD_FAILED"); + channel_stream_update_sub_.Reset(); + channel_stream_out_data_sub_.Reset(); + stream_info_.is_writable = false; + attempted_channel.failed = true; + + if (full_connected_) { + ServerError(); + } else { + DeferSelectChannel(); + } +} + void ServerConnection::ServerError() { - AE_TELED_ERROR("Server error"); + AE_TELED_ERROR("SERVER_CONNECTION_ERROR"); channel_stream_update_sub_.Reset(); channel_stream_out_data_sub_.Reset(); // TODO: should we also reset connection.stream() @@ -254,18 +255,17 @@ void ServerConnection::ChannelError() { if (full_connected_) { ServerError(); } else { - SelectChannel(); + DeferSelectChannel(); } } void ServerConnection::DeferServerError() { stream_info_.is_writable = false; defer_sub_ = ae_context_.scheduler().Task([&]() { ServerError(); }); -} - -void ServerConnection::DeferChannelError() { - stream_info_.is_writable = false; - defer_sub_ = ae_context_.scheduler().Task([&]() { ChannelError(); }); + if (!defer_sub_) { + AE_TELED_ERROR("DeferServerError schedule failed; invoking ServerError"); + ServerError(); + } } void ServerConnection::OnRead(DataBuffer const& data) { diff --git a/aether/server_connections/server_connection.h b/aether/server_connections/server_connection.h index 3898d2fd..27276752 100644 --- a/aether/server_connections/server_connection.h +++ b/aether/server_connections/server_connection.h @@ -17,53 +17,28 @@ #ifndef AETHER_SERVER_CONNECTIONS_SERVER_CONNECTION_H_ #define AETHER_SERVER_CONNECTIONS_SERVER_CONNECTION_H_ +#include #include #include -#include "aether-miscpp/types/result.h" - -#include "aether/actions/action.h" #include "aether/ae_context.h" #include "aether/events/events.h" #include "aether/ptr/ptr.h" #include "aether/ptr/ptr_view.h" -#include "aether/server_connections/channel_connection.h" +#include "aether/server_connections/channel_select_action.h" #include "aether/stream_api/istream.h" namespace ae { class Server; class Channel; -class ServerConnection final : public ByteIStream { - struct ChannelEntry { - ChannelEntry(AeContext const& ae_context, PtrView const& c) - : channel{c}, connection{ae_context} {} - - PtrView channel; - ChannelConnection connection; - bool failed = false; - }; - - class ChannelSelectAction final : public Action { - public: - using ResultEvent = Event)>; - - ChannelSelectAction(AeContext const& ae_context, - ChannelEntry& top_channel) noexcept; - - ResultEvent::Subscriber result_event() noexcept; - - private: - void ChannelSelected(); - void ChannelFailed(); - - AeContext ae_context_; - ChannelEntry* top_channel_; - TaskSubscription task_sub_; - ResultEvent result_event_; - }; +struct ChannelEntry { + PtrView channel; + bool failed = false; +}; +class ServerConnection final : public ByteIStream { public: using ServerErrorEvent = Event; using ChannelChangedEvent = Event; @@ -82,16 +57,20 @@ class ServerConnection final : public ByteIStream { Ptr current_channel() const; private: + friend struct ServerConnectionTestAccess; + void InitChannels(); // return top not failed channel or null if nothing was selected ChannelEntry* TopChannel(); void SelectChannel(); - void ChannelUpdated(ChannelEntry& new_channel); + void DeferSelectChannel(); + void ChannelUpdated(ChannelEntry& new_channel, + std::unique_ptr&& stream); + void ChannelBuildFailed(ChannelEntry& attempted_channel); void ServerError(); void ChannelError(); void DeferServerError(); - void DeferChannelError(); void OnRead(DataBuffer const& data); @@ -100,7 +79,8 @@ class ServerConnection final : public ByteIStream { bool full_connected_; ChannelEntry* top_channel_; - std::vector> channels_; + std::unique_ptr stream_; + std::vector channels_; std::optional channel_select_action_; StreamInfo stream_info_; diff --git a/aether/tasks/details/manual_task_scheduler.h b/aether/tasks/details/manual_task_scheduler.h index 63bbcfd3..da8a9b22 100644 --- a/aether/tasks/details/manual_task_scheduler.h +++ b/aether/tasks/details/manual_task_scheduler.h @@ -21,7 +21,7 @@ #include #include #include -#include // // IWYU pragma: keep +#include //IWYU pragma: keep #include #include "aether/tasks/details/task_manager.h" @@ -66,7 +66,7 @@ class ManualTaskScheduler { task_manager_.regular().StealTasks(reg_list_); UpdateTasks(lock, reg_list_, task_manager_.regular()); - // run delaed tasks + // run delayed tasks task_manager_.delayed().StealTasks(current_time, delay_list_); UpdateTasks(lock, delay_list_, task_manager_.delayed()); @@ -100,6 +100,9 @@ class ManualTaskScheduler { template IActive* AddSafe(F&& f) { auto lock = std::scoped_lock{lock_}; + + task_manager_.ReclaimInactive(); + auto* p = std::invoke(std::forward(f)); if (p == nullptr) { overflow_counter_++; diff --git a/aether/tasks/details/task.h b/aether/tasks/details/task.h index bea64fac..5d3e6ea5 100644 --- a/aether/tasks/details/task.h +++ b/aether/tasks/details/task.h @@ -30,7 +30,7 @@ class ITaskSubscription { class IActive { public: - static constexpr std::uintptr_t kMagic = 0xda; + static constexpr std::uintptr_t kMagic = 0xda11; virtual ~IActive() noexcept { if ((active != kMagic) && (active != 0)) { diff --git a/aether/tasks/details/task_manager.h b/aether/tasks/details/task_manager.h index 4d186b7f..920b3e78 100644 --- a/aether/tasks/details/task_manager.h +++ b/aether/tasks/details/task_manager.h @@ -18,12 +18,12 @@ #define AETHER_TASKS_DETAILS_TASK_MANAGER_H_ #include -#include #include +#include #include "aether-miscpp/meta/time_traits.h" -#include "aether/tasks/details/task_queues.h" #include "aether/tasks/details/generic_task.h" +#include "aether/tasks/details/task_queues.h" #include @@ -88,6 +88,14 @@ class TaskManager { delayd_task_list_, std::forward(f), tp); } + void ReclaimInactive() { + // clean up inactive task from the list + // this free space from tasks that + // still in pool and list, but never will be executed + regular_task_list_.ReclaimInactive(); + delayd_task_list_.ReclaimInactive(); + } + regular_task_list& regular() { return regular_task_list_; } delayd_task_list& delayed() { return delayd_task_list_; } diff --git a/aether/tasks/details/task_queues.h b/aether/tasks/details/task_queues.h index dc62081d..abec823c 100644 --- a/aether/tasks/details/task_queues.h +++ b/aether/tasks/details/task_queues.h @@ -17,9 +17,10 @@ #ifndef AETHER_TASKS_DETAILS_TASK_QUEUE_H_ #define AETHER_TASKS_DETAILS_TASK_QUEUE_H_ +#include +#include #include #include -#include #include "aether/tasks/details/task.h" @@ -31,6 +32,7 @@ DISABLE_WARNING_POP() namespace ae { template + requires(std::derived_from) class TaskQueueBase { public: static constexpr std::size_t kCapacity = Capacity; @@ -51,6 +53,24 @@ class TaskQueueBase { } } + /** + * \brief Destroy cancelled (inactive) tasks. + */ + std::size_t ReclaimInactive() { + auto size_before = list_.size(); + list_.erase(std::remove_if(std::begin(list_), std::end(list_), + [&](auto const* e) { + if (e->active == 0) { + pool_->template destroy(e); + return true; + } + return false; + }), + std::end(list_)); + + return size_before - list_.size(); + } + std::size_t size() const { return list_.size(); } Interface* back() const { return list_.back(); } Interface* front() const { return list_.front(); } @@ -76,6 +96,7 @@ class TaskQueue : TaskQueueBase { using base::size; using base::Free; + using base::ReclaimInactive; bool Add(ITask* p) { if (base::list_.size() == base::list_.max_size()) { @@ -115,17 +136,20 @@ class DelayedTaskQueue : TaskQueueBase, Capacity, Pool> { using base::size; using base::Free; + using base::ReclaimInactive; bool Add(IDelayedTask* p) { if (base::list_.size() == base::list_.max_size()) { return false; } // keep list sorted by expire_at - auto it = std::find_if( - std::rbegin(base::list_), std::rend(base::list_), - [&](IDelayedTask const* e) { return p->expire_at < e->expire_at; }); + auto pos = + std::lower_bound(std::begin(base::list_), std::end(base::list_), p, + [](auto const* left, auto const* right) noexcept { + return left->expire_at > right->expire_at; + }); - base::list_.emplace(it.base(), p); + base::list_.emplace(pos, p); return true; } diff --git a/config/user_config_android_smoke.h b/config/user_config_android_smoke.h new file mode 100644 index 00000000..756880d0 --- /dev/null +++ b/config/user_config_android_smoke.h @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Aethernet Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef CONFIG_USER_CONFIG_ANDROID_SMOKE_H_ +#define CONFIG_USER_CONFIG_ANDROID_SMOKE_H_ + +#include "aether/config_consts.h" + +// Minimal crypto for local AetherApp smoke (not a production default). +#define AE_CRYPTO_ASYNC AE_HYDRO_CRYPTO_PK +#define AE_CRYPTO_SYNC AE_HYDRO_CRYPTO_SK +#define AE_SIGNATURE AE_HYDRO_SIGNATURE +#define AE_KDF AE_HYDRO_KDF + +#define AE_SUPPORT_REGISTRATION 0 +#define AE_SUPPORT_CLOUD_DNS 0 +#define AE_SUPPORT_HTTP 0 +#define AE_SUPPORT_HTTPS 0 +#define AE_SUPPORT_PROXY 0 +#define AE_SUPPORT_WIFIS 0 +#define AE_SUPPORT_MODEMS 0 +#define AE_SUPPORT_LORA 0 +#define AE_SUPPORT_GATEWAY 0 + +#define AE_TELE_ENABLED 0 +#define AE_TELE_LOG_CONSOLE 0 +#define AE_TELE_LOG_TO_STATISTICS 0 + +#define AE_ENABLE_PING 0 + +#endif /* CONFIG_USER_CONFIG_ANDROID_SMOKE_H_ */ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 75f91278..f9449056 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -43,5 +43,6 @@ add_subdirectory(test-ptr) add_subdirectory(test-domain-storage) add_subdirectory(test-serial-port) add_subdirectory(test-tasks) +add_subdirectory(test-server-connection) add_subdirectory(third_party_tests) diff --git a/tests/android_ndk_smoke/CMakeLists.txt b/tests/android_ndk_smoke/CMakeLists.txt new file mode 100644 index 00000000..639a2d01 --- /dev/null +++ b/tests/android_ndk_smoke/CMakeLists.txt @@ -0,0 +1,34 @@ +# Copyright 2026 Aethernet Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +add_library(aether_android_smoke SHARED + aether_android_smoke.cpp +) + +target_link_libraries(aether_android_smoke PRIVATE aether log) + +set_target_properties(aether_android_smoke PROPERTIES + POSITION_INDEPENDENT_CODE ON + OUTPUT_NAME "aether_android_smoke" +) + +add_executable(aether_android_smoke_runner + aether_android_smoke_runner.cpp +) + +target_link_libraries(aether_android_smoke_runner PRIVATE dl) + +set_target_properties(aether_android_smoke_runner PROPERTIES + POSITION_INDEPENDENT_CODE ON +) diff --git a/tests/android_ndk_smoke/aether_android_smoke.cpp b/tests/android_ndk_smoke/aether_android_smoke.cpp new file mode 100644 index 00000000..e15d9f39 --- /dev/null +++ b/tests/android_ndk_smoke/aether_android_smoke.cpp @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Aethernet Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include + +#include "aether/aether_app.h" +#include "aether/common.h" +#include "aether/domain_storage/ram_domain_storage.h" +#include "aether/obj/idomain_storage.h" + +namespace { + +std::unique_ptr MakeRamDomainStorage() { + return std::make_unique(); +} + +} // namespace + +extern "C" int aether_android_smoke_run() { + std::fprintf(stdout, "AETHER_ANDROID_SMOKE_START\n"); + std::fflush(stdout); + + { + auto app = ae::AetherApp::Construct( + ae::AetherAppContext{MakeRamDomainStorage}); + if (app.get() == nullptr) { + std::fprintf(stderr, "AETHER_ANDROID_SMOKE_FAIL construct\n"); + std::fflush(stderr); + return 1; + } + + std::fprintf(stdout, "AETHER_ANDROID_APP_CONSTRUCTED\n"); + std::fflush(stdout); + + for (int i = 0; i < 5; ++i) { + (void)app->Update(ae::Now()); + } + + std::fprintf(stdout, "AETHER_ANDROID_UPDATE_OK\n"); + std::fflush(stdout); + } + + std::fprintf(stdout, "AETHER_ANDROID_SMOKE_OK\n"); + std::fflush(stdout); + return 0; +} diff --git a/tests/android_ndk_smoke/aether_android_smoke_runner.cpp b/tests/android_ndk_smoke/aether_android_smoke_runner.cpp new file mode 100644 index 00000000..f3c5615b --- /dev/null +++ b/tests/android_ndk_smoke/aether_android_smoke_runner.cpp @@ -0,0 +1,56 @@ +/* + * Copyright 2026 Aethernet Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include + +#include + +using SmokeFn = int (*)(); + +namespace { + +char const* DefaultLibraryName() { + return "libaether_android_smoke.so"; +} + +} // namespace + +int main(int argc, char** argv) { + auto const* library_path = + (argc > 1 && argv[1] != nullptr) ? argv[1] : DefaultLibraryName(); + + void* handle = dlopen(library_path, RTLD_NOW); + if (handle == nullptr) { + std::fprintf(stderr, "dlopen failed for %s: %s\n", library_path, dlerror()); + return 2; + } + + dlerror(); + auto* smoke = reinterpret_cast(dlsym(handle, "aether_android_smoke_run")); + auto const* sym_error = dlerror(); + if (sym_error != nullptr || smoke == nullptr) { + std::fprintf(stderr, "dlsym failed for aether_android_smoke_run: %s\n", + sym_error != nullptr ? sym_error : "null symbol"); + dlclose(handle); + return 3; + } + + auto const exit_code = smoke(); + dlclose(handle); + return exit_code; +} diff --git a/tests/test-server-connection/CMakeLists.txt b/tests/test-server-connection/CMakeLists.txt new file mode 100644 index 00000000..121b6c35 --- /dev/null +++ b/tests/test-server-connection/CMakeLists.txt @@ -0,0 +1,41 @@ +# Copyright 2026 Aethernet Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +cmake_minimum_required(VERSION 3.18) + +list(APPEND test_srcs + main.cpp + test_server_connection_recovery.cpp + test_cloud_quarantine_loop.cpp + ${CMAKE_CURRENT_LIST_DIR}/../test-object-system/map_domain_storage.cpp +) + +if(NOT CM_PLATFORM) + project(test-server-connection LANGUAGES CXX) + + add_executable(${PROJECT_NAME}) + target_sources(${PROJECT_NAME} PRIVATE ${test_srcs}) + target_include_directories(${PROJECT_NAME} PRIVATE + ${ROOT_DIR} + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ) + target_link_libraries(${PROJECT_NAME} PRIVATE aether unity) + target_compile_definitions(${PROJECT_NAME} PRIVATE + "AE_DISTILLATION=1" + ) + add_test(NAME ${PROJECT_NAME} COMMAND $) +else() + message(WARNING "Not implemented for ${CM_PLATFORM}") +endif() diff --git a/tests/test-server-connection/main.cpp b/tests/test-server-connection/main.cpp new file mode 100644 index 00000000..f190391e --- /dev/null +++ b/tests/test-server-connection/main.cpp @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Aethernet Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +void setUp() {} +void tearDown() {} + +extern int run_test_server_connection_recovery(); +extern int run_test_cloud_quarantine_loop(); + +int main() { + int res = 0; + res += run_test_server_connection_recovery(); + res += run_test_cloud_quarantine_loop(); + return res; +} diff --git a/tests/test-server-connection/test_cloud_quarantine_loop.cpp b/tests/test-server-connection/test_cloud_quarantine_loop.cpp new file mode 100644 index 00000000..7622df65 --- /dev/null +++ b/tests/test-server-connection/test_cloud_quarantine_loop.cpp @@ -0,0 +1,233 @@ +/* + * Copyright 2026 Aethernet Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include + +#include + +#include "aether/adapter_registry.h" +#include "aether/ae_context.h" +#include "aether/cloud.h" +#include "aether/cloud_connections/cloud_server_connections.h" +#include "aether/config.h" +#include "aether/obj/domain.h" +#include "aether/server.h" +#include "aether/server_connections/client_server_connection.h" +#include "aether/server_connections/iserver_connection_factory.h" +#include "aether/types/address.h" +#include "aether/types/server_id.h" + +#include "tests/test-object-system/map_domain_storage.h" + +namespace ae { +namespace test_cloud_quarantine_loop { +struct TestContext { + AeCtx ToAeContext() const { + static constexpr auto table = + AeCtxTable{nullptr, [](void* obj) -> TaskScheduler& { + return static_cast(obj)->sched; + }}; + return AeCtx{const_cast(this), &table}; // NOLINT + } + + void PumpAt(std::chrono::system_clock::time_point now, int rounds = 8) { + for (int i = 0; i < rounds; ++i) { + (void)sched.Update(now); + } + } + + TaskScheduler sched; +}; + +class CountingNullFactory final : public IServerConnectionFactory { + public: + std::shared_ptr CreateConnection( + Ptr const& /*server*/) override { + ++attempts; + return {}; + } + int attempts{0}; +}; + +class SwitchableNullFactory final : public IServerConnectionFactory { + public: + std::shared_ptr CreateConnection( + Ptr const& /*server*/) override { + ++attempts; + return {}; + } + bool available{false}; + int attempts{0}; +}; + +struct CloudFixture { + CloudFixture(std::unique_ptr factory, + IServerConnectionFactory* raw) + : ae_ctx{ctx}, + domain{Now(), storage}, + registry{AdapterRegistry::ptr::Create(CreateWith{domain})}, + server{Server::ptr::Create(CreateWith{domain}, ServerId{7}, + std::vector{}, registry)}, + cloud{Cloud::ptr::Create(CreateWith{domain})}, + factory_raw{raw} { + cloud->AddServer(server); + connections = std::make_unique( + ae_ctx, cloud.Load(), std::move(factory), /*max*/ 1); + } + + bool AnyQuarantined() const { + for (auto* s : connections->servers()) { + if (s->quarantine()) { + return true; + } + } + return false; + } + + TestContext ctx; + AeContext ae_ctx; + MapDomainStorage storage; + Domain domain; + AdapterRegistry::ptr registry; + Server::ptr server; + Cloud::ptr cloud; + IServerConnectionFactory* factory_raw{nullptr}; + std::unique_ptr connections; +}; + +void test_CloudQuarantineDoesNotBusyLoop() { + auto factory = std::make_unique(); + auto* factory_raw = factory.get(); + CloudFixture f{std::move(factory), factory_raw}; + + auto t0 = std::chrono::system_clock::now(); + f.ctx.PumpAt(t0, 16); + TEST_ASSERT_TRUE_MESSAGE(factory_raw->attempts >= 1, + "expected first attempt"); + TEST_ASSERT_TRUE_MESSAGE(f.AnyQuarantined(), "expected quarantine"); + TEST_ASSERT_EQUAL_UINT(0, f.connections->count_connections()); + auto attempts_after_first = factory_raw->attempts; + + f.ctx.PumpAt(t0, 128); + TEST_ASSERT_EQUAL_INT(attempts_after_first, factory_raw->attempts); + + auto t_before = + t0 + std::chrono::milliseconds{AE_CLOUD_SERVER_QUARANTINE_TIME_MS - 1}; + f.ctx.PumpAt(t_before, 8); + TEST_ASSERT_EQUAL_INT(attempts_after_first, factory_raw->attempts); +} + +void test_CloudQuarantineReleaseAfterExpiry() { + auto factory = std::make_unique(); + auto* factory_raw = factory.get(); + CloudFixture f{std::move(factory), factory_raw}; + + int releases = 0; + auto r_sub = f.connections->server_quarantine_release_event().Subscribe( + [&](CloudServerConnection*) { ++releases; }); + + auto t0 = std::chrono::system_clock::now(); + f.ctx.PumpAt(t0, 16); + TEST_ASSERT_TRUE(f.AnyQuarantined()); + auto attempts_after_first = factory_raw->attempts; + + auto t_release = + t0 + std::chrono::milliseconds{AE_CLOUD_SERVER_QUARANTINE_TIME_MS + 1}; + f.ctx.PumpAt(t_release, 4); + TEST_ASSERT_TRUE_MESSAGE(releases >= 1, "expected quarantine release"); + TEST_ASSERT_TRUE_MESSAGE(factory_raw->attempts > attempts_after_first, + "expected reconnect attempt after release"); + TEST_ASSERT_TRUE_MESSAGE(factory_raw->attempts <= attempts_after_first + 4, + "too many attempts in one release wave"); +} + +void test_CloudQuarantineNoRecursiveLoopSameTimestamp() { + auto factory = std::make_unique(); + auto* factory_raw = factory.get(); + CloudFixture f{std::move(factory), factory_raw}; + + auto t0 = std::chrono::system_clock::now(); + f.ctx.PumpAt(t0, 4); + auto attempts = factory_raw->attempts; + f.ctx.PumpAt(t0, 64); + TEST_ASSERT_EQUAL_INT(attempts, factory_raw->attempts); +} + +void test_CloudQuarantineAttemptsBoundedOverSimulatedSecond() { + auto factory = std::make_unique(); + auto* factory_raw = factory.get(); + CloudFixture f{std::move(factory), factory_raw}; + + auto t0 = std::chrono::system_clock::now(); + f.ctx.PumpAt(t0, 8); + auto attempts_after_first = factory_raw->attempts; + TEST_ASSERT_TRUE(attempts_after_first >= 1); + + // Advance one quarantine period at a time using a fresh wall-clock base so + // newly scheduled DelayedTasks are not immediately due under a far-future + // Update timestamp (no wall-clock sleep). + constexpr auto kWindowMs = 1000; + auto const period = AE_CLOUD_SERVER_QUARANTINE_TIME_MS; + auto const periods = (kWindowMs / period) + 1; + for (int i = 0; i < periods; ++i) { + auto now = std::chrono::system_clock::now(); + f.ctx.PumpAt(now + std::chrono::milliseconds{period + 1}, 2); + } + + auto const max_attempts = attempts_after_first + periods * 2 + 2; + TEST_ASSERT_TRUE_MESSAGE( + factory_raw->attempts <= max_attempts, + "too many attempts across simulated quarantine periods"); + TEST_ASSERT_TRUE_MESSAGE( + factory_raw->attempts > attempts_after_first, + "expected further attempts after quarantine periods"); +} + +void test_CloudFactoryBecomesAvailableAfterFailures() { + auto factory = std::make_unique(); + auto* factory_raw = factory.get(); + CloudFixture f{std::move(factory), factory_raw}; + + auto t0 = std::chrono::system_clock::now(); + f.ctx.PumpAt(t0, 8); + TEST_ASSERT_TRUE(f.AnyQuarantined()); + auto attempts_before = factory_raw->attempts; + + factory_raw->available = true; + auto t_release = + t0 + std::chrono::milliseconds{AE_CLOUD_SERVER_QUARANTINE_TIME_MS + 1}; + f.ctx.PumpAt(t_release, 4); + TEST_ASSERT_TRUE_MESSAGE(factory_raw->attempts > attempts_before, + "release must attempt reconnect after available"); +} + +} // namespace test_cloud_quarantine_loop +} // namespace ae + +int run_test_cloud_quarantine_loop() { + using namespace ae::test_cloud_quarantine_loop; // NOLINT + + UNITY_BEGIN(); + RUN_TEST(test_CloudQuarantineDoesNotBusyLoop); + RUN_TEST(test_CloudQuarantineReleaseAfterExpiry); + RUN_TEST(test_CloudQuarantineNoRecursiveLoopSameTimestamp); + RUN_TEST(test_CloudQuarantineAttemptsBoundedOverSimulatedSecond); + RUN_TEST(test_CloudFactoryBecomesAvailableAfterFailures); + return UNITY_END(); +} diff --git a/tests/test-server-connection/test_server_connection_recovery.cpp b/tests/test-server-connection/test_server_connection_recovery.cpp new file mode 100644 index 00000000..1cf063b9 --- /dev/null +++ b/tests/test-server-connection/test_server_connection_recovery.cpp @@ -0,0 +1,375 @@ +/* + * Copyright 2026 Aethernet Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "aether/adapter_registry.h" +#include "aether/ae_context.h" +#include "aether/channels/channel.h" +#include "aether/config.h" +#include "aether/executors/executors.h" +#include "aether/obj/domain.h" +#include "aether/server.h" +#include "aether/server_connections/server_connection.h" +#include "aether/stream_api/istream.h" +#include "aether/tasks/details/task_subsctiption.h" +#include "aether/types/address.h" +#include "aether/types/server_id.h" +#include "aether/write_action/write_action.h" + +#include "tests/test-object-system/map_domain_storage.h" + +namespace ae { + +struct ServerConnectionTestAccess { + static std::size_t ChannelCount(ServerConnection const& c) { + return c.channels_.size(); + } + static bool ChannelFailed(ServerConnection const& c, std::size_t index) { + return c.channels_.at(index).failed; + } + static std::size_t FailedCount(ServerConnection const& c) { + std::size_t n = 0; + for (auto const& entry : c.channels_) { + if (entry.failed) { + ++n; + } + } + return n; + } + static bool SelectActionFinished(ServerConnection const& c) { + return !c.channel_select_action_ || c.channel_select_action_->is_finished(); + } + static bool SelectActionFinishedFlag(ServerConnection const& c) { + return c.channel_select_action_ && c.channel_select_action_->is_finished(); + } +}; + +namespace test_server_connection_recovery { + +struct TestContext { + AeCtx ToAeContext() const { + static constexpr auto table = + AeCtxTable{nullptr, [](void* obj) -> TaskScheduler& { + return static_cast(obj)->sched; + }}; + return AeCtx{const_cast(this), &table}; // NOLINT + } + + void Pump(int rounds = 64) { + auto now = std::chrono::system_clock::now(); + for (int i = 0; i < rounds; ++i) { + now = sched.Update(now); + if (now == std::chrono::system_clock::time_point::max()) { + now = std::chrono::system_clock::now(); + } + } + } + + TaskScheduler sched; +}; + +class ImmediateWriteAction : public WriteAction { + public: + explicit ImmediateWriteAction(AeContext const& context) { + context.scheduler().Task( + [&]() { WriteAction::SetStatus(Status::kSuccess); }); + } +}; + +class LinkedMockStream final : public ByteIStream { + public: + explicit LinkedMockStream(AeContext const& context) + : context_{context}, + stream_info_{512, 1024, true, LinkState::kLinked, true} {} + + WriteAction& Write(DataBuffer&& /*data*/) override { + return last_action_.emplace(context_); + } + StreamUpdateEvent::Subscriber stream_update_event() override { + return EventSubscriber{stream_update_event_}; + } + StreamInfo stream_info() const override { return stream_info_; } + OutDataEvent::Subscriber out_data_event() override { + return EventSubscriber{out_data_event_}; + } + void Restream() override {} + + private: + AeContext context_; + StreamInfo stream_info_; + OutDataEvent out_data_event_; + StreamUpdateEvent stream_update_event_; + std::optional last_action_; +}; + +struct FakeBuildPolicy { + enum class Mode : std::uint8_t { + kAlwaysFail, + kAlwaysSucceed, + kFailThenSucceed + }; + Mode mode{Mode::kAlwaysFail}; + int* builds{nullptr}; + AeContext const* context{nullptr}; +}; + +class FakeChannel final : public Channel { + AE_OBJECT(FakeChannel, Channel, 0) + + protected: + FakeChannel() = default; + + public: + FakeChannel(ObjProp prop, FakeBuildPolicy policy) + : Channel{prop}, policy_{policy} { + transport_properties_.max_packet_size = 1024; + transport_properties_.rec_packet_size = 512; + transport_properties_.connection_type = ConnectionType::kConnectionFull; + transport_properties_.reliability = Reliability::kReliable; + } + + AE_OBJECT_REFLECT() + + TransportBuildSender TransportBuilder() override { + if (policy_.builds != nullptr) { + ++(*policy_.builds); + } + auto const builds = policy_.builds != nullptr ? *policy_.builds : 1; + bool succeed = false; + switch (policy_.mode) { + case FakeBuildPolicy::Mode::kAlwaysSucceed: + succeed = true; + break; + case FakeBuildPolicy::Mode::kFailThenSucceed: + succeed = builds > 1; + break; + case FakeBuildPolicy::Mode::kAlwaysFail: + default: + succeed = false; + break; + } + if (!succeed) { + return ex::just_error(1); + } + assert(policy_.context != nullptr); + return ex::just(std::unique_ptr{ + std::make_unique(*policy_.context)}); + } + + Duration TransportBuildTimeout() const override { + return std::chrono::milliseconds{50}; + } + Duration ResponseTimeout() const override { + return std::chrono::milliseconds{50}; + } + + private: + FakeBuildPolicy policy_; +}; + +Server::ptr MakeServerWithChannels(Domain& domain, + std::vector channels) { + auto registry = AdapterRegistry::ptr::Create(CreateWith{domain}); + auto server = Server::ptr::Create(CreateWith{domain}, ServerId{1}, + std::vector{}, registry); + for (auto& channel : channels) { + server->channels.emplace_back(channel); + } + return server; +} + +void test_SingleChannelBuildFailureReachesLinkError() { + TestContext ctx; + AeContext ae_ctx{ctx}; + MapDomainStorage storage; + Domain domain{Now(), storage}; + + int builds = 0; + FakeBuildPolicy policy{FakeBuildPolicy::Mode::kAlwaysFail, &builds, &ae_ctx}; + auto channel = FakeChannel::ptr::Create(CreateWith{domain}, policy); + auto server = MakeServerWithChannels(domain, {channel}); + + int server_errors = 0; + LinkState last_state = LinkState::kUnlinked; + bool last_writable = true; + bool finished_during_result = false; + + ServerConnection connection{ae_ctx, server.Load()}; + auto err_sub = + connection.server_error_event().Subscribe([&]() { ++server_errors; }); + auto upd_sub = connection.stream_update_event().Subscribe([&]() { + last_state = connection.stream_info().link_state; + last_writable = connection.stream_info().is_writable; + }); + // Re-subscribe is not available after construction; verify finished flag + // after pump and via access helper during ChannelBuildFailed path. + finished_during_result = + ServerConnectionTestAccess::SelectActionFinished(connection); + + ctx.Pump(); + + TEST_ASSERT_TRUE( + ServerConnectionTestAccess::SelectActionFinished(connection)); + TEST_ASSERT_EQUAL_UINT(1, + ServerConnectionTestAccess::FailedCount(connection)); + TEST_ASSERT_TRUE(ServerConnectionTestAccess::ChannelFailed(connection, 0)); + TEST_ASSERT_EQUAL(static_cast(LinkState::kLinkError), + static_cast(connection.stream_info().link_state)); + TEST_ASSERT_FALSE(connection.stream_info().is_writable); + TEST_ASSERT_EQUAL(1, server_errors); + TEST_ASSERT_EQUAL(static_cast(LinkState::kLinkError), + static_cast(last_state)); + TEST_ASSERT_FALSE(last_writable); + TEST_ASSERT_EQUAL(1, builds); + (void)finished_during_result; +} + +void test_ResultCallbackSeesFinishedAction() { + TestContext ctx; + AeContext ae_ctx{ctx}; + MapDomainStorage storage; + Domain domain{Now(), storage}; + + int builds = 0; + FakeBuildPolicy policy{FakeBuildPolicy::Mode::kAlwaysFail, &builds, &ae_ctx}; + auto channel = FakeChannel::ptr::Create(CreateWith{domain}, policy); + auto server = MakeServerWithChannels(domain, {channel}); + + ServerConnection connection{ae_ctx, server.Load()}; + TEST_ASSERT_TRUE_MESSAGE( + ServerConnectionTestAccess::SelectActionFinishedFlag(connection), + "sync failure must finish action before pump"); + ctx.Pump(16); + TEST_ASSERT_EQUAL(static_cast(LinkState::kLinkError), + static_cast(connection.stream_info().link_state)); +} + +void test_SecondChannelSucceedsAfterFirstFailure() { + TestContext ctx; + AeContext ae_ctx{ctx}; + MapDomainStorage storage; + Domain domain{Now(), storage}; + + int builds = 0; + FakeBuildPolicy policy{FakeBuildPolicy::Mode::kFailThenSucceed, &builds, + &ae_ctx}; + auto ch0 = FakeChannel::ptr::Create(CreateWith{domain}, policy); + auto ch1 = FakeChannel::ptr::Create(CreateWith{domain}, policy); + auto server = MakeServerWithChannels(domain, {ch0, ch1}); + + ServerConnection connection{ae_ctx, server.Load()}; + ctx.Pump(); + + TEST_ASSERT_TRUE( + ServerConnectionTestAccess::SelectActionFinished(connection)); + TEST_ASSERT_EQUAL_UINT(1, + ServerConnectionTestAccess::FailedCount(connection)); + TEST_ASSERT_EQUAL(static_cast(LinkState::kLinked), + static_cast(connection.stream_info().link_state)); + TEST_ASSERT_TRUE(connection.stream_info().is_writable); + TEST_ASSERT_EQUAL(2, builds); +} + +void test_FullPoolStillReachesLinkError() { + TestContext ctx; + AeContext ae_ctx{ctx}; + MapDomainStorage storage; + Domain domain{Now(), storage}; + + // Leave a few slots for transport timeout plumbing; keep the rest occupied + // with active delayed tasks so deferred reselect cannot allocate. + static constexpr auto kBlockers = + AE_TASK_MAX_COUNT > 8 ? AE_TASK_MAX_COUNT - 8 : AE_TASK_MAX_COUNT / 2; + std::array blockers{}; + for (auto& sub : blockers) { + sub = ctx.sched.DelayedTask([]() {}, std::chrono::seconds{60}); + TEST_ASSERT_TRUE_MESSAGE(static_cast(sub), + "expected to fill scheduler pool"); + } + + int builds = 0; + FakeBuildPolicy policy{FakeBuildPolicy::Mode::kAlwaysFail, &builds, &ae_ctx}; + auto channel = FakeChannel::ptr::Create(CreateWith{domain}, policy); + auto server = MakeServerWithChannels(domain, {channel}); + + int server_errors = 0; + ServerConnection connection{ae_ctx, server.Load()}; + auto err_sub = + connection.server_error_event().Subscribe([&]() { ++server_errors; }); + + // Exhaust remaining slots so DeferSelectChannel / DeferServerError fail. + std::vector extra; + for (;;) { + auto sub = ctx.sched.DelayedTask([]() {}, std::chrono::seconds{60}); + if (!sub) { + break; + } + extra.push_back(std::move(sub)); + } + + // Force the deferred path by pumping once if needed; if schedule already + // failed during ChannelBuildFailed, link error is already set. + ctx.Pump(8); + + TEST_ASSERT_TRUE( + ServerConnectionTestAccess::SelectActionFinished(connection)); + TEST_ASSERT_EQUAL(static_cast(LinkState::kLinkError), + static_cast(connection.stream_info().link_state)); + TEST_ASSERT_TRUE_MESSAGE(server_errors >= 1, "expected server error"); + TEST_ASSERT_EQUAL(1, builds); +} + +void test_NoBusyLoopOnPermanentFailure() { + TestContext ctx; + AeContext ae_ctx{ctx}; + MapDomainStorage storage; + Domain domain{Now(), storage}; + + int builds = 0; + FakeBuildPolicy policy{FakeBuildPolicy::Mode::kAlwaysFail, &builds, &ae_ctx}; + auto channel = FakeChannel::ptr::Create(CreateWith{domain}, policy); + auto server = MakeServerWithChannels(domain, {channel}); + + ServerConnection connection{ae_ctx, server.Load()}; + ctx.Pump(256); + + TEST_ASSERT_EQUAL(1, builds); + TEST_ASSERT_EQUAL(static_cast(LinkState::kLinkError), + static_cast(connection.stream_info().link_state)); +} + +} // namespace test_server_connection_recovery +} // namespace ae + +int run_test_server_connection_recovery() { + using namespace ae::test_server_connection_recovery; // NOLINT + UNITY_BEGIN(); + RUN_TEST(test_SingleChannelBuildFailureReachesLinkError); + RUN_TEST(test_ResultCallbackSeesFinishedAction); + RUN_TEST(test_SecondChannelSucceedsAfterFirstFailure); + RUN_TEST(test_FullPoolStillReachesLinkError); + RUN_TEST(test_NoBusyLoopOnPermanentFailure); + return UNITY_END(); +} diff --git a/tests/test-tasks/test-manual-task-scheduler.cpp b/tests/test-tasks/test-manual-task-scheduler.cpp index 01727c5b..bfc43a51 100644 --- a/tests/test-tasks/test-manual-task-scheduler.cpp +++ b/tests/test-tasks/test-manual-task-scheduler.cpp @@ -16,9 +16,13 @@ #include #include +#include +#include #include +#include #include "aether/tasks/details/manual_task_scheduler.h" +#include "aether/tasks/details/task_subsctiption.h" namespace ae::test_manual_task_scheduler { using namespace std::chrono_literals; @@ -138,6 +142,148 @@ void test_DelayedTiming() { TEST_ASSERT_TRUE(invoked); } +void test_ReclaimInactiveOnTaskWithoutUpdate() { + static constexpr auto kCount = 8; + auto task_sched = ManualTaskScheduler>{}; + + std::array subs{}; + for (auto i = 0; i < kCount; ++i) { + subs[i] = task_sched.DelayedTask([]() {}, 60s); + TEST_ASSERT_TRUE_MESSAGE(static_cast(subs[i]), + "expected delayed task allocation"); + } + TEST_ASSERT_TRUE_MESSAGE(task_sched.Task([]() {}) == nullptr, + "pool should be full before reclaim"); + + for (auto& sub : subs) { + sub.Reset(); + } + + // Next Task() must reclaim cancelled delayed tasks without calling Update(). + auto again = task_sched.Task([]() {}); + TEST_ASSERT_TRUE_MESSAGE(static_cast(again), + "Task() must reclaim cancelled delayed slots"); + (void)task_sched.Update(Now()); +} + +void test_ActiveDelayedTasksBlockAllocation() { + static constexpr auto kCount = 8; + auto task_sched = ManualTaskScheduler>{}; + + std::array subs{}; + for (auto i = 0; i < kCount; ++i) { + subs[i] = task_sched.DelayedTask([]() {}, 60s); + TEST_ASSERT_TRUE_MESSAGE(static_cast(subs[i]), + "expected delayed task allocation"); + } + + TEST_ASSERT_TRUE_MESSAGE(task_sched.Task([]() {}) == nullptr, + "active delayed tasks must keep pool full"); + TEST_ASSERT_TRUE_MESSAGE(task_sched.DelayedTask([]() {}, 60s) == nullptr, + "active delayed tasks must keep pool full"); + + for (auto& sub : subs) { + TEST_ASSERT_TRUE_MESSAGE(static_cast(sub), + "existing delayed subscriptions must stay valid"); + } +} + +void test_ResetUpdateRestoresAllSlots() { + static constexpr auto kCount = 8; + auto task_sched = ManualTaskScheduler>{}; + + std::array subs{}; + for (auto i = 0; i < kCount; ++i) { + subs[i] = task_sched.DelayedTask([]() {}, 60s); + TEST_ASSERT_TRUE(static_cast(subs[i])); + } + for (auto& sub : subs) { + sub.Reset(); + } + (void)task_sched.Update(Now()); + + std::array again{}; + for (auto i = 0; i < kCount; ++i) { + again[i] = task_sched.Task([]() {}); + TEST_ASSERT_TRUE_MESSAGE(static_cast(again[i]), + "all slots must be available after Reset+Update"); + } + (void)task_sched.Update(Now()); +} + +void test_MultithreadCancelledDelayedVsRegular() { + static constexpr auto kPool = 32; + static constexpr auto kIterations = 200; + auto task_sched = ManualTaskScheduler>{}; + + std::atomic_bool start{false}; + std::atomic_bool stop{false}; + std::atomic_bool consumer_done{false}; + std::atomic_int accepted_regular{0}; + std::atomic_int executed_regular{0}; + std::mutex regular_subs_mu; + std::vector regular_subs; + regular_subs.reserve(kIterations + kPool); + + auto producer = std::thread{[&]() { + while (!start.load(std::memory_order::acquire)) { + } + for (int i = 0; i < kIterations; ++i) { + TaskSubscription sub = task_sched.DelayedTask([]() {}, 60s); + if (sub) { + sub.Reset(); + } + } + stop.store(true, std::memory_order::release); + }}; + + auto consumer = std::thread{[&]() { + while (!start.load(std::memory_order::acquire)) { + } + while (!stop.load(std::memory_order::acquire)) { + TaskSubscription sub = + task_sched.Task([&]() { executed_regular.fetch_add(1); }); + if (sub) { + accepted_regular.fetch_add(1); + std::scoped_lock lock{regular_subs_mu}; + regular_subs.push_back(std::move(sub)); + } + } + for (int i = 0; i < kPool; ++i) { + TaskSubscription sub = + task_sched.Task([&]() { executed_regular.fetch_add(1); }); + if (sub) { + accepted_regular.fetch_add(1); + std::scoped_lock lock{regular_subs_mu}; + regular_subs.push_back(std::move(sub)); + } + } + consumer_done.store(true, std::memory_order::release); + }}; + + auto updater = std::thread{[&]() { + while (!start.load(std::memory_order::acquire)) { + } + while (!consumer_done.load(std::memory_order::acquire) || + executed_regular.load() < accepted_regular.load()) { + (void)task_sched.Update(Now()); + } + (void)task_sched.Update(Now()); + }}; + + start.store(true, std::memory_order::release); + producer.join(); + consumer.join(); + updater.join(); + + TEST_ASSERT_EQUAL_MESSAGE(accepted_regular.load(), executed_regular.load(), + "every accepted regular task must execute"); + TaskSubscription probe = task_sched.Task([]() {}); + TEST_ASSERT_TRUE_MESSAGE(static_cast(probe), + "pool must not stay exhausted by cancelled delayed"); + (void)task_sched.Update(Now()); +} + } // namespace ae::test_manual_task_scheduler int test_manual_task_scheduler() { @@ -145,5 +291,12 @@ int test_manual_task_scheduler() { RUN_TEST(ae::test_manual_task_scheduler::test_ManualScheduler); RUN_TEST(ae::test_manual_task_scheduler::test_Multithread); RUN_TEST(ae::test_manual_task_scheduler::test_DelayedTiming); + RUN_TEST( + ae::test_manual_task_scheduler::test_ReclaimInactiveOnTaskWithoutUpdate); + RUN_TEST( + ae::test_manual_task_scheduler::test_ActiveDelayedTasksBlockAllocation); + RUN_TEST(ae::test_manual_task_scheduler::test_ResetUpdateRestoresAllSlots); + RUN_TEST(ae::test_manual_task_scheduler:: + test_MultithreadCancelledDelayedVsRegular); return UNITY_END(); } diff --git a/tools/android_ndk/build_android_matrix.ps1 b/tools/android_ndk/build_android_matrix.ps1 new file mode 100644 index 00000000..c15229d2 --- /dev/null +++ b/tools/android_ndk/build_android_matrix.ps1 @@ -0,0 +1,58 @@ +# Copyright 2026 Aethernet Inc. +# +# Build the required Android NDK matrix. + +[CmdletBinding()] +param( + [string]$CMakeVersion = "", + + [string]$NdkVersion = "", + + [switch]$Clean +) + +$ErrorActionPreference = "Stop" + +$script_dir = Split-Path -Parent $PSCommandPath +$build_script = Join-Path $script_dir "build_android_ndk.ps1" + +$matrix = @( + @{ Abi = "x86_64"; Config = "Debug"; UserConfig = "config/user_config_hydrogen.h"; Smoke = $true }, + @{ Abi = "x86_64"; Config = "Debug"; UserConfig = "config/user_config_sodium.h"; Smoke = $true }, + @{ Abi = "arm64-v8a"; Config = "Debug"; UserConfig = "config/user_config_hydrogen.h"; Smoke = $true }, + @{ Abi = "arm64-v8a"; Config = "Debug"; UserConfig = "config/user_config_sodium.h"; Smoke = $true }, + @{ Abi = "x86_64"; Config = "Release"; UserConfig = "config/user_config_android_smoke.h"; Smoke = $true }, + @{ Abi = "arm64-v8a"; Config = "Release"; UserConfig = "config/user_config_android_smoke.h"; Smoke = $true } +) + +foreach ($entry in $matrix) { + Write-Host "============================================================" + Write-Host ("Matrix: {0} {1} {2}" -f $entry.Abi, $entry.Config, $entry.UserConfig) + Write-Host "============================================================" + + $invoke_args = @{ + Abi = $entry.Abi + Config = $entry.Config + UserConfig = $entry.UserConfig + } + if ($entry.Smoke) { + $invoke_args.BuildSmoke = $true + } + if ($Clean) { + $invoke_args.Clean = $true + } + if ($CMakeVersion) { + $invoke_args.CMakeVersion = $CMakeVersion + } + if ($NdkVersion) { + $invoke_args.NdkVersion = $NdkVersion + } + + & $build_script @invoke_args + if ($LASTEXITCODE -ne 0) { + throw "Matrix entry failed: $($entry.Abi) $($entry.Config) $($entry.UserConfig)" + } +} + +Write-Host "Android NDK matrix completed successfully." +exit 0 diff --git a/tools/android_ndk/build_android_ndk.ps1 b/tools/android_ndk/build_android_ndk.ps1 new file mode 100644 index 00000000..6f992b7d --- /dev/null +++ b/tools/android_ndk/build_android_ndk.ps1 @@ -0,0 +1,285 @@ +# Copyright 2026 Aethernet Inc. +# +# Build aether (and optional Android NDK smoke targets) with the Android NDK. + +[CmdletBinding()] +param( + [ValidateSet("x86_64", "arm64-v8a")] + [string]$Abi = "x86_64", + + [ValidateSet("Debug", "Release")] + [string]$Config = "Debug", + + [string]$UserConfig = "config/user_config_hydrogen.h", + + [string]$AndroidPlatform = "android-24", + + [string]$AndroidStl = "c++_static", + + [string]$CMakeVersion = "", + + [string]$NdkVersion = "", + + [switch]$BuildSmoke, + + [switch]$Clean +) + +$ErrorActionPreference = "Stop" + +function Test-StableVersionLabel([string]$Label) { + return $Label -notmatch "(?i)(rc|beta|alpha|preview)" +} + +function Get-CMakeVersionString([string]$CMakeExe) { + $text = & $CMakeExe --version 2>&1 | Out-String + if ($text -match "cmake version\s+(\S+)") { + return $Matches[1] + } + throw "Unable to parse CMake version from $CMakeExe" +} + +function Resolve-RepoRoot { + $script_dir = Split-Path -Parent $PSCommandPath + return (Resolve-Path (Join-Path $script_dir "..\..")).Path +} + +function Resolve-AndroidSdk { + if ($env:ANDROID_SDK_ROOT -and (Test-Path $env:ANDROID_SDK_ROOT)) { + return (Resolve-Path $env:ANDROID_SDK_ROOT).Path + } + if ($env:ANDROID_HOME -and (Test-Path $env:ANDROID_HOME)) { + return (Resolve-Path $env:ANDROID_HOME).Path + } + $default = Join-Path $env:LOCALAPPDATA "Android\Sdk" + if (Test-Path $default) { + return (Resolve-Path $default).Path + } + throw "Android SDK not found. Set ANDROID_SDK_ROOT or install Android Studio SDK." +} + +function Resolve-Ndk([string]$SdkRoot, [string]$RequestedVersion) { + $ndk_root = Join-Path $SdkRoot "ndk" + if (-not (Test-Path $ndk_root)) { + throw "No side-by-side NDK under $ndk_root" + } + + if ($RequestedVersion) { + $exact = Join-Path $ndk_root $RequestedVersion + $toolchain = Join-Path $exact "build\cmake\android.toolchain.cmake" + if (-not (Test-Path $toolchain)) { + throw "Requested NDK version '$RequestedVersion' not found or incomplete under $ndk_root" + } + return $exact + } + + $versions = Get-ChildItem $ndk_root -Directory | + Where-Object { Test-StableVersionLabel $_.Name } | + Sort-Object { [version]($_.Name -replace '[^\d.].*$', '') } -Descending + if (-not $versions) { + throw "No stable NDK version found under $ndk_root" + } + foreach ($v in $versions) { + $toolchain = Join-Path $v.FullName "build\cmake\android.toolchain.cmake" + if (Test-Path $toolchain) { + return $v.FullName + } + } + throw "No fully installed stable NDK with android.toolchain.cmake found." +} + +function Resolve-SdkCMakePackage([string]$SdkRoot, [string]$RequestedVersion) { + $cmake_root = Join-Path $SdkRoot "cmake" + if (-not (Test-Path $cmake_root)) { + return $null + } + + if ($RequestedVersion) { + $exact_dir = Join-Path $cmake_root $RequestedVersion + $exact_cmake = Join-Path $exact_dir "bin\cmake.exe" + if (-not (Test-Path $exact_cmake)) { + throw "Requested CMake version '$RequestedVersion' not found under $cmake_root" + } + if (-not (Test-StableVersionLabel $RequestedVersion)) { + throw "Requested CMake version '$RequestedVersion' is not a stable release label" + } + $ninja = Join-Path $exact_dir "bin\ninja.exe" + return [pscustomobject]@{ + CMake = $exact_cmake + Ninja = $(if (Test-Path $ninja) { $ninja } else { $null }) + VersionLabel = $RequestedVersion + Source = "Android SDK" + } + } + + $packages = Get-ChildItem $cmake_root -Directory | + Where-Object { + (Test-StableVersionLabel $_.Name) -and + (Test-Path (Join-Path $_.FullName "bin\cmake.exe")) + } | + Sort-Object { [version]($_.Name -replace '[^\d.].*$', '') } -Descending + + if (-not $packages) { + return $null + } + + $best = $packages[0] + $cmake = Join-Path $best.FullName "bin\cmake.exe" + $ninja = Join-Path $best.FullName "bin\ninja.exe" + return [pscustomobject]@{ + CMake = $cmake + Ninja = $(if (Test-Path $ninja) { $ninja } else { $null }) + VersionLabel = $best.Name + Source = "Android SDK" + } +} + +function Resolve-CMakeAndNinja([string]$SdkRoot, [string]$RequestedCMakeVersion) { + $sdk_pkg = Resolve-SdkCMakePackage $SdkRoot $RequestedCMakeVersion + if ($sdk_pkg) { + $version = Get-CMakeVersionString $sdk_pkg.CMake + if (-not (Test-StableVersionLabel $version)) { + throw "Selected SDK CMake reports non-stable version '$version'" + } + if (-not $sdk_pkg.Ninja) { + throw "Ninja.exe missing next to selected SDK CMake $($sdk_pkg.CMake)" + } + return [pscustomobject]@{ + CMake = $sdk_pkg.CMake + Ninja = $sdk_pkg.Ninja + CMakeVersion = $version + Source = $sdk_pkg.Source + } + } + + if ($RequestedCMakeVersion) { + throw "Requested CMake version '$RequestedCMakeVersion' was not found in Android SDK cmake packages" + } + + $path_cmake = Get-Command cmake -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source + if ($path_cmake -and (Test-Path $path_cmake)) { + $version = Get-CMakeVersionString $path_cmake + if (Test-StableVersionLabel $version) { + $path_ninja = Get-Command ninja -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source + if (-not $path_ninja) { + throw "Stable CMake found on PATH ($path_cmake) but Ninja was not found on PATH" + } + return [pscustomobject]@{ + CMake = $path_cmake + Ninja = $path_ninja + CMakeVersion = $version + Source = "PATH" + } + } + Write-Host "Ignoring non-stable CMake on PATH: $path_cmake ($version)" + } + + $pf_cmake = "C:\Program Files\CMake\bin\cmake.exe" + if (Test-Path $pf_cmake) { + $version = Get-CMakeVersionString $pf_cmake + if (Test-StableVersionLabel $version) { + $path_ninja = Get-Command ninja -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source + if (-not $path_ninja) { + throw "Stable CMake found in Program Files but Ninja was not found on PATH" + } + return [pscustomobject]@{ + CMake = $pf_cmake + Ninja = $path_ninja + CMakeVersion = $version + Source = "Program Files" + } + } + Write-Host "Ignoring non-stable CMake in Program Files: $pf_cmake ($version)" + } + + throw "No stable CMake found in Android SDK, PATH, or Program Files." +} + +function Get-BuildDirName([string]$Abi, [string]$Config, [string]$UserConfig) { + $cfg_leaf = [System.IO.Path]::GetFileNameWithoutExtension($UserConfig) + return "build-android-$Abi-$Config-$cfg_leaf" +} + +$repo_root = Resolve-RepoRoot +$sdk = Resolve-AndroidSdk +$ndk = Resolve-Ndk $sdk $NdkVersion +$tools = Resolve-CMakeAndNinja $sdk $CMakeVersion +$cmake = $tools.CMake +$ninja = $tools.Ninja +$toolchain = Join-Path $ndk "build\cmake\android.toolchain.cmake" +$build_dir = Join-Path $repo_root (Get-BuildDirName $Abi $Config $UserConfig) + +Write-Host "Selected Android SDK : $sdk" +Write-Host "Selected NDK : $ndk" +Write-Host "Selected CMake : $cmake" +Write-Host "Selected Ninja : $ninja" +Write-Host "CMake version : $($tools.CMakeVersion)" +Write-Host "CMake source : $($tools.Source)" +Write-Host "Repository root : $repo_root" +Write-Host "ABI : $Abi" +Write-Host "Config : $Config" +Write-Host "USER_CONFIG : $UserConfig" +Write-Host "Build directory : $build_dir" +Write-Host "Build smoke : $BuildSmoke" + +& $cmake --version +$ninja_version = & $ninja --version +Write-Host "Ninja version : $ninja_version" + +if ($Clean -and (Test-Path $build_dir)) { + Write-Host "Cleaning $build_dir" + Remove-Item -Recurse -Force $build_dir +} + +New-Item -ItemType Directory -Force -Path $build_dir | Out-Null + +$configure_args = @( + "-S", $repo_root, + "-B", $build_dir, + "-G", "Ninja", + "-DCMAKE_TOOLCHAIN_FILE=$toolchain", + "-DANDROID_ABI=$Abi", + "-DANDROID_PLATFORM=$AndroidPlatform", + "-DANDROID_STL=$AndroidStl", + "-DCMAKE_BUILD_TYPE=$Config", + "-DCMAKE_POSITION_INDEPENDENT_CODE=ON", + "-DCMAKE_MAKE_PROGRAM=$ninja", + "-DAE_BUILD_TOOLS=OFF", + "-DAE_BUILD_EXAMPLES=OFF", + "-DAE_BUILD_TESTS=OFF", + "-DAE_INSTALL=OFF", + "-DAE_DISTILLATION=ON", + "-DAE_FILTRATION=ON", + "-DUSER_CONFIG=$UserConfig" +) + +if ($BuildSmoke) { + $configure_args += "-DAE_BUILD_ANDROID_SMOKE=ON" +} else { + $configure_args += "-DAE_BUILD_ANDROID_SMOKE=OFF" +} + +Write-Host "Configure command:" +Write-Host (" {0} {1}" -f $cmake, ($configure_args -join " ")) +& $cmake @configure_args +if ($LASTEXITCODE -ne 0) { + throw "CMake configure failed with exit code $LASTEXITCODE" +} + +$targets = @("aether") +if ($BuildSmoke) { + $targets += @("aether_android_smoke", "aether_android_smoke_runner") +} + +foreach ($target in $targets) { + Write-Host "Build command:" + Write-Host (" {0} --build {1} --target {2} --parallel" -f $cmake, $build_dir, $target) + & $cmake --build $build_dir --target $target --parallel + if ($LASTEXITCODE -ne 0) { + throw "Build of target $target failed with exit code $LASTEXITCODE" + } +} + +Write-Host "Android NDK build succeeded." +Write-Host "Artifacts under: $build_dir" +exit 0 diff --git a/tools/android_ndk/run_android_smoke.ps1 b/tools/android_ndk/run_android_smoke.ps1 new file mode 100644 index 00000000..1ab72afc --- /dev/null +++ b/tools/android_ndk/run_android_smoke.ps1 @@ -0,0 +1,324 @@ +# Copyright 2026 Aethernet Inc. +# +# Build (if needed) and run the x86_64 Android NDK smoke on an emulator. + +[CmdletBinding()] +param( + [string]$AvdName = "", + + [string]$CMakeVersion = "", + + [string]$NdkVersion = "", + + [switch]$SkipBuild +) + +$ErrorActionPreference = "Stop" + +function Resolve-RepoRoot { + $script_dir = Split-Path -Parent $PSCommandPath + return (Resolve-Path (Join-Path $script_dir "..\..")).Path +} + +function Resolve-AndroidSdk { + if ($env:ANDROID_SDK_ROOT -and (Test-Path $env:ANDROID_SDK_ROOT)) { + return (Resolve-Path $env:ANDROID_SDK_ROOT).Path + } + if ($env:ANDROID_HOME -and (Test-Path $env:ANDROID_HOME)) { + return (Resolve-Path $env:ANDROID_HOME).Path + } + $default = Join-Path $env:LOCALAPPDATA "Android\Sdk" + if (Test-Path $default) { + return (Resolve-Path $default).Path + } + throw "Android SDK not found." +} + +function Get-Tool([string]$Sdk, [string]$Relative) { + $path = Join-Path $Sdk $Relative + if (-not (Test-Path $path)) { + throw "Required tool not found: $path" + } + return $path +} + +function Get-AdbDevices([string]$Adb) { + $lines = & $Adb devices | Select-Object -Skip 1 + $devices = @() + foreach ($line in $lines) { + if ($line -match "^\s*$") { continue } + if ($line -match "^(\S+)\s+device\s*$") { + $devices += $Matches[1] + } + } + return $devices +} + +function Wait-BootCompleted([string]$Adb, [string]$Serial, [int]$TimeoutSec = 300) { + & $Adb -s $Serial wait-for-device + $deadline = (Get-Date).AddSeconds($TimeoutSec) + while ((Get-Date) -lt $deadline) { + $boot = (& $Adb -s $Serial shell getprop sys.boot_completed).Trim() + if ($boot -eq "1") { + return + } + Start-Sleep -Seconds 2 + } + throw "Timed out waiting for sys.boot_completed on $Serial" +} + +function Get-AvdConfigPath([string]$Avd) { + $ini = Join-Path $env:USERPROFILE ".android\avd\$Avd.ini" + if (Test-Path $ini) { + $path_line = Get-Content $ini | Where-Object { $_ -match '^path\s*=' } | Select-Object -First 1 + if ($path_line -match '^path\s*=\s*(.+)$') { + $cfg = Join-Path $Matches[1].Trim() "config.ini" + if (Test-Path $cfg) { + return $cfg + } + } + } + $direct = Join-Path $env:USERPROFILE ".android\avd\$Avd.avd\config.ini" + if (Test-Path $direct) { + return $direct + } + throw "AVD config.ini not found for '$Avd'" +} + +function Get-AvdAbi([string]$ConfigPath) { + $abi_line = Get-Content $ConfigPath | Where-Object { $_ -match '^\s*abi\.type\s*=' } | Select-Object -First 1 + if (-not $abi_line) { + throw "abi.type not found in $ConfigPath" + } + if ($abi_line -match '^\s*abi\.type\s*=\s*(.+)$') { + return $Matches[1].Trim() + } + throw "Unable to parse abi.type from $ConfigPath" +} + +function Find-X86_64Avd([string]$Emulator, [string]$PreferredName) { + $avds = @(& $Emulator -list-avds) + if ($PreferredName) { + if ($avds -notcontains $PreferredName) { + throw "Requested AVD '$PreferredName' does not exist" + } + $cfg = Get-AvdConfigPath $PreferredName + $abi = Get-AvdAbi $cfg + if ($abi -ne "x86_64") { + throw "Requested AVD '$PreferredName' has abi.type=$abi; required x86_64" + } + return [pscustomobject]@{ + Name = $PreferredName + ConfigPath = $cfg + Abi = $abi + } + } + + foreach ($avd in $avds) { + try { + $cfg = Get-AvdConfigPath $avd + $abi = Get-AvdAbi $cfg + if ($abi -eq "x86_64") { + return [pscustomobject]@{ + Name = $avd + ConfigPath = $cfg + Abi = $abi + } + } + Write-Host "Skipping AVD $avd (abi.type=$abi)" + } catch { + Write-Host "Skipping AVD $avd ($($_.Exception.Message))" + } + } + return $null +} + +function New-X86_64Avd([string]$AvdManager, [string]$SdkManager, [string]$Name) { + Write-Host "No x86_64 AVD found; creating $Name if an x86_64 system image is installed." + + $packages = & $SdkManager --list_installed 2>&1 | Out-String + if ($packages -notmatch "system-images;android-\d+;google_apis;x86_64|system-images;android-\d+;default;x86_64") { + throw "No x86_64 system image installed; cannot create AVD $Name" + } + + $image_line = ($packages -split "`n" | Where-Object { + $_ -match "system-images;android-(\d+);(google_apis|default);x86_64" + } | Select-Object -Last 1) + if (-not $image_line) { + throw "Failed to parse installed x86_64 system image package." + } + if ($image_line -notmatch "(system-images;android-\d+;(google_apis|default);x86_64)") { + throw "Failed to extract x86_64 system image package name." + } + $package = $Matches[1] + Write-Host "Creating AVD $Name from $package" + $null | & $AvdManager create avd -n $Name -k $package --device "pixel_6" --force + if ($LASTEXITCODE -ne 0) { + throw "avdmanager create failed with exit code $LASTEXITCODE" + } + + $cfg = Get-AvdConfigPath $Name + $abi = Get-AvdAbi $cfg + if ($abi -ne "x86_64") { + throw "Created AVD $Name but abi.type=$abi" + } + return [pscustomobject]@{ + Name = $Name + ConfigPath = $cfg + Abi = $abi + } +} + +function Invoke-SmokeOnce([string]$Adb, [string]$Serial, [string]$RemoteDir) { + $exit_echo = 'echo __EXIT_CODE__:$?' + $cmd = "cd $RemoteDir && chmod 755 aether_android_smoke_runner && LD_LIBRARY_PATH=$RemoteDir ./aether_android_smoke_runner $RemoteDir/libaether_android_smoke.so; $exit_echo" + $output = & $Adb -s $Serial shell $cmd 2>&1 | Out-String + Write-Host '----- smoke stdout/stderr -----' + Write-Host $output + Write-Host '----- end smoke output -----' + + if ($output -notmatch "AETHER_ANDROID_SMOKE_START") { + throw "Missing marker AETHER_ANDROID_SMOKE_START" + } + if ($output -notmatch "AETHER_ANDROID_APP_CONSTRUCTED") { + throw "Missing marker AETHER_ANDROID_APP_CONSTRUCTED" + } + if ($output -notmatch "AETHER_ANDROID_UPDATE_OK") { + throw "Missing marker AETHER_ANDROID_UPDATE_OK" + } + if ($output -notmatch "AETHER_ANDROID_SMOKE_OK") { + throw "Missing marker AETHER_ANDROID_SMOKE_OK" + } + if ($output -notmatch "__EXIT_CODE__:0") { + throw "Smoke runner exit code was not 0" + } + return $output +} + +$repo_root = Resolve-RepoRoot +$sdk = Resolve-AndroidSdk +$adb = Get-Tool $sdk "platform-tools\adb.exe" +$emulator = Get-Tool $sdk "emulator\emulator.exe" +$avdmanager = Join-Path $sdk "cmdline-tools\latest\bin\avdmanager.bat" +$sdkmanager = Join-Path $sdk "cmdline-tools\latest\bin\sdkmanager.bat" +if (-not (Test-Path $avdmanager)) { + $avdmanager = Get-ChildItem (Join-Path $sdk "cmdline-tools") -Recurse -Filter avdmanager.bat | + Select-Object -First 1 -ExpandProperty FullName +} +if (-not (Test-Path $sdkmanager)) { + $sdkmanager = Get-ChildItem (Join-Path $sdk "cmdline-tools") -Recurse -Filter sdkmanager.bat | + Select-Object -First 1 -ExpandProperty FullName +} + +Write-Host "Selected Android SDK : $sdk" +Write-Host "adb version:" +& $adb version + +Write-Host "adb devices:" +& $adb devices + +$serial = $null +$selected_avd = $null +$devices = Get-AdbDevices $adb +foreach ($d in $devices) { + if ($d -match "^emulator-") { + $runtime_abi = (& $adb -s $d shell getprop ro.product.cpu.abi).Trim() + Write-Host "Found emulator $d runtime abi=$runtime_abi" + if ($runtime_abi -eq "x86_64") { + $serial = $d + break + } + } +} + +if (-not $serial) { + $selected_avd = Find-X86_64Avd $emulator $AvdName + if (-not $selected_avd) { + if ($AvdName -and $AvdName -ne "Aether_NDK_Smoke_x86_64") { + throw "Requested AVD '$AvdName' was not usable and auto-create only uses Aether_NDK_Smoke_x86_64" + } + $create_name = if ($AvdName) { $AvdName } else { "Aether_NDK_Smoke_x86_64" } + $selected_avd = New-X86_64Avd $avdmanager $sdkmanager $create_name + } + + Write-Host "Selected AVD : $($selected_avd.Name)" + Write-Host "AVD config path : $($selected_avd.ConfigPath)" + Write-Host "Configured ABI : $($selected_avd.Abi)" + Write-Host "Starting AVD $($selected_avd.Name) (no wipe-data)" + Start-Process -FilePath $emulator -ArgumentList @("-avd", $selected_avd.Name) -WindowStyle Minimized | Out-Null + + $deadline = (Get-Date).AddSeconds(180) + while ((Get-Date) -lt $deadline -and -not $serial) { + Start-Sleep -Seconds 2 + foreach ($d in (Get-AdbDevices $adb)) { + if ($d -match "^emulator-") { + $serial = $d + break + } + } + } + if (-not $serial) { + throw "Failed to see emulator device via adb" + } +} elseif ($AvdName) { + $selected_avd = Find-X86_64Avd $emulator $AvdName + Write-Host "Selected AVD : $($selected_avd.Name)" + Write-Host "AVD config path : $($selected_avd.ConfigPath)" + Write-Host "Configured ABI : $($selected_avd.Abi)" +} + +Wait-BootCompleted $adb $serial +$abi = (& $adb -s $serial shell getprop ro.product.cpu.abi).Trim() +$api = (& $adb -s $serial shell getprop ro.build.version.sdk).Trim() +Write-Host "Running device serial: $serial" +Write-Host "Runtime ABI : $abi" +Write-Host "Runtime API : $api" +if ($abi -ne "x86_64") { + throw "Emulator ABI must be x86_64 for this smoke; got $abi" +} + +$build_dir = Join-Path $repo_root "build-android-x86_64-Release-user_config_android_smoke" +$so = Join-Path $build_dir "android_ndk_smoke\libaether_android_smoke.so" +$runner = Join-Path $build_dir "android_ndk_smoke\aether_android_smoke_runner" + +if (-not $SkipBuild -or -not (Test-Path $so) -or -not (Test-Path $runner)) { + $build_script = Join-Path $repo_root "tools\android_ndk\build_android_ndk.ps1" + $build_args = @{ + Abi = "x86_64" + Config = "Release" + UserConfig = "config/user_config_android_smoke.h" + BuildSmoke = $true + } + if ($CMakeVersion) { $build_args.CMakeVersion = $CMakeVersion } + if ($NdkVersion) { $build_args.NdkVersion = $NdkVersion } + & $build_script @build_args + if ($LASTEXITCODE -ne 0) { + throw "Smoke build failed" + } +} + +if (-not (Test-Path $so)) { + $so = Get-ChildItem $build_dir -Recurse -Filter libaether_android_smoke.so | Select-Object -First 1 -ExpandProperty FullName + $runner = Get-ChildItem $build_dir -Recurse -Filter aether_android_smoke_runner | Select-Object -First 1 -ExpandProperty FullName +} +if (-not $so -or -not $runner -or -not (Test-Path $so) -or -not (Test-Path $runner)) { + throw "Smoke artifacts not found under $build_dir" +} + +$remote_dir = "/data/local/tmp/aether-android-smoke" +& $adb -s $serial shell "mkdir -p $remote_dir" +& $adb -s $serial push $so "$remote_dir/libaether_android_smoke.so" +if ($LASTEXITCODE -ne 0) { throw "adb push .so failed" } +& $adb -s $serial push $runner "$remote_dir/aether_android_smoke_runner" +if ($LASTEXITCODE -ne 0) { throw "adb push runner failed" } + +Write-Host "Running smoke #1" +$out1 = Invoke-SmokeOnce $adb $serial $remote_dir +Write-Host "Running smoke #2" +$out2 = Invoke-SmokeOnce $adb $serial $remote_dir + +Write-Host "Android emulator smoke passed twice." +Write-Host "Running device serial: $serial" +Write-Host "Runtime ABI : $abi" +Write-Host "Runtime API : $api" +exit 0