Compare commits

..
10 Commits
Author SHA1 Message Date
latentprionandCursor e6d9bfe30c SharedResourceGroup: add move constructor for initial resource.
Allows move-only ResourceType values (e.g. containers of unique_ptr) to initialize rsrc without copying.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 19:14:46 -04:00
latentprionandCursor fd2440e4ee EnvKvStore: find/get split, typed int accessors, nest DotenvParser.
Rename optional lookup to find(), add throwing get(), and provide
getInt/getPositiveInt/getPositiveNonZeroInt with optional defaults so
callers own missing-key policy without baking domain period semantics
into spinscale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 19:26:14 -04:00
latentprionandCursor 21fa125d52 Summarize and merge MultiOperationResultSetWithException from settled Groups.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 05:06:18 -04:00
latentprionandCursor 3156652257 Document closeAdmission-before-cancel nursery stop ordering.
Explain the admit-after-cancel race and that daemons should disconnect at
the protocol layer before sealing admission (or handle closed-admission on enqueue).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 19:02:52 -04:00
latentprion 88d6367491 probe: barrier TLS init until make_shared arms shared_from_this.
PuppeteerThread starts its OS thread in the constructor, so initializeTls()
could race and throw bad_weak_ptr; wait for the harness barrier first.
2026-07-12 06:10:41 -04:00
latentprionandCursor 5e6108d396 Migrate test timer awaiters from deadline_timer to steady_timer.
Boost 1.90 marks deadline_timer deprecated; keep historical type aliases so existing spinscale tests need no renames.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 05:22:11 -04:00
latentprionandCursor 3d23ed21be cmake: omit Boost::system on Boost 1.89+ where the compiled stub is gone.
Keep linking Boost::log, and only require Boost::system on older package sets that still ship libboost_system.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 22:27:55 -04:00
latentprionandCursor 15ebf375ef spinscale: split probe harness from test support.
Move ProbeComponentThreadHarness into spinscale_probe_support (sscl::probe) so tools can link it without gtest; keep a sscl::tests compatibility shim.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 22:03:19 -04:00
latentprionandCursor 7c9bec7b9c spinscale: document slot-cancel-before-internal-op-cancel ordering.
Shutdown call sites must set slot cancelers before cancelling timers, I/O,
or hardware capture so callees observe stop intent when unblocked.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 18:26:54 -04:00
hayodeaandCursor 01efbd8c94 Add sscl::co::syncAwaitNonViralCoro for blocking non-viral coroutine launch.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 00:21:45 -04:00
18 changed files with 1212 additions and 318 deletions
+27 -11
View File
@@ -66,13 +66,13 @@ configure_file(
@ONLY
)
# Find dependencies
# Tell CMake we're linking against the shared library (not header-only)
set(Boost_USE_STATIC_LIBS OFF)
set(Boost_USE_HEADER_ONLY OFF)
find_package(Boost REQUIRED COMPONENTS system log)
# Define BOOST_ALL_DYN_LINK project-wide to ensure all Boost libraries use dynamic linking
add_compile_definitions(BOOST_ALL_DYN_LINK)
# Find dependencies (Boost.System optional on 1.89+; see BoostSharedDeps.cmake)
if(EXISTS ${CMAKE_SOURCE_DIR}/cmake/BoostSharedDeps.cmake
AND NOT CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
include(${CMAKE_SOURCE_DIR}/cmake/BoostSharedDeps.cmake)
else()
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/BoostSharedDeps.cmake)
endif()
find_package(Threads REQUIRED)
@@ -87,6 +87,7 @@ add_library(spinscale SHARED
src/puppetApplication.cpp
src/runtime.cpp
src/callableTracer.cpp
src/multiOperationResultSet.cpp
)
set_target_properties(spinscale PROPERTIES
@@ -110,12 +111,12 @@ target_include_directories(spinscale PUBLIC
$<INSTALL_INTERFACE:include>
)
# Link against required dependencies for shared library
# Boost::system is PUBLIC because componentThread.h exposes Boost.Asio types
# Link against required dependencies for shared library.
# BOOST_SHARED_DEP_TARGETS is PUBLIC because componentThread.h exposes Boost.Asio
# types (and Boost::system when a compiled stub still exists).
target_link_libraries(spinscale PUBLIC
Threads::Threads
Boost::system
Boost::log
${BOOST_SHARED_DEP_TARGETS}
)
# Verify Boost dynamic dependencies after build
@@ -162,6 +163,21 @@ endif()
option(LIBSPINSCALE_BUILD_TESTS "Build libspinscale unit tests"
${_libspinscaleTestsDefault})
option(LIBSPINSCALE_BUILD_PROBE_SUPPORT
"Build spinscale probe component-thread harness (tools and tests)"
OFF)
# Tests always need the probe harness; tools may request it via cache/root.
if(LIBSPINSCALE_BUILD_TESTS)
set(LIBSPINSCALE_BUILD_PROBE_SUPPORT ON CACHE BOOL
"Build spinscale probe component-thread harness (tools and tests)"
FORCE)
endif()
if(LIBSPINSCALE_BUILD_PROBE_SUPPORT)
add_subdirectory(probe)
endif()
if(LIBSPINSCALE_BUILD_TESTS)
if(NOT TARGET gtest AND NOT TARGET gtest_main)
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
+6 -4
View File
@@ -202,17 +202,19 @@ nursery.launch(
nvc.checkAndRethrowException();
});
nursery.requestCancelOnAll();
nursery.closeAdmission();
nursery.requestCancelOnAll();
nursery.syncAwaitAllSettlements(
sscl::ComponentThread::getSelf()->getIoContext());
```
Each slot owns a `SyncCancelerForAsyncWork`. `requestCancelOnAll()` only signals
cooperative stop; it does not destroy invokers. Invokers are retired when their
completion callbacks run. Call `closeAdmission()` explicitly before
`asyncAwaitAllSettlements()` or `syncAwaitAllSettlements()`; those APIs wait until
all slots have retired naturally and throw if admission is still open.
completion callbacks run. Call `closeAdmission()` before `requestCancelOnAll()`
so no new work can be admitted after cancel begins, and call `closeAdmission()`
explicitly before `asyncAwaitAllSettlements()` or `syncAwaitAllSettlements()`;
those APIs wait until all slots have retired naturally and throw if admission is
still open.
`syncAwaitAllSettlements()` runs a nested `io_context` loop on the **calling
thread** (it blocks in `run_one()` until every slot has retired). Pass the
+59
View File
@@ -0,0 +1,59 @@
# EXPLANATION:
# Shared Boost deps for standalone or nested libspinscale builds.
# Always require Boost.Log as a shared library. Require Boost.System only on
# versions that still ship a compiled stub (Ubuntu dropped it at Boost 1.89).
#
# Sets BOOST_SHARED_DEP_TARGETS for target_link_libraries(... ${BOOST_SHARED_DEP_TARGETS}).
# Uses Boost:: imported targets (not a project INTERFACE lib) so export sets stay valid.
set(BOOST_COMPILED_SYSTEM_REMOVED_VERSION "1.89.0")
set(BOOST_SHARED_DEPS_MIN_VERSION "1.69")
function(boostSharedDepsComponentsForVersion _boostVersion _outVar)
set(_components log)
if(_boostVersion VERSION_LESS "${BOOST_COMPILED_SYSTEM_REMOVED_VERSION}")
list(APPEND _components system)
endif()
set(${_outVar} ${_components} PARENT_SCOPE)
endfunction()
function(boostSharedDepsLinkTargets _outVar)
set(_targets Boost::log)
if(TARGET Boost::system)
list(APPEND _targets Boost::system)
endif()
set(${_outVar} ${_targets} PARENT_SCOPE)
endfunction()
function(boostSharedDepsReportStatus)
if(TARGET Boost::system)
message(STATUS
"Boost ${Boost_VERSION}: linking Boost::system "
"(compiled stub still present)")
else()
message(STATUS
"Boost ${Boost_VERSION}: omitting Boost::system "
"(header-only; compiled stub removed at "
"${BOOST_COMPILED_SYSTEM_REMOVED_VERSION})")
endif()
endfunction()
if(NOT BOOST_SHARED_DEPS_RESOLVED)
# Prefer shared Boost libs where a compiled component still exists.
set(Boost_USE_STATIC_LIBS OFF)
set(Boost_USE_HEADER_ONLY OFF)
# Resolve version before requesting components so we can skip system on
# Boost 1.89+, where Ubuntu no longer packages libboost_system.
find_package(Boost ${BOOST_SHARED_DEPS_MIN_VERSION} REQUIRED)
boostSharedDepsComponentsForVersion("${Boost_VERSION}" _boostSharedDepsComponents)
find_package(Boost ${BOOST_SHARED_DEPS_MIN_VERSION} REQUIRED
COMPONENTS ${_boostSharedDepsComponents})
boostSharedDepsLinkTargets(BOOST_SHARED_DEP_TARGETS)
set(BOOST_SHARED_DEPS_RESOLVED TRUE)
boostSharedDepsReportStatus()
# Ensure remaining Boost libs (e.g. Log) use dynamic linking.
add_compile_definitions(BOOST_ALL_DYN_LINK)
endif()
+30 -3
View File
@@ -55,9 +55,6 @@ struct MemberInvoker : MemberInvokerBase
* nursery member. The external submitter should add the complete flow to the
* nursery and then return; the nursery owns that flow until the flow settles.
*
* Call closeAdmission() explicitly before asyncAwaitAllSettlements() or
* syncAwaitAllSettlements().
*
* syncAwaitAllSettlements() runs a nested io_context loop on the calling
* thread (AsynchronousBridge). Pass the calling thread's io_context —
* typically
@@ -240,6 +237,36 @@ public:
s.rsrc.admissionOpen = true;
}
/** EXPLANATION:
* Stopping a nursery: always closeAdmission() before
* requestCancelOnAll(). requestCancelOnAll() only marks currently
* ACTIVE_UNSETTLED slots; it does not refuse new leases. If cancel
* runs while admission is still open, a concurrent submitter can still
* getNewSlotLease() / launch() after cancel has fanned out, and that
* newly admitted work will not have been cancelled — it races past the
* stop wave and keeps the drain from reaching "all settled" until it
* finishes on its own (or a later cancel). Closing admission first
* seals the nursery so cancel applies to a fixed membership set, then
* drain with asyncAwaitAllSettlements() / syncAwaitAllSettlements()
* (those APIs also require admission already closed).
*
* Preferred stop stack for a daemon/service that enqueues request
* coroutines into the nursery: keep protocol "stop listening /
* disconnect / refuse new connections and requests" separate from
* protocol state destruction. Stop accepting at the protocol level
* first, then nursery closeAdmission(), then requestCancelOnAll(),
* then cancel any awaited I/O owned outside the cancelers, then drain,
* then destroy protocol state. That ordering stops new work at the
* source before admission is sealed.
*
* If the daemon/service cannot disconnect/stop listening separately
* from destruction, the spinscale-using embedding project must handle
* closed-admission failures when it tries to enqueue. For example,
* catch the "admission closed" throw around nursery.launch() (or in
* the factory that calls it) and emit a protocol-specific failure such
* as "connection failed" or "request timed out" instead of letting the
* exception escape the accept/request path unbounded.
*/
void closeAdmission()
{
sscl::SpinLock::Guard guard(s.lock);
@@ -0,0 +1,43 @@
#ifndef SYNC_AWAIT_NON_VIRAL_CORO_H
#define SYNC_AWAIT_NON_VIRAL_CORO_H
#include <boostAsioLinkageFix.h>
#include <spinscale/componentThread.h>
#include <spinscale/co/nonViralTaskNursery.h>
#include <exception>
#include <functional>
namespace sscl::co {
/** Launch a non-viral coroutine on the current ComponentThread io_context and
* block until it settles, rethrowing any stored exception.
*/
template<typename InvokerFactory>
void syncAwaitNonViralCoro(InvokerFactory&& _invokerFactory)
{
std::exception_ptr slotException;
NonViralTaskNursery nursery;
nursery.openAdmission();
nursery.launch(
[&_invokerFactory](NonViralTaskNursery::Slot::Lease& lease)
{
return _invokerFactory(lease);
},
[&slotException](std::exception_ptr& exceptionPtr)
{
slotException = exceptionPtr;
});
nursery.closeAdmission();
nursery.syncAwaitAllSettlements(
sscl::ComponentThread::getSelf()->getIoContext());
if (slotException) {
std::rethrow_exception(slotException);
}
}
} // namespace sscl::co
#endif // SYNC_AWAIT_NON_VIRAL_CORO_H
+58 -1
View File
@@ -20,9 +20,66 @@ public:
explicit EnvKvStore(
const std::vector<std::filesystem::path> &envFilePaths);
std::optional<std::string> get(std::string_view name) const;
/** EXPLANATION:
* Precedence: process getenv wins over compiled file-store values unless
* bypassProcessEnvironment is true (file store only).
*/
std::optional<std::string> find(
std::string_view name,
bool bypassProcessEnvironment = false) const;
/** Throws if find() returns nullopt. */
std::string get(
std::string_view name,
bool bypassProcessEnvironment = false) const;
/** EXPLANATION:
* Typed int accessors. defaultValue applies only when find() is nullopt;
* nullopt defaultValue with a missing key throws. A present value that fails
* to parse or fails the positivity constraint always throws.
*/
int getInt(
std::string_view name,
std::optional<int> defaultValue = std::nullopt) const
{
return getIntWithConstraint(name, defaultValue, IntConstraint::Any);
}
/** Parsed value must be >= 0. */
int getPositiveInt(
std::string_view name,
std::optional<int> defaultValue = std::nullopt) const
{
return getIntWithConstraint(
name, defaultValue, IntConstraint::NonNegative);
}
/** Parsed value must be > 0. */
int getPositiveNonZeroInt(
std::string_view name,
std::optional<int> defaultValue = std::nullopt) const
{
return getIntWithConstraint(
name, defaultValue, IntConstraint::PositiveNonZero);
}
private:
/** dotenv line parsing owned by EnvKvStore (definition in .cpp). */
class DotenvParser;
enum class IntConstraint
{
Any,
NonNegative,
PositiveNonZero,
};
static int parseInt(std::string_view name, const std::string &raw);
int getIntWithConstraint(
std::string_view name,
std::optional<int> defaultValue,
IntConstraint constraint) const;
void loadFiles(
const std::vector<std::filesystem::path> &envFilePaths,
std::ostream &warningStream);
@@ -4,6 +4,9 @@
#include <exception>
namespace sscl {
namespace co {
struct Group;
} // namespace co
/** Plain aggregate for fan-out / fan-in results returned from coroutines. */
struct MultiOperationResultSet
@@ -38,9 +41,29 @@ struct MultiOperationResultSetWithException
memberFailureException(memberFailureExceptionIn)
{}
/** Summarize a settled Group into counts + aggregated member failure. */
explicit MultiOperationResultSetWithException(const co::Group &group);
bool hasMemberFailure() const
{ return memberFailureException != nullptr; }
/** Combine this result set with another phase's counts and exception. */
MultiOperationResultSetWithException mergeWith(
const MultiOperationResultSetWithException &other) const
{
std::exception_ptr memberFailure = memberFailureException;
if (!memberFailure && other.hasMemberFailure()) {
memberFailure = other.memberFailureException;
}
return MultiOperationResultSetWithException(
MultiOperationResultSet(
results.nTotal + other.results.nTotal,
results.nSucceeded + other.results.nSucceeded,
results.nFailed + other.results.nFailed),
memberFailure);
}
MultiOperationResultSet results;
std::exception_ptr memberFailureException = nullptr;
};
+6
View File
@@ -2,6 +2,7 @@
#define SHARED_RESOURCE_GROUP_H
#include <string>
#include <utility>
namespace sscl {
@@ -20,6 +21,11 @@ public:
: lock(lockName), rsrc(initialRsrc)
{}
SharedResourceGroup(
const std::string& lockName, ResourceType&& initialRsrc)
: lock(lockName), rsrc(std::move(initialRsrc))
{}
~SharedResourceGroup() = default;
LockType lock;
@@ -27,6 +27,11 @@ namespace sscl {
* then flips shouldContinue to false. This guarantees shouldContinue is stable
* throughout each uncancelable segment.
*
* Shutdown call sites that also cancel internal async operations (timers, I/O,
* hardware capture, etc.) must call requestStop() on slot cancelers before
* cancelling those internal operations, so callees observe stop intent when
* the internal op unblocks their co_await.
*
* startAcceptingWork() is intentionally unlocked. Precondition: callers must
* only call startAcceptingWork() when no async callee is running yet (e.g. at
* the end of setup(), before posting/arming the first async work). If this
+11
View File
@@ -0,0 +1,11 @@
add_library(spinscale_probe_support STATIC
probeComponentThread.cpp
)
target_include_directories(spinscale_probe_support PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/..
)
target_link_libraries(spinscale_probe_support PUBLIC
spinscale
)
@@ -1,10 +1,12 @@
#include <support/probeComponentThread.h>
#include <probe/probeComponentThread.h>
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <spinscale/component.h>
namespace sscl::tests {
namespace sscl::probe {
namespace {
@@ -25,12 +27,48 @@ public:
}
};
/** EXPLANATION:
* PuppeteerThread starts its std::thread inside the constructor, but
* enable_shared_from_this::weak_this is only armed after make_shared returns.
* Without a barrier, initializeTls()'s shared_from_this() races and throws
* std::bad_weak_ptr. DedicatedIoThread uses the same handshake.
*/
struct ProbeThreadStartupState
{
std::mutex mutex;
std::condition_variable condition;
bool allowInitialization = false;
};
void waitForProbeThreadStartupPermission(
const std::shared_ptr<ProbeThreadStartupState>& startupState)
{
std::unique_lock<std::mutex> lock(startupState->mutex);
startupState->condition.wait(
lock,
[&startupState]() { return startupState->allowInitialization; });
}
void releaseProbeThreadStartupBarrier(
const std::shared_ptr<ProbeThreadStartupState>& startupState)
{
{
std::lock_guard<std::mutex> guard(startupState->mutex);
startupState->allowInitialization = true;
}
startupState->condition.notify_all();
}
void probePuppeteerMain(
const sscl::PuppeteerThread::EntryFnArguments& args,
const std::function<void(
const std::shared_ptr<sscl::ComponentThread>&)>& work,
std::promise<std::exception_ptr>& donePromise)
std::promise<std::exception_ptr>& donePromise,
const std::shared_ptr<ProbeThreadStartupState>& startupState)
{
waitForProbeThreadStartupPermission(startupState);
sscl::PuppeteerThread& thr = args.usableBeforeJolt;
thr.initializeTls();
sscl::ComponentThread::setPuppeteerThreadId(PROBE_PUPPETEER_THREAD_ID);
@@ -73,21 +111,23 @@ void ProbeComponentThreadHarness::runSync(
{
std::promise<std::exception_ptr> donePromise;
std::future<std::exception_ptr> doneFuture = donePromise.get_future();
auto startupState = std::make_shared<ProbeThreadStartupState>();
std::shared_ptr<sscl::PuppeteerThread> runThread =
std::make_shared<sscl::PuppeteerThread>(
PROBE_PUPPETEER_THREAD_ID,
threadName,
[&work, &donePromise](
[&work, &donePromise, startupState](
const sscl::PuppeteerThread::EntryFnArguments& args)
{
probePuppeteerMain(args, work, donePromise);
probePuppeteerMain(args, work, donePromise, startupState);
},
*dummyComponent,
nullptr);
dummyComponent->thread = runThread;
lastComponentThread = runThread;
releaseProbeThreadStartupBarrier(startupState);
runThread->thread.join();
std::exception_ptr probeException = doneFuture.get();
@@ -115,4 +155,4 @@ void runNonViralNurseryOnComponentThread(
nursery.syncAwaitAllSettlements(componentThread->getIoContext());
}
} // namespace sscl::tests
} // namespace sscl::probe
+71
View File
@@ -0,0 +1,71 @@
#ifndef SPINSCALE_PROBE_COMPONENT_THREAD_H
#define SPINSCALE_PROBE_COMPONENT_THREAD_H
#include <chrono>
#include <exception>
#include <functional>
#include <future>
#include <memory>
#include <stdexcept>
#include <string>
#include <spinscale/componentThread.h>
#include <spinscale/co/invokers.h>
#include <spinscale/co/nonViralTaskNursery.h>
namespace sscl::probe {
constexpr std::chrono::milliseconds defaultProbeTaskTimeout{10000};
void runNonViralNurseryOnComponentThread(
const std::shared_ptr<sscl::ComponentThread>& componentThread,
std::function<sscl::co::NonViralNonPostingInvoker(
sscl::co::NonViralTaskNursery::Slot::Lease&)> invokerFactory,
std::chrono::milliseconds timeout = defaultProbeTaskTimeout);
/** Sync driver: run work on a temporary puppeteer ComponentThread.
*
* Shared by lcameraDev probe tools and HIL/unit tests. Not tied to gtest.
*/
class ProbeComponentThreadHarness
{
public:
explicit ProbeComponentThreadHarness(
const char *threadName = "spinscale-probe");
~ProbeComponentThreadHarness();
ProbeComponentThreadHarness(const ProbeComponentThreadHarness &) = delete;
ProbeComponentThreadHarness &operator=(
const ProbeComponentThreadHarness &) = delete;
std::shared_ptr<sscl::ComponentThread> componentThread() const;
void runSync(
const std::function<void(
const std::shared_ptr<sscl::ComponentThread>&)>& work);
template <typename InvokerFactory>
void runNonViralNurseryTask(
InvokerFactory &&invokerFactory,
std::chrono::milliseconds timeout = defaultProbeTaskTimeout)
{
runSync(
[this, &invokerFactory, timeout](
const std::shared_ptr<sscl::ComponentThread>& componentThread)
{
sscl::probe::runNonViralNurseryOnComponentThread(
componentThread,
std::forward<InvokerFactory>(invokerFactory),
timeout);
});
}
private:
std::string threadName;
std::shared_ptr<sscl::pptr::PuppeteerComponent> dummyComponent;
std::shared_ptr<sscl::ComponentThread> lastComponentThread;
};
} // namespace sscl::probe
#endif // SPINSCALE_PROBE_COMPONENT_THREAD_H
+256 -176
View File
@@ -3,197 +3,186 @@
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <limits>
#include <ranges>
#include <sstream>
#include <stdexcept>
#include <utility>
#include <spinscale/envKvStore.h>
namespace sscl {
namespace {
std::string trim(std::string_view value)
class EnvKvStore::DotenvParser
{
auto begin = std::ranges::find_if_not(
value, [](unsigned char c) { return std::isspace(c); });
auto rbegin = std::ranges::find_if_not(
value | std::views::reverse,
[](unsigned char c) { return std::isspace(c); });
auto end = rbegin.base();
if (begin >= end)
public:
static bool lineIsBlankOrComment(const std::string &line)
{
return {};
std::string trimmed = trim(line);
return trimmed.empty() || trimmed.front() == '#';
}
return std::string(begin, end);
}
bool characterIsValidNameStart(char c)
{
return std::isalpha(static_cast<unsigned char>(c)) || c == '_';
}
bool characterIsValidNameBody(char c)
{
return std::isalnum(static_cast<unsigned char>(c)) || c == '_';
}
bool nameIsValid(std::string_view name)
{
if (name.empty() || !characterIsValidNameStart(name.front()))
static std::pair<std::string, std::string> parseAssignment(
const std::filesystem::path &envFilePath,
std::size_t lineNumber,
const std::string &line)
{
return false;
std::size_t separator = line.find('=');
if (separator == std::string::npos)
{
throw makeParseError(
envFilePath, lineNumber, "Expected KEY=value.");
}
std::string name = trim(std::string_view(line).substr(0, separator));
if (!nameIsValid(name))
{
throw makeParseError(
envFilePath, lineNumber, "Invalid variable name.");
}
return {
std::move(name),
parseValue(
envFilePath,
lineNumber,
std::string_view(line).substr(separator + 1))};
}
return std::ranges::all_of(name.substr(1), characterIsValidNameBody);
}
std::runtime_error makeParseError(
const std::filesystem::path &envFilePath,
std::size_t lineNumber,
const std::string &message)
{
std::ostringstream stream;
stream << envFilePath << ":" << lineNumber << ": " << message;
return std::runtime_error(stream.str());
}
bool lineIsBlankOrComment(const std::string &line)
{
std::string trimmed = trim(line);
return trimmed.empty() || trimmed.front() == '#';
}
std::size_t findClosingQuote(
const std::filesystem::path &envFilePath,
std::size_t lineNumber,
std::string_view value)
{
char quote = value.front();
bool escapeNext = false;
for (std::size_t i = 1; i < value.size(); ++i)
private:
static std::string trim(std::string_view value)
{
if (escapeNext)
{
escapeNext = false;
continue;
}
if (quote == '"' && value[i] == '\\')
{
escapeNext = true;
continue;
}
if (value[i] == quote)
{
return i;
}
auto begin = std::ranges::find_if_not(
value, [](unsigned char c) { return std::isspace(c); });
auto rbegin = std::ranges::find_if_not(
value | std::views::reverse,
[](unsigned char c) { return std::isspace(c); });
auto end = rbegin.base();
if (begin >= end) { return {}; }
return std::string(begin, end);
}
throw makeParseError(envFilePath, lineNumber, "Unterminated quoted value.");
}
std::string decodeDoubleQuotedValue(std::string_view value)
{
std::string decoded;
decoded.reserve(value.size());
bool escapeNext = false;
for (char c : value)
static bool characterIsValidNameStart(char c)
{
if (!escapeNext && c == '\\')
return std::isalpha(static_cast<unsigned char>(c)) || c == '_';
}
static bool characterIsValidNameBody(char c)
{
return std::isalnum(static_cast<unsigned char>(c)) || c == '_';
}
static bool nameIsValid(std::string_view name)
{
if (name.empty() || !characterIsValidNameStart(name.front())) { return false; }
return std::ranges::all_of(name.substr(1), characterIsValidNameBody);
}
static std::runtime_error makeParseError(
const std::filesystem::path &envFilePath,
std::size_t lineNumber,
const std::string &message)
{
std::ostringstream stream;
stream << envFilePath << ":" << lineNumber << ": " << message;
return std::runtime_error(stream.str());
}
static std::size_t findClosingQuote(
const std::filesystem::path &envFilePath,
std::size_t lineNumber,
std::string_view value)
{
char quote = value.front();
bool escapeNext = false;
for (std::size_t i = 1; i < value.size(); ++i)
{
escapeNext = true;
continue;
}
if (escapeNext)
{
switch (c)
if (escapeNext)
{
case 'n':
decoded.push_back('\n');
break;
case 'r':
decoded.push_back('\r');
break;
case 't':
decoded.push_back('\t');
break;
default:
decoded.push_back(c);
break;
escapeNext = false;
continue;
}
escapeNext = false;
continue;
if (quote == '"' && value[i] == '\\')
{
escapeNext = true;
continue;
}
if (value[i] == quote) { return i; }
}
decoded.push_back(c);
}
if (escapeNext)
{
decoded.push_back('\\');
}
return decoded;
}
std::string parseQuotedValue(
const std::filesystem::path &envFilePath,
std::size_t lineNumber,
std::string_view value)
{
char quote = value.front();
std::size_t closingQuote = findClosingQuote(envFilePath, lineNumber, value);
std::string trailing = trim(value.substr(closingQuote + 1));
if (!trailing.empty() && trailing.front() != '#')
{
throw makeParseError(
envFilePath, lineNumber, "Unexpected text after quoted value.");
}
std::string_view quotedBody = value.substr(1, closingQuote - 1);
if (quote == '"')
{
return decodeDoubleQuotedValue(quotedBody);
}
return std::string(quotedBody);
}
std::string parseValue(
const std::filesystem::path &envFilePath,
std::size_t lineNumber,
std::string_view rawValue)
{
std::string value = trim(rawValue);
if (value.empty())
{
return {};
}
if (value.front() == '\'' || value.front() == '"')
{
return parseQuotedValue(envFilePath, lineNumber, value);
}
return trim(value.substr(0, value.find('#')));
}
std::pair<std::string, std::string> parseAssignment(
const std::filesystem::path &envFilePath,
std::size_t lineNumber,
const std::string &line)
{
std::size_t separator = line.find('=');
if (separator == std::string::npos)
{
throw makeParseError(envFilePath, lineNumber, "Expected KEY=value.");
envFilePath, lineNumber, "Unterminated quoted value.");
}
std::string name = trim(std::string_view(line).substr(0, separator));
if (!nameIsValid(name))
static std::string decodeDoubleQuotedValue(std::string_view value)
{
throw makeParseError(envFilePath, lineNumber, "Invalid variable name.");
std::string decoded;
decoded.reserve(value.size());
bool escapeNext = false;
for (char c : value)
{
if (!escapeNext && c == '\\')
{
escapeNext = true;
continue;
}
if (escapeNext)
{
switch (c)
{
case 'n':
decoded.push_back('\n');
break;
case 'r':
decoded.push_back('\r');
break;
case 't':
decoded.push_back('\t');
break;
default:
decoded.push_back(c);
break;
}
escapeNext = false;
continue;
}
decoded.push_back(c);
}
if (escapeNext) { decoded.push_back('\\'); }
return decoded;
}
return {
std::move(name),
parseValue(
envFilePath,
lineNumber,
std::string_view(line).substr(separator + 1))};
}
static std::string parseQuotedValue(
const std::filesystem::path &envFilePath,
std::size_t lineNumber,
std::string_view value)
{
char quote = value.front();
std::size_t closingQuote =
findClosingQuote(envFilePath, lineNumber, value);
std::string trailing = trim(value.substr(closingQuote + 1));
if (!trailing.empty() && trailing.front() != '#')
{
throw makeParseError(
envFilePath,
lineNumber,
"Unexpected text after quoted value.");
}
std::string_view quotedBody = value.substr(1, closingQuote - 1);
if (quote == '"') { return decodeDoubleQuotedValue(quotedBody); }
return std::string(quotedBody);
}
} // namespace
static std::string parseValue(
const std::filesystem::path &envFilePath,
std::size_t lineNumber,
std::string_view rawValue)
{
std::string value = trim(rawValue);
if (value.empty()) { return {}; }
if (value.front() == '\'' || value.front() == '"') { return parseQuotedValue(envFilePath, lineNumber, value); }
return trim(value.substr(0, value.find('#')));
}
};
EnvKvStore::EnvKvStore(
const std::vector<std::filesystem::path> &envFilePaths,
@@ -205,8 +194,7 @@ EnvKvStore::EnvKvStore(
EnvKvStore::EnvKvStore(
const std::vector<std::filesystem::path> &envFilePaths)
: EnvKvStore(envFilePaths, std::cerr)
{
}
{}
void EnvKvStore::loadFiles(
const std::vector<std::filesystem::path> &envFilePaths,
@@ -218,21 +206,115 @@ void EnvKvStore::loadFiles(
}
}
std::optional<std::string> EnvKvStore::get(std::string_view name) const
std::optional<std::string> EnvKvStore::find(
std::string_view name,
bool bypassProcessEnvironment) const
{
std::string ownedName(name);
if (const char *value = std::getenv(ownedName.c_str()))
if (!bypassProcessEnvironment)
{
return std::string(value);
std::string ownedName(name);
if (const char *value = std::getenv(ownedName.c_str())) {
return std::string(value);
}
}
auto value = values.find(std::string(name));
if (value == values.end())
{
return std::nullopt;
}
if (value == values.end()) { return std::nullopt; }
return value->second;
}
std::string EnvKvStore::get(
std::string_view name,
bool bypassProcessEnvironment) const
{
std::optional<std::string> value = find(name, bypassProcessEnvironment);
if (!value.has_value())
{
throw std::runtime_error(
std::string("EnvKvStore: missing key '")
+ std::string(name)
+ "'");
}
return *value;
}
int EnvKvStore::parseInt(std::string_view name, const std::string &raw)
{
try
{
std::size_t consumed = 0;
const long parsed = std::stol(raw, &consumed);
if (consumed != raw.size())
{
throw std::runtime_error(
std::string("EnvKvStore: '")
+ std::string(name)
+ "' must be an integer, got: "
+ raw);
}
if (parsed < std::numeric_limits<int>::min()
|| parsed > std::numeric_limits<int>::max())
{
throw std::runtime_error(
std::string("EnvKvStore: '")
+ std::string(name)
+ "' is out of int range, got: "
+ raw);
}
return static_cast<int>(parsed);
}
catch (const std::runtime_error &)
{
throw;
}
catch (const std::exception &)
{
throw std::runtime_error(
std::string("EnvKvStore: failed to parse '")
+ std::string(name)
+ "' as an integer, got: "
+ raw);
}
}
int EnvKvStore::getIntWithConstraint(
std::string_view name,
std::optional<int> defaultValue,
IntConstraint constraint) const
{
const std::optional<std::string> raw = find(name);
if (!raw.has_value())
{
if (!defaultValue.has_value())
{
throw std::runtime_error(
std::string("EnvKvStore: missing key '")
+ std::string(name)
+ "'");
}
return *defaultValue;
}
const int parsed = parseInt(name, *raw);
if (constraint == IntConstraint::NonNegative && parsed < 0)
{
throw std::runtime_error(
std::string("EnvKvStore: '")
+ std::string(name)
+ "' must be a non-negative integer, got: "
+ *raw);
}
if (constraint == IntConstraint::PositiveNonZero && parsed <= 0)
{
throw std::runtime_error(
std::string("EnvKvStore: '")
+ std::string(name)
+ "' must be a positive non-zero integer, got: "
+ *raw);
}
return parsed;
}
void EnvKvStore::loadFile(
const std::filesystem::path &envFilePath,
std::ostream &warningStream)
@@ -249,11 +331,9 @@ void EnvKvStore::loadFile(
while (std::getline(file, line))
{
++lineNumber;
if (lineIsBlankOrComment(line))
{
continue;
}
auto [name, value] = parseAssignment(envFilePath, lineNumber, line);
if (DotenvParser::lineIsBlankOrComment(line)) { continue; }
auto [name, value] =
DotenvParser::parseAssignment(envFilePath, lineNumber, line);
storeValue(envFilePath, name, value, warningStream);
}
}
+35
View File
@@ -0,0 +1,35 @@
#include <boostAsioLinkageFix.h>
#include <spinscale/multiOperationResultSet.h>
#include <spinscale/co/group.h>
namespace sscl {
MultiOperationResultSetWithException::MultiOperationResultSetWithException(
const co::Group &group)
{
unsigned int nSucceeded = 0;
unsigned int nFailed = 0;
using SettlementType = co::Group::SettlementDescriptor::TypeE;
for (const auto &desc : group.s.rsrc.settlements)
{
if (desc.type == SettlementType::EXCEPTION_THROWN) {
nFailed++;
}
else {
nSucceeded++;
}
}
results = MultiOperationResultSet(
static_cast<unsigned int>(group.s.rsrc.settlements.size()),
nSucceeded,
nFailed);
if (nFailed > 0) {
memberFailureException = group.captureAggregatedGroupExceptions();
}
}
} // namespace sscl
+1 -1
View File
@@ -1,6 +1,5 @@
add_library(spinscale_test_support STATIC
support/threadHarness.cpp
support/probeComponentThread.cpp
)
target_include_directories(spinscale_test_support PUBLIC
@@ -10,6 +9,7 @@ target_include_directories(spinscale_test_support PUBLIC
target_link_libraries(spinscale_test_support PUBLIC
spinscale
spinscale_probe_support
gtest
)
+505 -45
View File
@@ -1,8 +1,11 @@
#include <algorithm>
#include <cstdlib>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
@@ -12,6 +15,16 @@
namespace {
constexpr const char *kTestEnvName = "SSCL_ENV_TEST_VALUE";
constexpr const char *kPositiveIntMsEnvName = "SSCL_POSITIVE_INT_MS";
constexpr int kPositiveIntMsDefault = 33;
void unsetTestEnvVars()
{
unsetenv(kTestEnvName);
unsetenv(kPositiveIntMsEnvName);
}
class EnvKvStoreTest
: public testing::Test
{
@@ -24,12 +37,12 @@ protected:
.time_since_epoch().count())
+ "-" + std::to_string(testCounter++));
std::filesystem::create_directories(root);
unsetenv("SSCL_ENV_TEST_VALUE");
unsetTestEnvVars();
}
void TearDown() override
{
unsetenv("SSCL_ENV_TEST_VALUE");
unsetTestEnvVars();
std::filesystem::remove_all(root);
}
@@ -43,6 +56,26 @@ protected:
return path;
}
void expectParseErrorContaining(
const std::filesystem::path &envFile,
const std::string &expectedFragment)
{
std::ostringstream warnings;
try
{
sscl::EnvKvStore store({envFile}, warnings);
FAIL() << "Expected parse of " << envFile << " to throw.";
}
catch (const std::runtime_error &e)
{
std::string message = e.what();
EXPECT_NE(message.find(envFile.string()), std::string::npos)
<< message;
EXPECT_NE(message.find(expectedFragment), std::string::npos)
<< message;
}
}
std::filesystem::path root;
static inline int testCounter = 0;
};
@@ -65,69 +98,196 @@ TEST_F(EnvKvStoreTest, ParsesSupportedDotenvForms)
std::ostringstream warnings;
sscl::EnvKvStore store({envFile}, warnings);
EXPECT_EQ(store.get("PLAIN"), "value");
EXPECT_EQ(store.get("TRIMMED"), "value with spaces");
EXPECT_EQ(store.get("SINGLE"), " preserved value ");
EXPECT_EQ(store.get("DOUBLE"), "another preserved value");
EXPECT_EQ(store.get("ESCAPED"), "quote: \" slash: \\ tab: \t");
EXPECT_EQ(store.get("COMMENTED"), "value");
EXPECT_EQ(store.find("PLAIN"), "value");
EXPECT_EQ(store.find("TRIMMED"), "value with spaces");
EXPECT_EQ(store.find("SINGLE"), " preserved value ");
EXPECT_EQ(store.find("DOUBLE"), "another preserved value");
EXPECT_EQ(store.find("ESCAPED"), "quote: \" slash: \\ tab: \t");
EXPECT_EQ(store.find("COMMENTED"), "value");
EXPECT_TRUE(warnings.str().empty());
}
TEST_F(EnvKvStoreTest, LaterFilesOverwriteEarlierFilesAndWarn)
TEST_F(EnvKvStoreTest, EmptyPathListYieldsEmptyStore)
{
std::filesystem::path first = writeFile("first.env", "VALUE=first\n");
std::filesystem::path second = writeFile("second.env", "VALUE=second\n");
std::ostringstream warnings;
sscl::EnvKvStore store({first, second}, warnings);
sscl::EnvKvStore store({}, warnings);
EXPECT_EQ(store.get("VALUE"), "second");
EXPECT_NE(warnings.str().find("VALUE"), std::string::npos);
EXPECT_NE(warnings.str().find("first"), std::string::npos);
EXPECT_NE(warnings.str().find("second"), std::string::npos);
EXPECT_NE(warnings.str().find(second.string()), std::string::npos);
EXPECT_EQ(store.find("ANY"), std::nullopt);
EXPECT_TRUE(warnings.str().empty());
}
TEST_F(EnvKvStoreTest, DuplicateKeysInsideSameFileOverwriteAndWarn)
TEST_F(EnvKvStoreTest, EmptyFileYieldsEmptyStore)
{
std::filesystem::path envFile =
writeFile("one.env", "VALUE=first\nVALUE=second\n");
std::filesystem::path envFile = writeFile("empty.env", "");
std::ostringstream warnings;
sscl::EnvKvStore store({envFile}, warnings);
EXPECT_EQ(store.find("ANY"), std::nullopt);
EXPECT_TRUE(warnings.str().empty());
}
TEST_F(EnvKvStoreTest, CommentOnlyAndWhitespaceOnlyLinesAreIgnored)
{
std::filesystem::path envFile = writeFile(
"comments.env",
" \n"
"\t\n"
"# only comment\n"
" # indented comment\n"
"KEEP=yes\n"
"\n");
std::ostringstream warnings;
sscl::EnvKvStore store({envFile}, warnings);
EXPECT_EQ(store.get("VALUE"), "second");
EXPECT_NE(warnings.str().find("VALUE"), std::string::npos);
EXPECT_NE(warnings.str().find("first"), std::string::npos);
EXPECT_NE(warnings.str().find("second"), std::string::npos);
EXPECT_NE(warnings.str().find(envFile.string()), std::string::npos);
EXPECT_EQ(store.find("KEEP"), "yes");
EXPECT_TRUE(warnings.str().empty());
}
TEST_F(EnvKvStoreTest, ProcessEnvironmentOverridesStoreSilently)
TEST_F(EnvKvStoreTest, EmptyUnquotedValueIsAccepted)
{
std::filesystem::path envFile =
writeFile("one.env", "SSCL_ENV_TEST_VALUE=file\n");
setenv("SSCL_ENV_TEST_VALUE", "process", 1);
std::filesystem::path envFile = writeFile("empty-value.env", "EMPTY=\n");
std::ostringstream warnings;
sscl::EnvKvStore store({envFile}, warnings);
EXPECT_EQ(store.find("EMPTY"), "");
}
TEST_F(EnvKvStoreTest, UnquotedHashStartsInlineComment)
{
std::filesystem::path envFile = writeFile(
"hash.env",
"A=before#after\n"
"B= # leading comment after equals\n");
std::ostringstream warnings;
sscl::EnvKvStore store({envFile}, warnings);
EXPECT_EQ(store.get("SSCL_ENV_TEST_VALUE"), "process");
EXPECT_TRUE(warnings.str().empty());
EXPECT_EQ(store.find("A"), "before");
EXPECT_EQ(store.find("B"), "");
}
TEST_F(EnvKvStoreTest, EmptyProcessEnvironmentValueOverridesStoreSilently)
TEST_F(EnvKvStoreTest, HashInsideQuotesIsLiteral)
{
std::filesystem::path envFile =
writeFile("one.env", "SSCL_ENV_TEST_VALUE=file\n");
setenv("SSCL_ENV_TEST_VALUE", "", 1);
std::filesystem::path envFile = writeFile(
"hash-quoted.env",
"SINGLE='#not-comment'\n"
"DOUBLE=\"#not-comment\"\n"
"DOUBLE_TRAIL=\"kept\" # trailing comment ok\n");
std::ostringstream warnings;
sscl::EnvKvStore store({envFile}, warnings);
EXPECT_EQ(store.get("SSCL_ENV_TEST_VALUE"), "");
EXPECT_TRUE(warnings.str().empty());
EXPECT_EQ(store.find("SINGLE"), "#not-comment");
EXPECT_EQ(store.find("DOUBLE"), "#not-comment");
EXPECT_EQ(store.find("DOUBLE_TRAIL"), "kept");
}
TEST_F(EnvKvStoreTest, DoubleQuotedEscapeSequences)
{
std::filesystem::path envFile = writeFile(
"escapes.env",
"NL=\"line\\nbreak\"\n"
"CR=\"ret\\rurn\"\n"
"TAB=\"a\\tb\"\n"
"UNKNOWN=\"\\q\"\n"
"TRAILING=\"end\\\\\"\n"
"ESCAPED_QUOTE_MID=\"a\\\"b\"\n");
std::ostringstream warnings;
sscl::EnvKvStore store({envFile}, warnings);
EXPECT_EQ(store.find("NL"), "line\nbreak");
EXPECT_EQ(store.find("CR"), "ret\rurn");
EXPECT_EQ(store.find("TAB"), "a\tb");
EXPECT_EQ(store.find("UNKNOWN"), "q");
EXPECT_EQ(store.find("TRAILING"), "end\\");
EXPECT_EQ(store.find("ESCAPED_QUOTE_MID"), "a\"b");
}
TEST_F(EnvKvStoreTest, SingleQuotedValuesDoNotDecodeEscapes)
{
std::filesystem::path envFile = writeFile(
"single-escapes.env",
"LITERAL='\\n\\t\\\"'\n");
std::ostringstream warnings;
sscl::EnvKvStore store({envFile}, warnings);
EXPECT_EQ(store.find("LITERAL"), "\\n\\t\\\"");
}
TEST_F(EnvKvStoreTest, ValidNamesAcceptUnderscoreAndAlnum)
{
std::filesystem::path envFile = writeFile(
"names.env",
"_LEADING=1\n"
"A1B2=2\n"
"mixed_Case99=3\n");
std::ostringstream warnings;
sscl::EnvKvStore store({envFile}, warnings);
EXPECT_EQ(store.find("_LEADING"), "1");
EXPECT_EQ(store.find("A1B2"), "2");
EXPECT_EQ(store.find("mixed_Case99"), "3");
}
TEST_F(EnvKvStoreTest, InvalidNamesThrow)
{
expectParseErrorContaining(
writeFile("digit.env", "1BAD=x\n"),
"Invalid variable name.");
expectParseErrorContaining(
writeFile("hyphen.env", "BAD-NAME=x\n"),
"Invalid variable name.");
expectParseErrorContaining(
writeFile("dot.env", "BAD.NAME=x\n"),
"Invalid variable name.");
expectParseErrorContaining(
writeFile("empty-name.env", "=value\n"),
"Invalid variable name.");
}
TEST_F(EnvKvStoreTest, UnterminatedQuotedValueThrows)
{
expectParseErrorContaining(
writeFile("unterminated-double.env", "X=\"no close\n"),
"Unterminated quoted value.");
expectParseErrorContaining(
writeFile("unterminated-single.env", "X='no close\n"),
"Unterminated quoted value.");
}
TEST_F(EnvKvStoreTest, UnexpectedTextAfterQuotedValueThrows)
{
expectParseErrorContaining(
writeFile("trail.env", "X=\"ok\" trailing\n"),
"Unexpected text after quoted value.");
}
TEST_F(EnvKvStoreTest, MalformedLineThrowsWithLineNumber)
{
std::filesystem::path envFile = writeFile(
"bad.env",
"# header\n"
"GOOD=1\n"
"NOT AN ASSIGNMENT\n");
std::ostringstream warnings;
try
{
sscl::EnvKvStore store({envFile}, warnings);
FAIL() << "Expected malformed env file to throw.";
}
catch (const std::runtime_error &e)
{
std::string message = e.what();
EXPECT_NE(message.find(envFile.string()), std::string::npos);
EXPECT_NE(message.find(":3:"), std::string::npos) << message;
EXPECT_NE(message.find("Expected KEY=value."), std::string::npos)
<< message;
}
}
TEST_F(EnvKvStoreTest, MissingFileThrows)
@@ -138,20 +298,320 @@ TEST_F(EnvKvStoreTest, MissingFileThrows)
std::runtime_error);
}
TEST_F(EnvKvStoreTest, MalformedLineThrows)
TEST_F(EnvKvStoreTest, UnreadableFileThrowsOpenFailure)
{
std::filesystem::path envFile = writeFile("bad.env", "NOT AN ASSIGNMENT\n");
std::ostringstream warnings;
std::filesystem::path envFile = writeFile("noread.env", "X=1\n");
std::filesystem::permissions(envFile, std::filesystem::perms::none);
std::ostringstream warnings;
try
{
sscl::EnvKvStore store({envFile}, warnings);
FAIL() << "Expected malformed env file to throw.";
FAIL() << "Expected unreadable file open to throw.";
}
catch (const std::runtime_error &e)
{
std::string message = e.what();
EXPECT_NE(message.find(envFile.string()), std::string::npos);
EXPECT_NE(message.find(":1:"), std::string::npos);
EXPECT_NE(
std::string(e.what()).find("Failed to open env file:"),
std::string::npos);
}
std::filesystem::permissions(
envFile,
std::filesystem::perms::owner_read
| std::filesystem::perms::owner_write);
}
TEST_F(EnvKvStoreTest, LaterFilesOverwriteEarlierFilesAndWarn)
{
std::filesystem::path first = writeFile("first.env", "VALUE=first\n");
std::filesystem::path second = writeFile("second.env", "VALUE=second\n");
std::ostringstream warnings;
sscl::EnvKvStore store({first, second}, warnings);
EXPECT_EQ(store.find("VALUE"), "second");
EXPECT_NE(warnings.str().find("VALUE"), std::string::npos);
EXPECT_NE(warnings.str().find("first"), std::string::npos);
EXPECT_NE(warnings.str().find("second"), std::string::npos);
EXPECT_NE(warnings.str().find(second.string()), std::string::npos);
}
TEST_F(EnvKvStoreTest, ThreeFileOverwriteKeepsLastValue)
{
std::filesystem::path a = writeFile("a.env", "K=a\nSHARED=1\n");
std::filesystem::path b = writeFile("b.env", "SHARED=2\n");
std::filesystem::path c = writeFile("c.env", "SHARED=3\nK=c\n");
std::ostringstream warnings;
sscl::EnvKvStore store({a, b, c}, warnings);
EXPECT_EQ(store.find("K"), "c");
EXPECT_EQ(store.find("SHARED"), "3");
const std::string warningText = warnings.str();
EXPECT_GE(
std::count(warningText.begin(), warningText.end(), '\n'),
2);
}
TEST_F(EnvKvStoreTest, DuplicateKeysInsideSameFileOverwriteAndWarn)
{
std::filesystem::path envFile =
writeFile("one.env", "VALUE=first\nVALUE=second\n");
std::ostringstream warnings;
sscl::EnvKvStore store({envFile}, warnings);
EXPECT_EQ(store.find("VALUE"), "second");
EXPECT_NE(warnings.str().find("VALUE"), std::string::npos);
EXPECT_NE(warnings.str().find("first"), std::string::npos);
EXPECT_NE(warnings.str().find("second"), std::string::npos);
EXPECT_NE(warnings.str().find(envFile.string()), std::string::npos);
}
TEST_F(EnvKvStoreTest, ProcessEnvironmentOverridesStoreSilently)
{
std::filesystem::path envFile =
writeFile("one.env", "SSCL_ENV_TEST_VALUE=file\n");
setenv(kTestEnvName, "process", 1);
std::ostringstream warnings;
sscl::EnvKvStore store({envFile}, warnings);
EXPECT_EQ(store.find(kTestEnvName), "process");
EXPECT_TRUE(warnings.str().empty());
}
TEST_F(EnvKvStoreTest, EmptyProcessEnvironmentValueOverridesStoreSilently)
{
std::filesystem::path envFile =
writeFile("one.env", "SSCL_ENV_TEST_VALUE=file\n");
setenv(kTestEnvName, "", 1);
std::ostringstream warnings;
sscl::EnvKvStore store({envFile}, warnings);
EXPECT_EQ(store.find(kTestEnvName), "");
EXPECT_TRUE(warnings.str().empty());
}
TEST_F(EnvKvStoreTest, ProcessEnvironmentReturnedWhenKeyAbsentFromStore)
{
setenv(kTestEnvName, "only-process", 1);
std::ostringstream warnings;
sscl::EnvKvStore store({}, warnings);
EXPECT_EQ(store.find(kTestEnvName), "only-process");
}
TEST_F(EnvKvStoreTest, BypassProcessEnvironmentUsesFileStoreOnly)
{
std::filesystem::path envFile =
writeFile("one.env", "SSCL_ENV_TEST_VALUE=file\n");
setenv(kTestEnvName, "process", 1);
std::ostringstream warnings;
sscl::EnvKvStore store({envFile}, warnings);
EXPECT_EQ(store.find(kTestEnvName, true), "file");
EXPECT_EQ(store.find(kTestEnvName, false), "process");
}
TEST_F(EnvKvStoreTest, BypassIgnoresProcessEnvWhenKeyOnlyInProcess)
{
setenv(kTestEnvName, "process-only", 1);
std::ostringstream warnings;
sscl::EnvKvStore store({}, warnings);
EXPECT_EQ(store.find(kTestEnvName, true), std::nullopt);
EXPECT_EQ(store.find(kTestEnvName, false), "process-only");
}
TEST_F(EnvKvStoreTest, MissingKeyReturnsNullopt)
{
std::filesystem::path envFile = writeFile("one.env", "PRESENT=1\n");
std::ostringstream warnings;
sscl::EnvKvStore store({envFile}, warnings);
EXPECT_EQ(store.find("PRESENT"), "1");
EXPECT_EQ(store.find("ABSENT"), std::nullopt);
EXPECT_EQ(store.find("ABSENT", true), std::nullopt);
}
TEST_F(EnvKvStoreTest, DefaultWarningCtorLoadsWithoutThrowing)
{
std::filesystem::path envFile = writeFile("one.env", "OK=1\n");
sscl::EnvKvStore store({envFile});
EXPECT_EQ(store.find("OK"), "1");
}
TEST_F(EnvKvStoreTest, GetThrowsWhenKeyMissing)
{
std::ostringstream warnings;
sscl::EnvKvStore store({}, warnings);
EXPECT_THROW(store.get("ABSENT"), std::runtime_error);
}
TEST_F(EnvKvStoreTest, GetReturnsPresentValue)
{
std::ostringstream warnings;
sscl::EnvKvStore store(
{writeFile("one.env", "PRESENT=1\n")},
warnings);
EXPECT_EQ(store.get("PRESENT"), "1");
}
TEST_F(EnvKvStoreTest, GetIntUsesDefaultWhenUnset)
{
std::ostringstream warnings;
sscl::EnvKvStore store({}, warnings);
EXPECT_EQ(store.getInt(kPositiveIntMsEnvName, kPositiveIntMsDefault), kPositiveIntMsDefault);
EXPECT_EQ(store.getInt("CUSTOM", 42), 42);
EXPECT_THROW(store.getInt("MISSING"), std::runtime_error);
}
TEST_F(EnvKvStoreTest, GetIntParsesSignedValues)
{
std::ostringstream warnings;
sscl::EnvKvStore store(
{writeFile("one.env", "NEG=-7\nZERO=0\n")},
warnings);
EXPECT_EQ(store.getInt("NEG"), -7);
EXPECT_EQ(store.getInt("ZERO"), 0);
EXPECT_EQ(store.getPositiveInt("ZERO"), 0);
}
TEST_F(EnvKvStoreTest, GetPositiveNonZeroIntUsesDefaultWhenUnset)
{
std::ostringstream warnings;
sscl::EnvKvStore store({}, warnings);
EXPECT_EQ(
store.getPositiveNonZeroInt(kPositiveIntMsEnvName, kPositiveIntMsDefault),
kPositiveIntMsDefault);
}
TEST_F(EnvKvStoreTest, GetPositiveNonZeroIntParsesFileValue)
{
std::filesystem::path envFile = writeFile(
"one.env",
std::string(kPositiveIntMsEnvName) + "=50\n");
std::ostringstream warnings;
sscl::EnvKvStore store({envFile}, warnings);
EXPECT_EQ(
store.getPositiveNonZeroInt(kPositiveIntMsEnvName, kPositiveIntMsDefault),
50);
}
TEST_F(EnvKvStoreTest, GetPositiveNonZeroIntHonorsProcessEnvironmentOverFile)
{
std::filesystem::path envFile = writeFile(
"one.env",
std::string(kPositiveIntMsEnvName) + "=50\n");
setenv(kPositiveIntMsEnvName, "77", 1);
std::ostringstream warnings;
sscl::EnvKvStore store({envFile}, warnings);
EXPECT_EQ(
store.getPositiveNonZeroInt(kPositiveIntMsEnvName, kPositiveIntMsDefault),
77);
}
TEST_F(EnvKvStoreTest, GetPositiveNonZeroIntRejectsNonPositive)
{
std::ostringstream warnings;
sscl::EnvKvStore zeroStore(
{writeFile(
"zero.env",
std::string(kPositiveIntMsEnvName) + "=0\n")},
warnings);
EXPECT_THROW(
zeroStore.getPositiveNonZeroInt(
kPositiveIntMsEnvName, kPositiveIntMsDefault),
std::runtime_error);
sscl::EnvKvStore negativeStore(
{writeFile(
"neg.env",
std::string(kPositiveIntMsEnvName) + "=-3\n")},
warnings);
EXPECT_THROW(
negativeStore.getPositiveNonZeroInt(
kPositiveIntMsEnvName, kPositiveIntMsDefault),
std::runtime_error);
EXPECT_THROW(
negativeStore.getPositiveInt(
kPositiveIntMsEnvName, kPositiveIntMsDefault),
std::runtime_error);
}
TEST_F(EnvKvStoreTest, GetPositiveNonZeroIntRejectsNonNumericAndTrailingJunk)
{
std::ostringstream warnings;
sscl::EnvKvStore letters(
{writeFile(
"letters.env",
std::string(kPositiveIntMsEnvName) + "=abc\n")},
warnings);
try
{
(void)letters.getPositiveNonZeroInt(
kPositiveIntMsEnvName, kPositiveIntMsDefault);
FAIL() << "Expected non-numeric parse to throw.";
}
catch (const std::runtime_error &e)
{
EXPECT_NE(
std::string(e.what()).find("failed to parse"),
std::string::npos)
<< e.what();
}
sscl::EnvKvStore trailing(
{writeFile(
"trailing.env",
std::string(kPositiveIntMsEnvName) + "=50ms\n")},
warnings);
try
{
(void)trailing.getPositiveNonZeroInt(
kPositiveIntMsEnvName, kPositiveIntMsDefault);
FAIL() << "Expected trailing junk to throw.";
}
catch (const std::runtime_error &e)
{
EXPECT_NE(
std::string(e.what()).find("must be an integer"),
std::string::npos)
<< e.what();
}
}
TEST_F(EnvKvStoreTest, GetPositiveNonZeroIntRejectsEmptyStringValue)
{
std::ostringstream warnings;
sscl::EnvKvStore store(
{writeFile(
"empty.env",
std::string(kPositiveIntMsEnvName) + "=\n")},
warnings);
EXPECT_THROW(
store.getPositiveNonZeroInt(kPositiveIntMsEnvName, kPositiveIntMsDefault),
std::runtime_error);
}
TEST_F(EnvKvStoreTest, GetPositiveNonZeroIntAcceptsOne)
{
std::ostringstream warnings;
sscl::EnvKvStore store(
{writeFile("one.env", "CUSTOM=1\n")},
warnings);
EXPECT_EQ(store.getPositiveNonZeroInt("CUSTOM", 99), 1);
}
+9 -55
View File
@@ -1,65 +1,19 @@
#ifndef SPINSCALE_TEST_SUPPORT_PROBE_COMPONENT_THREAD_H
#define SPINSCALE_TEST_SUPPORT_PROBE_COMPONENT_THREAD_H
#include <chrono>
#include <exception>
#include <functional>
#include <future>
#include <memory>
#include <stdexcept>
/** EXPLANATION:
* Compatibility shim: probe harness lives in spinscale_probe_support under
* sscl::probe. Test code may keep including this path and using sscl::tests
* names; tools should include <probe/probeComponentThread.h> directly.
*/
#include <spinscale/componentThread.h>
#include <spinscale/co/invokers.h>
#include <spinscale/co/nonViralTaskNursery.h>
#include <probe/probeComponentThread.h>
namespace sscl::tests {
constexpr std::chrono::milliseconds defaultProbeTaskTimeout{10000};
void runNonViralNurseryOnComponentThread(
const std::shared_ptr<sscl::ComponentThread>& componentThread,
std::function<sscl::co::NonViralNonPostingInvoker(
sscl::co::NonViralTaskNursery::Slot::Lease&)> invokerFactory,
std::chrono::milliseconds timeout = defaultProbeTaskTimeout);
class ProbeComponentThreadHarness
{
public:
explicit ProbeComponentThreadHarness(
const char *threadName = "spinscale-probe");
~ProbeComponentThreadHarness();
ProbeComponentThreadHarness(const ProbeComponentThreadHarness &) = delete;
ProbeComponentThreadHarness &operator=(
const ProbeComponentThreadHarness &) = delete;
std::shared_ptr<sscl::ComponentThread> componentThread() const;
void runSync(
const std::function<void(
const std::shared_ptr<sscl::ComponentThread>&)>& work);
template <typename InvokerFactory>
void runNonViralNurseryTask(
InvokerFactory &&invokerFactory,
std::chrono::milliseconds timeout = defaultProbeTaskTimeout)
{
runSync(
[this, &invokerFactory, timeout](
const std::shared_ptr<sscl::ComponentThread>& componentThread)
{
sscl::tests::runNonViralNurseryOnComponentThread(
componentThread,
std::forward<InvokerFactory>(invokerFactory),
timeout);
});
}
private:
std::string threadName;
std::shared_ptr<sscl::pptr::PuppeteerComponent> dummyComponent;
std::shared_ptr<sscl::ComponentThread> lastComponentThread;
};
using sscl::probe::defaultProbeTaskTimeout;
using sscl::probe::runNonViralNurseryOnComponentThread;
using sscl::probe::ProbeComponentThreadHarness;
} // namespace sscl::tests
+21 -16
View File
@@ -1,6 +1,9 @@
#ifndef SPINSCALE_TEST_SUPPORT_TIMER_AWAITERS_H
#define SPINSCALE_TEST_SUPPORT_TIMER_AWAITERS_H
#include <boostAsioLinkageFix.h>
#include <chrono>
#include <coroutine>
#include <memory>
#include <mutex>
@@ -9,15 +12,17 @@
#include <string>
#include <unordered_map>
#include <boost/asio/deadline_timer.hpp>
#include <boost/asio/error.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/date_time/posix_time/posix_time_types.hpp>
#include <boost/asio/steady_timer.hpp>
#include <boost/system/error_code.hpp>
namespace sscl::tests {
using SharedDeadlineTimer = std::shared_ptr<boost::asio::deadline_timer>;
using SharedSteadyTimer = std::shared_ptr<boost::asio::steady_timer>;
/* Keep historical names as aliases so existing spinscale tests stay readable. */
using SharedDeadlineTimer = SharedSteadyTimer;
class CancelableDeadlineTimerRegistry
{
@@ -30,7 +35,7 @@ public:
void registerTimer(
int labelMilliseconds,
const SharedDeadlineTimer &timer)
const SharedSteadyTimer &timer)
{
std::lock_guard<std::mutex> guard(mutex);
timersByLabel[labelMilliseconds] = timer;
@@ -43,15 +48,15 @@ public:
if (iterator == timersByLabel.end()) {
throw std::runtime_error(
"No cancelable deadline_timer registered for label "
"No cancelable steady_timer registered for label "
+ std::to_string(labelMilliseconds));
}
const SharedDeadlineTimer timer = iterator->second.lock();
const SharedSteadyTimer timer = iterator->second.lock();
if (!timer) {
throw std::runtime_error(
"Cancelable deadline_timer expired before cancel for label "
"Cancelable steady_timer expired before cancel for label "
+ std::to_string(labelMilliseconds));
}
@@ -60,7 +65,7 @@ public:
private:
std::mutex mutex;
std::unordered_map<int, std::weak_ptr<boost::asio::deadline_timer>>
std::unordered_map<int, std::weak_ptr<boost::asio::steady_timer>>
timersByLabel;
};
@@ -69,13 +74,13 @@ struct DeadlineTimerAwaiter
DeadlineTimerAwaiter(
boost::asio::io_context &ioContext,
int delayMilliseconds)
: timer(std::make_shared<boost::asio::deadline_timer>(ioContext))
: timer(std::make_shared<boost::asio::steady_timer>(ioContext))
{
start(delayMilliseconds);
}
DeadlineTimerAwaiter(
SharedDeadlineTimer sharedTimer,
SharedSteadyTimer sharedTimer,
int delayMilliseconds)
: timer(std::move(sharedTimer))
{
@@ -97,8 +102,8 @@ struct DeadlineTimerAwaiter
private:
void start(int delayMilliseconds)
{
timer->expires_from_now(
boost::posix_time::milliseconds(delayMilliseconds));
timer->expires_after(
std::chrono::milliseconds(delayMilliseconds));
timer->async_wait(
[this](const boost::system::error_code &errorCode)
{
@@ -110,7 +115,7 @@ private:
});
}
SharedDeadlineTimer timer;
SharedSteadyTimer timer;
boost::system::error_code completionErrorCode;
bool waitCompleted = false;
std::coroutine_handle<> resumeHandle;
@@ -123,7 +128,7 @@ struct RegisteredDeadlineTimerAwaiter
int delayMilliseconds,
int registrationLabelMilliseconds,
CancelableDeadlineTimerRegistry &registry)
: timer(std::make_shared<boost::asio::deadline_timer>(ioContext))
: timer(std::make_shared<boost::asio::steady_timer>(ioContext))
{
registry.registerTimer(registrationLabelMilliseconds, timer);
waiter.emplace(timer, delayMilliseconds);
@@ -138,7 +143,7 @@ struct RegisteredDeadlineTimerAwaiter
boost::system::error_code await_resume() const noexcept
{ return waiter->await_resume(); }
SharedDeadlineTimer timer;
SharedSteadyTimer timer;
std::optional<DeadlineTimerAwaiter> waiter;
};
@@ -147,7 +152,7 @@ inline void throwIfTimerWaitFailed(
{
if (waitError) {
throw std::runtime_error(
"deadline_timer wait failed: " + waitError.message());
"steady_timer wait failed: " + waitError.message());
}
}