Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: tests

on:
push:
pull_request:

jobs:
host-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure
run: cmake -S tests -B build
- name: Build
run: cmake --build build --parallel
- name: Test
run: ctest --test-dir build --output-on-failure
22 changes: 19 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,23 @@ Versioning](https://semver.org/).
> had little or no release-note detail, the entry is intentionally terse
> rather than inferring unsupported intent.

## \[3.0.0\] - 2026-08-13
## [3.0.1] - 2026-08-20

### Changed

- Added an atomic Observer-count fast path to `ThreadSafeObservable`.
- Notifications now return immediately when no Observers are registered,
avoiding the notification mutex and notification-lifetime `shared_ptr`
acquisition on the zero-Observer production path.
- Preserved the Observable 3.0 registration, mutation-during-notification,
exception and RAII handle semantics.

### Fixed

- Corrected stale `component.mk` compile-time version metadata that still
identified the library as 2.0.0.

## [3.0.0] - 2026-08-13

### Changed

Expand All @@ -29,7 +45,7 @@ Versioning](https://semver.org/).
- Corrected Observer-registration lifetime hazards during
callback/notification mutation.

## \[2.0.0\]
## [2.0.0]

### Changed

Expand All @@ -38,7 +54,7 @@ Versioning](https://semver.org/).
- Standardised the common `IObserver`-based synchronous observation
contract.

## \[1.x\]
## [1.x]

### Added

Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,19 @@ Development Platform.

## Latest Stable Version

**3.0.0**
**3.0.1**

### 3.0.1 performance maintenance

Version 3.0.1 preserves the Observable 3.0 public API and ownership-safe
registration model while reducing the cost of optional observability when no
Observers are registered.

`ThreadSafeObservable` now maintains a lightweight atomic Observer count so
`ExecuteNotification()` can return immediately without taking the notification
mutex or acquiring a notification-lifetime `shared_ptr` when there are no
Observers. Registration, unregistration, mutation-during-notification and RAII
handle semantics are unchanged.

## ESPressio Development Platform

Expand Down
6 changes: 3 additions & 3 deletions component.mk
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
COMPONENT_ADD_INCLUDEDIRS := src
COMPONENT_SRCDIRS := src
CXXFLAGS += -DESPRESSIO_OBSERVER
CXXFLAGS += -DESPRESSIO_OBSERVER_VERSION_MAJOR=2
CXXFLAGS += -DESPRESSIO_OBSERVER_VERSION_MAJOR=3
CXXFLAGS += -DESPRESSIO_OBSERVER_VERSION_MINOR=0
CXXFLAGS += -DESPRESSIO_OBSERVER_VERSION_PATCH=0
CXXFLAGS += -DESPRESSIO_OBSERVER_VERSION_STRING=\"2.0.0\"
CXXFLAGS += -DESPRESSIO_OBSERVER_VERSION_PATCH=1
CXXFLAGS += -DESPRESSIO_OBSERVER_VERSION_STRING=\"3.0.1\"
2 changes: 1 addition & 1 deletion library.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"type": "git",
"url": "https://github.com/Flowduino/ESPressio-Observable.git"
},
"version": "3.0.0",
"version": "3.0.1",
"license": "Apache-2.0",
"frameworks": "*",
"platforms": "*",
Expand Down
2 changes: 1 addition & 1 deletion library.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name=Flowduino ESPressio-Observable
version=3.0.0
version=3.0.1
author=Simon J. Stuart
maintainer=Flowduino.com
sentence=Observer Pattern library for microcontrollers with modern C++ toolchains
Expand Down
87 changes: 71 additions & 16 deletions src/ESPressio_ThreadSafeObservable.hpp
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
#pragma once

#include <algorithm>
#include <atomic>
#include <functional>
#include <memory>
#include <mutex>
#include <utility>
#include <vector>
#include <algorithm>

