Refactor app loading and window management (#609)

This commit is contained in:
Ken Van Hoeylandt
2026-08-11 23:40:59 +02:00
committed by GitHub
parent dc3f6104b8
commit 37c507544b
243 changed files with 16034 additions and 10865 deletions
+134
View File
@@ -0,0 +1,134 @@
#include "doctest.h"
#include <tactility/bundle.h>
#include <cstring>
TEST_CASE("bundle_alloc/bundle_free round-trip") {
Bundle* bundle = bundle_alloc();
CHECK_NE(bundle, nullptr);
bundle_free(bundle);
}
TEST_CASE("bool can be stored and retrieved") {
Bundle* bundle = bundle_alloc();
bundle_put_bool(bundle, "key", true);
CHECK(bundle_has_bool(bundle, "key"));
CHECK_EQ(bundle_get_bool(bundle, "key"), true);
bool out = false;
CHECK(bundle_opt_bool(bundle, "key", &out));
CHECK_EQ(out, true);
bundle_free(bundle);
}
TEST_CASE("int32 can be stored and retrieved") {
Bundle* bundle = bundle_alloc();
bundle_put_int32(bundle, "key", -42);
CHECK(bundle_has_int32(bundle, "key"));
CHECK_EQ(bundle_get_int32(bundle, "key"), -42);
int32_t out = 0;
CHECK(bundle_opt_int32(bundle, "key", &out));
CHECK_EQ(out, -42);
bundle_free(bundle);
}
TEST_CASE("int64 can be stored and retrieved") {
Bundle* bundle = bundle_alloc();
bundle_put_int64(bundle, "key", 123456789012345LL);
CHECK(bundle_has_int64(bundle, "key"));
CHECK_EQ(bundle_get_int64(bundle, "key"), 123456789012345LL);
int64_t out = 0;
CHECK(bundle_opt_int64(bundle, "key", &out));
CHECK_EQ(out, 123456789012345LL);
bundle_free(bundle);
}
TEST_CASE("string can be stored and retrieved") {
Bundle* bundle = bundle_alloc();
bundle_put_string(bundle, "key", "hello world");
CHECK(bundle_has_string(bundle, "key"));
char buffer[32];
CHECK_EQ(bundle_get_string(bundle, "key", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "hello world"), 0);
char tiny[4];
CHECK_EQ(bundle_get_string(bundle, "key", tiny, sizeof(tiny)), ERROR_BUFFER_OVERFLOW);
char out[32];
CHECK_EQ(bundle_opt_string(bundle, "key", out, sizeof(out)), ERROR_NONE);
CHECK_EQ(std::strcmp(out, "hello world"), 0);
bundle_free(bundle);
}
TEST_CASE("has_*/opt_* reject a key stored with a different type") {
Bundle* bundle = bundle_alloc();
bundle_put_bool(bundle, "key", true);
CHECK_FALSE(bundle_has_int32(bundle, "key"));
CHECK_FALSE(bundle_has_int64(bundle, "key"));
CHECK_FALSE(bundle_has_string(bundle, "key"));
int32_t out_int32 = 0;
CHECK_FALSE(bundle_opt_int32(bundle, "key", &out_int32));
char out_string[8];
CHECK_EQ(bundle_opt_string(bundle, "key", out_string, sizeof(out_string)), ERROR_NOT_FOUND);
bundle_free(bundle);
}
TEST_CASE("opt_string reports ERROR_NOT_FOUND for a missing key") {
Bundle* bundle = bundle_alloc();
char out[8];
CHECK_EQ(bundle_opt_string(bundle, "missing", out, sizeof(out)), ERROR_NOT_FOUND);
bundle_free(bundle);
}
TEST_CASE("bundle_clone makes an independent deep copy") {
Bundle* original = bundle_alloc();
bundle_put_bool(original, "bool", true);
bundle_put_int32(original, "int32", 123);
bundle_put_string(original, "string", "text");
Bundle* clone = bundle_clone(original);
bundle_free(original); // clone must not be affected
CHECK_EQ(bundle_get_bool(clone, "bool"), true);
CHECK_EQ(bundle_get_int32(clone, "int32"), 123);
char buffer[16];
CHECK_EQ(bundle_get_string(clone, "string", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "text"), 0);
// Mutating the clone must not affect a re-clone of the (already-freed) original's data.
bundle_put_int32(clone, "int32", 456);
CHECK_EQ(bundle_get_int32(clone, "int32"), 456);
bundle_free(clone);
}
TEST_CASE("put overwrites a previously stored value, including across types") {
Bundle* bundle = bundle_alloc();
bundle_put_int32(bundle, "key", 1);
bundle_put_string(bundle, "key", "now a string");
CHECK_FALSE(bundle_has_int32(bundle, "key"));
CHECK(bundle_has_string(bundle, "key"));
char buffer[32];
CHECK_EQ(bundle_get_string(bundle, "key", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "now a string"), 0);
bundle_free(bundle);
}
@@ -0,0 +1,244 @@
#include "doctest.h"
#include <tactility/preferences.h>
#include <cstdio>
#include <cstring>
#include <sys/stat.h>
#include <unistd.h>
namespace {
const char* TEST_PATH = "/tmp/tactility_kernel_preferences_test.properties";
struct ScratchFile {
ScratchFile() { std::remove(TEST_PATH); }
~ScratchFile() { std::remove(TEST_PATH); }
};
bool file_exists(const char* path) {
FILE* file = std::fopen(path, "r");
if (file == nullptr) {
return false;
}
std::fclose(file);
return true;
}
bool is_directory(const char* path) {
struct stat info {};
return stat(path, &info) == 0 && (info.st_mode & S_IFMT) == S_IFDIR;
}
// Writes a raw properties file directly (bypassing preferences_put_*()) so a test can exercise
// a hand-crafted/corrupted payload that preferences_put_*() itself would never produce.
void write_raw(const char* path, const char* content) {
FILE* file = std::fopen(path, "w");
std::fputs(content, file);
std::fclose(file);
}
} // namespace
TEST_CASE("preferences_open_path on a missing file starts out empty, without creating it") {
ScratchFile scratch;
Preferences* preferences = preferences_open(TEST_PATH);
CHECK_NE(preferences, nullptr);
CHECK_FALSE(preferences_has_bool(preferences, "key"));
CHECK_FALSE(file_exists(TEST_PATH));
preferences_close(preferences);
}
TEST_CASE("put_*/has_*/opt_* round-trip all four types") {
ScratchFile scratch;
Preferences* preferences = preferences_open(TEST_PATH);
preferences_put_bool(preferences, "flag", true);
preferences_put_int32(preferences, "count", -42);
preferences_put_int64(preferences, "big", 123456789012345LL);
preferences_put_string(preferences, "text", "hello world");
CHECK(preferences_has_bool(preferences, "flag"));
bool bool_out = false;
CHECK(preferences_opt_bool(preferences, "flag", &bool_out));
CHECK_EQ(bool_out, true);
CHECK(preferences_has_int32(preferences, "count"));
int32_t int32_out = 0;
CHECK(preferences_opt_int32(preferences, "count", &int32_out));
CHECK_EQ(int32_out, -42);
CHECK(preferences_has_int64(preferences, "big"));
int64_t int64_out = 0;
CHECK(preferences_opt_int64(preferences, "big", &int64_out));
CHECK_EQ(int64_out, 123456789012345LL);
CHECK(preferences_has_string(preferences, "text"));
char buffer[32];
CHECK_EQ(preferences_opt_string(preferences, "text", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "hello world"), 0);
preferences_close(preferences);
}
TEST_CASE("opt_string reports ERROR_BUFFER_OVERFLOW and ERROR_NOT_FOUND") {
ScratchFile scratch;
Preferences* preferences = preferences_open(TEST_PATH);
preferences_put_string(preferences, "text", "hello world");
char tiny[4];
CHECK_EQ(preferences_opt_string(preferences, "text", tiny, sizeof(tiny)), ERROR_BUFFER_OVERFLOW);
char buffer[32];
CHECK_EQ(preferences_opt_string(preferences, "missing", buffer, sizeof(buffer)), ERROR_NOT_FOUND);
preferences_close(preferences);
}
TEST_CASE("has_*/opt_* reject a key stored with a different type") {
ScratchFile scratch;
Preferences* preferences = preferences_open(TEST_PATH);
preferences_put_bool(preferences, "key", true);
CHECK_FALSE(preferences_has_int32(preferences, "key"));
CHECK_FALSE(preferences_has_int64(preferences, "key"));
CHECK_FALSE(preferences_has_string(preferences, "key"));
int32_t out = 0;
CHECK_FALSE(preferences_opt_int32(preferences, "key", &out));
preferences_close(preferences);
}
TEST_CASE("has_*/opt_* reject malformed scalar payloads instead of misparsing them") {
ScratchFile scratch;
write_raw(TEST_PATH,
"bad_bool=b:garbage\n"
"bad_bool_2=b:2\n"
"trailing_junk=i32:42abc\n"
"int32_overflow=i32:5000000000\n"
"int64_overflow=i64:99999999999999999999\n"
"empty_int=i32:\n");
Preferences* preferences = preferences_open(TEST_PATH);
// "b:garbage" must not silently read back as false - has_bool()/opt_bool() must agree it's
// not a valid bool at all.
CHECK_FALSE(preferences_has_bool(preferences, "bad_bool"));
bool bool_out = true;
CHECK_FALSE(preferences_opt_bool(preferences, "bad_bool", &bool_out));
CHECK_FALSE(preferences_has_bool(preferences, "bad_bool_2"));
CHECK_FALSE(preferences_opt_bool(preferences, "bad_bool_2", &bool_out));
// "42abc" must not silently parse as 42 - the full payload must be consumed.
CHECK_FALSE(preferences_has_int32(preferences, "trailing_junk"));
int32_t int32_out = 0;
CHECK_FALSE(preferences_opt_int32(preferences, "trailing_junk", &int32_out));
// Fits in a (64-bit, on this platform) `long` but overflows int32_t - must not silently
// truncate on the narrowing cast.
CHECK_FALSE(preferences_has_int32(preferences, "int32_overflow"));
CHECK_FALSE(preferences_opt_int32(preferences, "int32_overflow", &int32_out));
// Overflows even a 64-bit integer - strtoll() itself reports ERANGE.
CHECK_FALSE(preferences_has_int64(preferences, "int64_overflow"));
int64_t int64_out = 0;
CHECK_FALSE(preferences_opt_int64(preferences, "int64_overflow", &int64_out));
CHECK_FALSE(preferences_has_int32(preferences, "empty_int"));
CHECK_FALSE(preferences_opt_int32(preferences, "empty_int", &int32_out));
preferences_close(preferences);
}
TEST_CASE("a string value with embedded newlines and backslashes survives a reopen") {
ScratchFile scratch;
{
Preferences* preferences = preferences_open(TEST_PATH);
preferences_put_string(preferences, "text", "line1\nline2 with \\ backslash");
preferences_close(preferences);
}
{
Preferences* preferences = preferences_open(TEST_PATH);
char buffer[64];
CHECK_EQ(preferences_opt_string(preferences, "text", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "line1\nline2 with \\ backslash"), 0);
preferences_close(preferences);
}
}
TEST_CASE("preferences_close persists changes, and only close persists them") {
ScratchFile scratch;
Preferences* preferences = preferences_open(TEST_PATH);
preferences_put_bool(preferences, "flag", true);
// Not persisted yet - only preferences_close() writes to disk.
CHECK_FALSE(file_exists(TEST_PATH));
preferences_close(preferences);
CHECK(file_exists(TEST_PATH));
Preferences* reopened = preferences_open(TEST_PATH);
CHECK(preferences_has_bool(reopened, "flag"));
preferences_close(reopened);
}
TEST_CASE("put_* on an already-closed value is visible without reopening") {
ScratchFile scratch;
Preferences* preferences = preferences_open(TEST_PATH);
preferences_put_int32(preferences, "count", 1);
preferences_close(preferences);
Preferences* reopened = preferences_open(TEST_PATH);
preferences_put_int32(reopened, "count", 2);
int32_t out = 0;
CHECK(preferences_opt_int32(reopened, "count", &out));
CHECK_EQ(out, 2);
preferences_close(reopened);
Preferences* final_instance = preferences_open(TEST_PATH);
CHECK(preferences_opt_int32(final_instance, "count", &out));
CHECK_EQ(out, 2);
preferences_close(final_instance);
}
TEST_CASE("preferences_open creates missing parent directories (recursively) and persists into them") {
const char* nested_dir_a = "/tmp/tactility_kernel_preferences_test_nested";
const char* nested_dir_b = "/tmp/tactility_kernel_preferences_test_nested/a";
const char* nested_dir_c = "/tmp/tactility_kernel_preferences_test_nested/a/b";
const char* nested_path = "/tmp/tactility_kernel_preferences_test_nested/a/b/settings.properties";
std::remove(nested_path);
rmdir(nested_dir_c);
rmdir(nested_dir_b);
rmdir(nested_dir_a);
REQUIRE_FALSE(is_directory(nested_dir_a));
Preferences* preferences = preferences_open(nested_path);
REQUIRE_NE(preferences, nullptr);
CHECK(is_directory(nested_dir_a));
CHECK(is_directory(nested_dir_b));
CHECK(is_directory(nested_dir_c));
preferences_put_int32(preferences, "count", 7);
preferences_close(preferences);
CHECK(file_exists(nested_path));
Preferences* reopened = preferences_open(nested_path);
REQUIRE_NE(reopened, nullptr);
int32_t out = 0;
CHECK(preferences_opt_int32(reopened, "count", &out));
CHECK_EQ(out, 7);
preferences_close(reopened);
std::remove(nested_path);
rmdir(nested_dir_c);
rmdir(nested_dir_b);
rmdir(nested_dir_a);
}
@@ -0,0 +1,258 @@
#include "doctest.h"
#include <tactility/properties_file.h>
#include <cstdio>
#include <cstring>
#include <string>
#include <sys/stat.h>
#include <unistd.h>
#include <utility>
#include <vector>
namespace {
const char* TEST_PATH = "/tmp/tactility_kernel_properties_file_test.properties";
struct ScratchFile {
ScratchFile() { std::remove(TEST_PATH); }
~ScratchFile() { std::remove(TEST_PATH); }
};
bool file_exists(const char* path) {
FILE* file = std::fopen(path, "r");
if (file == nullptr) {
return false;
}
std::fclose(file);
return true;
}
void write_raw(const char* path, const char* content) {
FILE* file = std::fopen(path, "w");
std::fputs(content, file);
std::fclose(file);
}
} // namespace
TEST_CASE("properties_file_open on a missing file starts out empty, without creating it") {
ScratchFile scratch;
PropertiesFile* file = properties_file_open(TEST_PATH);
CHECK_NE(file, nullptr);
CHECK_FALSE(properties_file_has(file, "key"));
CHECK_FALSE(file_exists(TEST_PATH));
properties_file_close(file);
}
TEST_CASE("properties_file_open returns NULL when a genuine I/O error interrupts reading") {
// fopen() on a directory succeeds on Linux, but the first read fails with EISDIR and sets
// the stream's error indicator - a deterministic way to exercise load_from_file()'s
// ferror() check without needing real storage-hardware fault injection.
const char* dir_path = "/tmp/tactility_kernel_properties_file_test_is_a_directory";
rmdir(dir_path);
REQUIRE_EQ(mkdir(dir_path, 0777), 0);
CHECK_EQ(properties_file_open(dir_path), nullptr);
rmdir(dir_path);
}
TEST_CASE("set/has/get round-trip, and close persists while unclosed changes don't") {
ScratchFile scratch;
PropertiesFile* file = properties_file_open(TEST_PATH);
properties_file_set(file, "key", "value");
CHECK(properties_file_has(file, "key"));
char buffer[32];
CHECK_EQ(properties_file_get(file, "key", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "value"), 0);
// Not persisted yet - only properties_file_close() writes to disk.
CHECK_FALSE(file_exists(TEST_PATH));
properties_file_close(file);
CHECK(file_exists(TEST_PATH));
PropertiesFile* reopened = properties_file_open(TEST_PATH);
CHECK(properties_file_has(reopened, "key"));
properties_file_close(reopened);
}
TEST_CASE("properties_file_get reports ERROR_BUFFER_OVERFLOW and ERROR_NOT_FOUND") {
ScratchFile scratch;
PropertiesFile* file = properties_file_open(TEST_PATH);
properties_file_set(file, "key", "value");
char tiny[3];
CHECK_EQ(properties_file_get(file, "key", tiny, sizeof(tiny)), ERROR_BUFFER_OVERFLOW);
char buffer[32];
CHECK_EQ(properties_file_get(file, "missing", buffer, sizeof(buffer)), ERROR_NOT_FOUND);
properties_file_close(file);
}
TEST_CASE("set overwrites a previously stored value") {
ScratchFile scratch;
PropertiesFile* file = properties_file_open(TEST_PATH);
properties_file_set(file, "key", "first");
properties_file_set(file, "key", "second");
char buffer[32];
CHECK_EQ(properties_file_get(file, "key", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "second"), 0);
properties_file_close(file);
}
TEST_CASE("comments and blank lines are skipped, keys and values are trimmed") {
ScratchFile scratch;
write_raw(TEST_PATH,
"# Comment\n"
" \t# Indented comment\n"
"\n"
"key1=value1\n"
" \tkey 2\t = \tvalue 2\t \n");
PropertiesFile* file = properties_file_open(TEST_PATH);
char buffer[32];
CHECK_EQ(properties_file_get(file, "key1", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "value1"), 0);
// Only leading/trailing whitespace is trimmed - the internal space in "key 2"/"value 2"
// survives.
CHECK_EQ(properties_file_get(file, "key 2", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "value 2"), 0);
properties_file_close(file);
}
TEST_CASE("a malformed line (no '=') is skipped without aborting the rest of the file") {
ScratchFile scratch;
write_raw(TEST_PATH, "not_a_key_value_pair\nkey=value\n");
PropertiesFile* file = properties_file_open(TEST_PATH);
CHECK(properties_file_has(file, "key"));
CHECK_FALSE(properties_file_has(file, "not_a_key_value_pair"));
properties_file_close(file);
}
TEST_CASE("a [section] line prefixes every following key until the next section") {
ScratchFile scratch;
write_raw(TEST_PATH,
"[app]\n"
"id=one.tactility.helloworld\n"
"name=Hello\n"
"[other]\n"
"id=x\n");
PropertiesFile* file = properties_file_open(TEST_PATH);
char buffer[64];
CHECK_EQ(properties_file_get(file, "[app]id", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "one.tactility.helloworld"), 0);
CHECK_EQ(properties_file_get(file, "[app]name", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "Hello"), 0);
CHECK_EQ(properties_file_get(file, "[other]id", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "x"), 0);
CHECK_FALSE(properties_file_has(file, "id"));
properties_file_close(file);
}
TEST_CASE("properties_file_close reports ERROR_NONE on success") {
ScratchFile scratch;
PropertiesFile* file = properties_file_open(TEST_PATH);
properties_file_set(file, "key", "value");
CHECK_EQ(properties_file_close(file), ERROR_NONE);
}
TEST_CASE("properties_file_close reports ERROR_RESOURCE when the parent directory doesn't exist") {
const char* path = "/tmp/tactility_kernel_properties_file_test_missing_dir/settings.properties";
std::remove(path); // no-op if the directory doesn't exist, which is the point of this test
// Missing directory is not an error for open() - it starts out empty, same as a missing
// file (see the "starts out empty" test above).
PropertiesFile* file = properties_file_open(path);
REQUIRE_NE(file, nullptr);
properties_file_set(file, "key", "value");
// close()'s save can't create its temp file in a directory that doesn't exist.
CHECK_EQ(properties_file_close(file), ERROR_RESOURCE);
}
TEST_CASE("a failed close leaves previously-saved content on disk untouched") {
if (geteuid() == 0) {
// Root bypasses directory write permissions, so the read-only directory below would
// not make save_to_file() fail.
return;
}
const char* dir = "/tmp/tactility_kernel_properties_file_readonly_test";
const char* path = "/tmp/tactility_kernel_properties_file_readonly_test/settings.properties";
mkdir(dir, 0700);
chmod(dir, 0700);
std::remove(path);
{
PropertiesFile* file = properties_file_open(path);
properties_file_set(file, "key", "original");
REQUIRE_EQ(properties_file_close(file), ERROR_NONE);
}
// Read-only directory - save_to_file()'s temp file can't be created there, so the close
// below must fail without disturbing the "original" content already on disk.
REQUIRE_EQ(chmod(dir, 0555), 0);
PropertiesFile* file = properties_file_open(path);
properties_file_set(file, "key", "corrupted");
CHECK_EQ(properties_file_close(file), ERROR_RESOURCE);
chmod(dir, 0700); // restore write access for the check below and for cleanup
PropertiesFile* reloaded = properties_file_open(path);
char buffer[32];
CHECK_EQ(properties_file_get(reloaded, "key", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "original"), 0);
properties_file_close(reloaded);
std::remove(path);
rmdir(dir);
}
TEST_CASE("properties_file_for_each visits every key exactly once") {
ScratchFile scratch;
PropertiesFile* file = properties_file_open(TEST_PATH);
properties_file_set(file, "a", "1");
properties_file_set(file, "b", "2");
properties_file_set(file, "c", "3");
std::vector<std::pair<std::string, std::string>> seen;
properties_file_for_each(file, [](const char* key, const char* value, void* context) {
auto* out = static_cast<std::vector<std::pair<std::string, std::string>>*>(context);
out->emplace_back(key, value);
}, &seen);
CHECK_EQ(seen.size(), 3);
for (const auto& [key, value] : seen) {
if (key == "a") CHECK_EQ(value, "1");
else if (key == "b") CHECK_EQ(value, "2");
else if (key == "c") CHECK_EQ(value, "3");
else FAIL("unexpected key: " << key);
}
properties_file_close(file);
}
+270 -46
View File
@@ -1,20 +1,24 @@
#include "doctest.h"
#include <tactility/concurrent/thread.h>
#include <tactility/delay.h>
#include <tactility/system_event.h>
#include <tactility/time.h>
#include <vector>
// system_event_emit() snapshots matching subscriptions under the lock, then invokes them
// after unlocking (see the @warning on system_event_subscribe() in system_event.h), so a
// callback calling system_event_subscribe()/_unsubscribe()/_emit() must not deadlock -
// after unlocking (see the @warning on system_event_callback_add() in system_event.h), so a
// callback calling system_event_callback_add()/_unsubscribe()/_emit() must not deadlock -
// covered below, mirroring DeviceListenerTest.cpp's reentrancy test.
struct RecordedCall {
void* context;
SystemEventType type;
const void* data;
size_t data_len;
// Copied out of event->data during the callback - event->data is only valid for the
// duration of the callback (it lives in system_event_emit()'s own stack frame), so a bare
// pointer/length pair recorded here would dangle by the time a TEST_CASE inspects it.
std::vector<uint8_t> data;
uint64_t timestamp;
};
@@ -22,11 +26,11 @@ static std::vector<RecordedCall> calls_a;
static std::vector<RecordedCall> calls_b;
static void listener_a(SystemEvent* event, void* context) {
calls_a.push_back({ context, event->type, event->data, event->data_len, event->timestamp });
calls_a.push_back({ context, event->type, std::vector<uint8_t>(event->data, event->data + event->data_len), event->timestamp });
}
static void listener_b(SystemEvent* event, void* context) {
calls_b.push_back({ context, event->type, event->data, event->data_len, event->timestamp });
calls_b.push_back({ context, event->type, std::vector<uint8_t>(event->data, event->data + event->data_len), event->timestamp });
}
static void reset_calls() {
@@ -39,8 +43,8 @@ TEST_CASE("system_event_emit invokes every subscriber registered for that type")
int context_a = 1;
int context_b = 2;
CHECK_EQ(system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a), ERROR_NONE);
CHECK_EQ(system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_b, &context_b), ERROR_NONE);
CHECK_EQ(system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a), ERROR_NONE);
CHECK_EQ(system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_b, &context_b), ERROR_NONE);
CHECK_EQ(system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0), ERROR_NONE);
@@ -51,14 +55,14 @@ TEST_CASE("system_event_emit invokes every subscriber registered for that type")
REQUIRE_EQ(calls_b.size(), 1);
CHECK_EQ(calls_b[0].context, &context_b);
system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a);
system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_b);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_b);
}
TEST_CASE("system_event_emit only invokes subscribers registered for the emitted type") {
reset_calls();
int context_a = 1;
system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a);
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a);
system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0);
CHECK_EQ(calls_a.size(), 0);
@@ -66,80 +70,78 @@ TEST_CASE("system_event_emit only invokes subscribers registered for the emitted
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
CHECK_EQ(calls_a.size(), 1);
system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a);
}
TEST_CASE("system_event_emit passes the data pointer and length through unchanged") {
TEST_CASE("system_event_emit copies the data into the delivered event") {
reset_calls();
int context_a = 1;
struct Payload { int value; } payload { 42 };
system_event_subscribe(KERNEL_EVENT_TIME_CHANGED, listener_a, &context_a);
system_event_callback_add(KERNEL_EVENT_TIME_CHANGED, listener_a, &context_a);
system_event_emit(KERNEL_EVENT_TIME_CHANGED, &payload, sizeof(payload));
REQUIRE_EQ(calls_a.size(), 1);
CHECK_EQ(calls_a[0].data, &payload);
CHECK_EQ(calls_a[0].data_len, sizeof(payload));
CHECK_EQ(static_cast<const Payload*>(calls_a[0].data)->value, 42);
REQUIRE_EQ(calls_a[0].data.size(), sizeof(payload));
CHECK_EQ(reinterpret_cast<const Payload*>(calls_a[0].data.data())->value, 42);
system_event_unsubscribe(KERNEL_EVENT_TIME_CHANGED, listener_a);
system_event_callback_remove(KERNEL_EVENT_TIME_CHANGED, listener_a);
}
TEST_CASE("system_event_emit with no data passes a null pointer and zero length") {
TEST_CASE("system_event_emit with no data delivers an empty payload") {
reset_calls();
int context_a = 1;
system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a);
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a);
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
REQUIRE_EQ(calls_a.size(), 1);
CHECK_EQ(calls_a[0].data, nullptr);
CHECK_EQ(calls_a[0].data_len, 0);
CHECK(calls_a[0].data.empty());
system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a);
}
TEST_CASE("system_event_unsubscribe stops further notifications for that callback only") {
TEST_CASE("system_event_callback_remove stops further notifications for that callback only") {
reset_calls();
int context_a = 1;
int context_b = 2;
system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a);
system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_b, &context_b);
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a);
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_b, &context_b);
CHECK_EQ(system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a), ERROR_NONE);
CHECK_EQ(system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a), ERROR_NONE);
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
CHECK_EQ(calls_a.size(), 0);
CHECK_EQ(calls_b.size(), 1);
system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_b);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_b);
}
TEST_CASE("system_event_unsubscribe on an unregistered callback returns ERROR_NOT_FOUND and is a no-op") {
TEST_CASE("system_event_callback_remove on an unregistered callback returns ERROR_NOT_FOUND and is a no-op") {
reset_calls();
int context_b = 2;
system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_b, &context_b);
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_b, &context_b);
// listener_a was never added for this type, so removing it must not disturb listener_b.
CHECK_EQ(system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a), ERROR_NOT_FOUND);
CHECK_EQ(system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a), ERROR_NOT_FOUND);
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
CHECK_EQ(calls_b.size(), 1);
system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_b);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_b);
}
TEST_CASE("system_event_unsubscribe matches on (type, callback), not the callback alone") {
TEST_CASE("system_event_callback_remove matches on (type, callback), not the callback alone") {
reset_calls();
int context_a = 1;
// Same callback subscribed for two different event types.
system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a);
system_event_subscribe(KERNEL_EVENT_TIME_CHANGED, listener_a, &context_a);
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a);
system_event_callback_add(KERNEL_EVENT_TIME_CHANGED, listener_a, &context_a);
system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a);
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
CHECK_EQ(calls_a.size(), 0);
@@ -147,7 +149,7 @@ TEST_CASE("system_event_unsubscribe matches on (type, callback), not the callbac
system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0);
CHECK_EQ(calls_a.size(), 1);
system_event_unsubscribe(KERNEL_EVENT_TIME_CHANGED, listener_a);
system_event_callback_remove(KERNEL_EVENT_TIME_CHANGED, listener_a);
}
TEST_CASE("system_event_emit with no subscribers for that type returns ERROR_NONE") {
@@ -157,7 +159,7 @@ TEST_CASE("system_event_emit with no subscribers for that type returns ERROR_NON
TEST_CASE("system_event_emit stamps the event with the current boot-relative time") {
reset_calls();
int context_a = 1;
system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a);
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a);
auto before = static_cast<uint64_t>(get_micros_since_boot());
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
@@ -167,22 +169,22 @@ TEST_CASE("system_event_emit stamps the event with the current boot-relative tim
CHECK_GE(calls_a[0].timestamp, before);
CHECK_LE(calls_a[0].timestamp, after);
system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a);
}
static bool reentrant_add_triggered = false;
static void reentrant_listener(SystemEvent* event, void* context) {
calls_a.push_back({ context, event->type, event->data, event->data_len, event->timestamp });
calls_a.push_back({ context, event->type, std::vector<uint8_t>(event->data, event->data + event->data_len), event->timestamp });
if (!reentrant_add_triggered) {
reentrant_add_triggered = true;
// Subscribing from within a notification must not deadlock: emit() releases the
// lock before invoking callbacks, so this only blocks briefly on the (already
// unlocked) mutex.
system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_b, context);
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_b, context);
// Also exercise unsubscribe() and a nested emit() of a different type from within
// a callback - all must complete without deadlocking.
system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, reentrant_listener);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, reentrant_listener);
system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0);
}
}
@@ -192,8 +194,8 @@ TEST_CASE("system_event_emit is safe when a callback subscribes, unsubscribes an
reentrant_add_triggered = false;
int context_a = 1;
system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, reentrant_listener, &context_a);
system_event_subscribe(KERNEL_EVENT_TIME_CHANGED, listener_b, &context_a);
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, reentrant_listener, &context_a);
system_event_callback_add(KERNEL_EVENT_TIME_CHANGED, listener_b, &context_a);
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
@@ -210,6 +212,228 @@ TEST_CASE("system_event_emit is safe when a callback subscribes, unsubscribes an
CHECK_EQ(calls_a.size(), 1);
CHECK_EQ(calls_b.size(), 2);
system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_b);
system_event_unsubscribe(KERNEL_EVENT_TIME_CHANGED, listener_b);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_b);
system_event_callback_remove(KERNEL_EVENT_TIME_CHANGED, listener_b);
}
// gps.h-style poll subscription: system_event_subscribe()/_await()/_unsubscribe().
//
// system_event_await() only detects sequence increments that happen *after* it starts
// waiting (same as gps_api_event_await()), so the emit must be started from another task
// while this one is already blocked in await() - emitting first and awaiting after would
// race the notification the same way it would with any FreeRTOS task-notify consumer.
TEST_CASE("system_event_subscribe/_await deliver the event payload by value") {
SystemEventSubscription sub {};
sub.event.type = KERNEL_EVENT_NETWORK_CONNECTED;
CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE);
NetworkConnectedEvent connected { .device = nullptr, .ipv4_addr = 0x0A000001, .gateway = 0x0A0000FE };
auto* thread = thread_alloc_full(
"system-event-emitter",
4096,
[](void* context) {
delay_millis(20);
auto* connected_ptr = static_cast<NetworkConnectedEvent*>(context);
system_event_emit(KERNEL_EVENT_NETWORK_CONNECTED, connected_ptr, sizeof(*connected_ptr));
return 0;
},
&connected,
-1
);
CHECK_EQ(thread_start(thread), ERROR_NONE);
CHECK_EQ(system_event_await(&sub, pdMS_TO_TICKS(2000)), ERROR_NONE);
NetworkConnectedEvent received {};
CHECK_EQ(system_event_get_data(&sub, reinterpret_cast<uint8_t*>(&received), sizeof(received)), ERROR_NONE);
CHECK_EQ(received.ipv4_addr, connected.ipv4_addr);
CHECK_EQ(received.gateway, connected.gateway);
CHECK_EQ(thread_join(thread, 2, 1), ERROR_NONE);
thread_free(thread);
CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NONE);
CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NOT_FOUND);
}
TEST_CASE("system_event_await returns a matching event that arrived before it started waiting") {
SystemEventSubscription sub {};
sub.event.type = KERNEL_EVENT_NETWORK_CONNECTED;
CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE);
// Same-thread emit, no background thread needed: unlike the "detects a change after it
// starts waiting" tests above, this is exactly the case system_event_await() must handle -
// sequence already moved ahead of consumed_sequence before await() is even called.
NetworkConnectedEvent connected { .device = nullptr, .ipv4_addr = 0x0A000001, .gateway = 0x0A0000FE };
CHECK_EQ(system_event_emit(KERNEL_EVENT_NETWORK_CONNECTED, &connected, sizeof(connected)), ERROR_NONE);
CHECK_EQ(system_event_await(&sub, 0), ERROR_NONE);
NetworkConnectedEvent received {};
CHECK_EQ(system_event_get_data(&sub, reinterpret_cast<uint8_t*>(&received), sizeof(received)), ERROR_NONE);
CHECK_EQ(received.ipv4_addr, connected.ipv4_addr);
CHECK_EQ(received.gateway, connected.gateway);
// The pending event was consumed by the call above - a second await() with no further
// emit must time out rather than returning the same event again.
CHECK_EQ(system_event_await(&sub, 0), ERROR_TIMEOUT);
system_event_unsubscribe(&sub);
}
TEST_CASE("system_event_await times out when no matching event has arrived") {
SystemEventSubscription sub {};
sub.event.type = KERNEL_EVENT_TIME_CHANGED;
system_event_subscribe(&sub);
CHECK_EQ(system_event_await(&sub, 0), ERROR_TIMEOUT);
system_event_unsubscribe(&sub);
}
TEST_CASE("system_event_emit does not notify a poll subscriber of a different type") {
SystemEventSubscription sub {};
sub.event.type = KERNEL_EVENT_BOOT_COMPLETED;
system_event_subscribe(&sub);
system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0);
CHECK_EQ(system_event_await(&sub, 0), ERROR_TIMEOUT);
system_event_unsubscribe(&sub);
}
TEST_CASE("system_event_get_data reports ERROR_BUFFER_OVERFLOW and leaves the buffer untouched") {
SystemEventSubscription sub {};
sub.event.type = KERNEL_EVENT_NETWORK_DISCONNECTED;
CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE);
// system_event_await() only detects sequence increments that happen *after* it starts
// waiting (see the comment above), so the emit must come from another task while this one
// is already blocked in await() - same pattern as the payload-delivery test above.
NetworkDisconnectedEvent disconnected { .device = nullptr };
auto* thread = thread_alloc_full(
"system-event-emitter",
4096,
[](void* context) {
delay_millis(20);
auto* disconnected_ptr = static_cast<NetworkDisconnectedEvent*>(context);
system_event_emit(KERNEL_EVENT_NETWORK_DISCONNECTED, disconnected_ptr, sizeof(*disconnected_ptr));
return 0;
},
&disconnected,
-1
);
CHECK_EQ(thread_start(thread), ERROR_NONE);
CHECK_EQ(system_event_await(&sub, pdMS_TO_TICKS(2000)), ERROR_NONE);
CHECK_EQ(thread_join(thread, 2, 1), ERROR_NONE);
thread_free(thread);
uint8_t tiny[1] = { 0xAA };
CHECK_EQ(system_event_get_data(&sub, tiny, sizeof(tiny)), ERROR_BUFFER_OVERFLOW);
CHECK_EQ(tiny[0], 0xAA);
uint8_t exact[sizeof(NetworkDisconnectedEvent)];
CHECK_EQ(system_event_get_data(&sub, exact, sizeof(exact)), ERROR_NONE);
system_event_unsubscribe(&sub);
}
TEST_CASE("system_event_get_data on a subscription with no payload copies nothing and succeeds") {
SystemEventSubscription sub {};
sub.event.type = KERNEL_EVENT_BOOT_COMPLETED;
CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE);
auto* thread = thread_alloc_full(
"system-event-emitter",
4096,
[](void*) {
delay_millis(20);
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
return 0;
},
nullptr,
-1
);
CHECK_EQ(thread_start(thread), ERROR_NONE);
CHECK_EQ(system_event_await(&sub, pdMS_TO_TICKS(2000)), ERROR_NONE);
CHECK_EQ(thread_join(thread, 2, 1), ERROR_NONE);
thread_free(thread);
uint8_t buffer[1] = { 0x42 };
CHECK_EQ(system_event_get_data(&sub, buffer, 0), ERROR_NONE);
CHECK_EQ(buffer[0], 0x42); // untouched - nothing to copy
system_event_unsubscribe(&sub);
}
// Regression coverage for system_event_unsubscribe() racing a task blocked in
// system_event_await() on the same subscription, and for reusing a subscription node after
// unsubscribing it - see the @warning on system_event_unsubscribe() in system_event.h.
TEST_CASE("system_event_unsubscribe wakes a task blocked in system_event_await with ERROR_INVALID_STATE") {
SystemEventSubscription sub {};
sub.event.type = KERNEL_EVENT_SERVICE_STARTED;
CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE);
auto* thread = thread_alloc_full(
"system-event-awaiter",
4096,
[](void* context) {
auto* awaited_sub = static_cast<SystemEventSubscription*>(context);
// Long timeout - the point is that unsubscribe() wakes this early, not that it
// eventually times out on its own.
return static_cast<int32_t>(system_event_await(awaited_sub, pdMS_TO_TICKS(5000)));
},
&sub,
-1
);
CHECK_EQ(thread_start(thread), ERROR_NONE);
// Give the awaiter task a moment to actually reach xSemaphoreTake() before unsubscribing -
// otherwise this test wouldn't exercise the "already blocked" race at all.
delay_millis(20);
// Must return promptly (nudging the blocked awaiter awake), not by waiting out its timeout.
TickType_t before = get_ticks();
CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NONE);
CHECK_LT(get_ticks() - before, pdMS_TO_TICKS(1000));
CHECK_EQ(thread_join(thread, pdMS_TO_TICKS(2000), pdMS_TO_TICKS(1)), ERROR_NONE);
CHECK_EQ(thread_get_return_code(thread), ERROR_INVALID_STATE);
thread_free(thread);
// A second unsubscribe() has nothing left to do.
CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NOT_FOUND);
}
TEST_CASE("a subscription node can be re-subscribed after system_event_unsubscribe") {
SystemEventSubscription sub {};
sub.event.type = KERNEL_EVENT_SERVICE_STOPPED;
CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE);
CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NONE);
// Re-registering the same node (same storage, not a fresh SystemEventSubscription) must
// work as if it were new - a fresh semaphore, and no leftover `cancelled` state from the
// unsubscribe() above causing an immediate spurious ERROR_INVALID_STATE below.
CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE);
auto* thread = thread_alloc_full(
"system-event-emitter",
4096,
[](void*) {
delay_millis(20);
system_event_emit(KERNEL_EVENT_SERVICE_STOPPED, nullptr, 0);
return 0;
},
nullptr,
-1
);
CHECK_EQ(thread_start(thread), ERROR_NONE);
CHECK_EQ(system_event_await(&sub, pdMS_TO_TICKS(2000)), ERROR_NONE);
CHECK_EQ(thread_join(thread, pdMS_TO_TICKS(2000), pdMS_TO_TICKS(1)), ERROR_NONE);
thread_free(thread);
CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NONE);
}