#include "ESPressio_IObservable.hpp"
#include "ESPressio_IObserver.hpp"
Expand All @@ -22,6 +23,7 @@ namespace ESPressio {
private:
std::vector<IObserverHandle*> _observers;
std::recursive_mutex _mutex;
std::atomic<std::size_t> _observerCount{0};
std::size_t _notificationDepth = 0;
bool _needsCompaction = false;

Expand Down Expand Up @@ -51,7 +53,9 @@ namespace ESPressio {
try {
for (std::size_t index = 0; index < observerCount; ++index) {
IObserverHandle* handle = _observers[index];
if (handle != nullptr) { callback(handle->GetObserver()); }
if (handle != nullptr) {
callback(handle->GetObserver());
}
}
} catch (...) {
_finishNotification();
Expand All @@ -68,9 +72,14 @@ namespace ESPressio {
try {
for (std::size_t index = 0; index < observerCount; ++index) {
IObserverHandle* handle = _observers[index];
if (handle == nullptr) { continue; }
ObserverType* observerAsT = dynamic_cast<ObserverType*>(handle->GetObserver());
if (observerAsT != nullptr) { callback(observerAsT); }
if (handle == nullptr) {
continue;
}
ObserverType* observerAsT =
dynamic_cast<ObserverType*>(handle->GetObserver());
if (observerAsT != nullptr) {
callback(observerAsT);
}
}
} catch (...) {
_finishNotification();
Expand Down Expand Up @@ -107,10 +116,29 @@ namespace ESPressio {

template <class Operation>
void ExecuteNotification(Operation&& operation) {
/*
* Notifications are intentionally very cheap when no observers
* are registered. The relaxed/acquire atomic read avoids taking
* the recursive mutex and avoids acquiring a notification-lifetime
* shared_ptr on the overwhelmingly common production fast path.
*
* A concurrently registering observer is not required to observe
* a notification that had already begun before registration.
*/
if (
_observerCount.load(
std::memory_order_acquire
) == 0
) {
return;
}

NotificationContext context(
*this, AcquireNotificationLifetime());
*this,
AcquireNotificationLifetime());
operation(context);
}

public:
~ThreadSafeObservable() override {
BeginObservableDestruction();
Expand All @@ -121,6 +149,7 @@ namespace ESPressio {
}
}
_observers.clear();
_observerCount.store(0, std::memory_order_release);
}

ObserverHandlePtr RegisterObserver(IObserver* observer) override {
Expand All @@ -137,26 +166,52 @@ namespace ESPressio {
std::unique_ptr<ObserverHandle> handle(
new ObserverHandle(GetLifetimeControl(), observer));
_observers.push_back(handle.get());
_observerCount.fetch_add(1, std::memory_order_release);
return ObserverHandlePtr(handle.release());
}

void UnregisterObserver(IObserver* observer) override {
std::lock_guard<std::recursive_mutex> lock(_mutex);
for (auto thisObserver = _observers.begin(); thisObserver != _observers.end(); thisObserver++) {
if ((*thisObserver)->GetObserver() == observer) {
static_cast<ObserverHandle*>((*thisObserver))->InvalidateRegistration();
if (_notificationDepth > 0) {
*thisObserver = nullptr;
_needsCompaction = true;
} else {
_observers.erase(thisObserver);
}
return;
for (
auto thisObserver = _observers.begin();
thisObserver != _observers.end();
++thisObserver
) {
if (
*thisObserver == nullptr ||
(*thisObserver)->GetObserver() != observer
) {
continue;
}

static_cast<ObserverHandle*>(
*thisObserver
)->InvalidateRegistration();

_observerCount.fetch_sub(
1,
std::memory_order_acq_rel
);

if (_notificationDepth > 0) {
*thisObserver = nullptr;
_needsCompaction = true;
} else {
_observers.erase(thisObserver);
}
return;
}
}

bool IsObserverRegistered(IObserver* observer) override {
if (
_observerCount.load(
std::memory_order_acquire
) == 0
) {
return false;
}

std::lock_guard<std::recursive_mutex> lock(_mutex);
return _isObserverRegistered(observer);
}
Expand Down
Loading