Compare commits

..

No commits in common. "main" and "docs/assessment" have entirely different histories.

11 changed files with 15 additions and 378 deletions

View File

@ -1,12 +1,10 @@
# azkoyen_technical_test # azkoyen_technical_test
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) Azkoyen technical test implementation. Implemented (mostly) on standard c++ 17 framework, but with Qt wherever was necessary.
Azkoyen technical test implementation. Implemented (mostly) on standard C++17, but with Qt wherever it was strictly necessary.
## Development approach ## Development approach
A Test-Driven Development (TDD) workflow was followed throughout the project. Every component — from the lowest-level file reader to the GUI window — has a corresponding Google Test suite written before or alongside the production code. This keeps each module verifiable in isolation and makes regressions immediately visible. A Test-Driven Development (TDD) workflow was followed throughout the project. Every component — from the lowest-level file reader to the GUI window — has a corresponding Google Test suite that was written before (or alongside) the production code. This ensures each module behaves correctly in isolation and makes regressions immediately visible.
## SysfsRead class ## SysfsRead class
@ -26,60 +24,24 @@ The reader never throws on I/O errors; every outcome is expressed through the en
## Producer class / thread ## Producer class / thread
`Producer` ([include/Producer.hpp](include/Producer.hpp), [src/core/Producer.cxx](src/core/Producer.cxx)) runs a worker `std::thread` that periodically polls the `SysfsReader` and, when the status is `Enabled`, generates a random integer and forwards it through an injected `send_fn` callback. The polling interval is 1 second under normal conditions and 7 seconds when the sysfs file reports `ErrorTempTooHigh` (cool-down). All dependencies (send function, random generator, logger, sleep) are injected, so the producer has no Qt dependency and no knowledge of sockets. `Producer` ([include/Producer.hpp](include/Producer.hpp), [src/core/Producer.cxx](src/core/Producer.cxx)) runs a worker `std::thread` that periodically polls the `SysfsReader` and, when the status is `Enabled`, generates a random integer and forwards it through an injected `send_fn` callback. The polling interval is 1 second under normal conditions and 7 seconds when the sysfs file reports `ErrorTempTooHigh` (cool-down).
**Tests:** [tests/test_producer.cxx](tests/test_producer.cxx) — verifies that the send callback is called when `Enabled`, and is not called for `Unreachable`, `Empty`, `ErrorTempTooHigh`, and `UnexpectedValue`. Uses an injected no-op sleep to run at full speed.
## UnixIpcBridge ## UnixIpcBridge
>[!note] >[!note]
>Why UNIX domain sockets? More experience with them under Linux than with POSIX shared memory and semaphores, and they map cleanly to mockable abstractions for unit testing. >why unix domain sockets? Because I have more experience with them under linux than with posix shared memmory and semaphore, and I find them easier to unit-test.
`UnixIpcBridge` ([include/UnixIpcBridge.hpp](include/UnixIpcBridge.hpp), [src/core/UnixIpcBridge.cxx](src/core/UnixIpcBridge.cxx)) connects to a UNIX domain socket and sends a single `int` per call. It opens a new connection for each value, keeping the protocol stateless and simple. `UnixIpcBridge` ([include/UnixIpcBridge.hpp](include/UnixIpcBridge.hpp), [src/core/UnixIpcBridge.cxx](src/core/UnixIpcBridge.cxx)) is a small helper that connects to a UNIX domain socket and sends a single `int` per call. It opens a new connection for each value, which keeps the protocol stateless and simple.
**Tests:** [tests/test_unix_ipc.cxx](tests/test_unix_ipc.cxx) — spins up a `FakeConsumer` server, sends values through the bridge, and asserts they arrive correctly. Covers single value, zero, negative, `INT_MAX`/`INT_MIN`, multiple sequential sends, and throws-when-no-server. **Tests:** [tests/test_unix_ipc.cxx](tests/test_unix_ipc.cxx) — spins up a fake socket server, sends values through the bridge, and asserts they arrive correctly.
## ConsumerThread ## ConsumerThread
`ConsumerThread` ([include/Consumer.hpp](include/Consumer.hpp), [src/core/Consumer.cxx](src/core/Consumer.cxx)) is a `QObject` that listens on a UNIX domain socket in a background `std::thread`. On each received integer it: `ConsumerThread` ([include/ConsumerThread.hpp](include/ConsumerThread.hpp), [src/core/ConsumerThread.cxx](src/core/ConsumerThread.cxx)) is a `QObject` that listens on a UNIX domain socket in a background `std::thread`. On each received integer it:
1. Prints the value to `stdout`. 1. Prints the value to `stdout`.
2. Emits the `valueReceived(int)` Qt signal. 2. Emits the `valueReceived(int)` Qt signal.
The server socket is created and bound inside `start()` **before** the thread is spawned, so the socket is guaranteed ready by the time `start()` returns — no race with the producer. Graceful shutdown is handled by `stop()`, which closes the file descriptor to unblock the blocking `accept()` call. The server socket is created and bound inside `start()` **before** the thread is spawned, so the socket is guaranteed to be ready by the time `start()` returns — eliminating race conditions with the producer. Graceful shutdown is handled by `stop()`, which shuts down the file descriptor to unblock the blocking `accept()` call.
**Tests:** [tests/test_consumer.cxx](tests/test_consumer.cxx) — uses `QSignalSpy` to verify single-value, multi-value, negative, and zero reception; clean stop without deadlock; stop when never started; and three corrupted-data cases (short message, empty connection, corrupted then valid). **Tests:** [tests/test_consumer_thread.cxx](tests/test_consumer_thread.cxx) — uses `QSignalSpy` to verify single-value, multi-value, negative, and zero reception.
## MainWindow
`MainWindow` ([include/MainWindow.hpp](include/MainWindow.hpp), [src/app/MainWindow.cxx](src/app/MainWindow.cxx)) is a minimal `QWidget` that displays the last integer received from `ConsumerThread`. It has no logic beyond updating a label via a slot connected to `valueReceived(int)` through Qt's queued connection — the GUI never blocks.
**Tests:** [tests/test_main_window.cxx](tests/test_main_window.cxx) — verifies label updates on single and repeated values, and that the window title is set.
## Race conditions and crash resilience
**Tests:** [tests/test_race_conditions.cxx](tests/test_race_conditions.cxx)
- **`RepeatedStartStopWhileProducerSends`** — starts and stops `ConsumerThread` 20 times while a producer thread continuously attempts sends. A watchdog thread aborts the process if any `stop()` call deadlocks within 15 seconds.
- **`ProducerSurvivesConsumerCrash`** — simulates a hard consumer crash by force-closing the server fd from outside its thread (equivalent to the kernel reclaiming fds on SIGKILL). Verifies that the producer keeps running and successfully delivers values to a fresh consumer started afterwards.
## Project structure
```
include/ Public headers for all core components
src/
app/ main.cxx and MainWindow.cxx — Qt application entry point
core/ Platform-independent logic: Producer, Consumer, SysfsReader, UnixIpcBridge
tests/ Google Test suites, one file per module + race conditions
docs/ Supporting documentation (see below)
build/ CMake out-of-source build directory
fake_sysfs_input Simulated sysfs control file used at runtime and in tests
```
## Docs
| File | Contents |
|------|----------|
| [docs/self-assessment.md](docs/self-assessment.md) | Honest breakdown of difficulties encountered, the IPC mechanism trade-off, and the main design decision that changed mid-development |
| [docs/quality_description.md](docs/quality_description.md) | One-paragraph explanation of how TDD keeps concurrent embedded software robust and reduces cyclomatic complexity by design |
| [docs/logic-flow-chart.png](docs/logic-flow-chart.png) | Architecture diagram covering thread layout, IPC flow, error-handling paths, and GUI data flow |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 405 KiB

View File

@ -1,5 +0,0 @@
# Quality Description
Writing tests first forced every component to be injectable and independently exercisable before any integration happened. That constraint turned out to matter more than expected when the race-condition tests were added at the end: because the producer, sysfs reader, and IPC bridge had already been broken into units with explicit interfaces (`std::function` callbacks, injected sleep, injected logger), the stress tests could be wired up without touching any production code. Nothing needed to be refactored to be testable — it already was. That is the practical benefit of TDD for concurrent embedded software: the discipline of writing the test first tends to eliminate shared mutable state and deep call chains by making them painful to test, which in turn reduces cyclomatic complexity almost as a side effect.

View File

@ -1,6 +1,6 @@
#pragma once #pragma once
// MainWindow.hpp // MainWindow.hpp
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-only
// Author: Unai Blazquez <unaibg2000@gmail.com> // Author: Unai Blazquez <unaibg2000@gmail.com>
#include <QLabel> #include <QLabel>

View File

@ -1,5 +1,5 @@
// UnixIpcBridge.hpp // UnixIpcBridge.hpp
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-only
// Author: Unai Blazquez <unaibg2000@gmail.com> // Author: Unai Blazquez <unaibg2000@gmail.com>
#pragma once #pragma once

View File

@ -1,4 +1,4 @@
// main.cxx ^// main.cxx
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
// Author: Unai Blazquez <unaibg2000@gmail.com> // Author: Unai Blazquez <unaibg2000@gmail.com>

View File

@ -1,11 +1,9 @@
// Producer.cxx // Producer.cxx
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-only
// Author: Unai Blazquez <unaibg2000@gmail.com> // Author: Unai Blazquez <unaibg2000@gmail.com>
#include "Producer.hpp" #include "Producer.hpp"
#include <random>
#include "SysfsRead.hpp" #include "SysfsRead.hpp"
Producer::Producer(const std::filesystem::path& sysfs_path, Producer::Producer(const std::filesystem::path& sysfs_path,
@ -29,14 +27,6 @@ std::chrono::milliseconds Producer::compute_delay(SysfsStatus status) const
{ // when error = temp too high { // when error = temp too high
return hot; return hot;
} }
if (status == SysfsStatus::Enabled)
{
static std::random_device rd;
static std::mt19937 gen(rd());
std::uniform_int_distribution<int> dist(1000,5000); //jittered around 3s with +-2s
return std::chrono::milliseconds(dist(gen));
}
else else
{ {
return standard; return standard;

View File

@ -1,5 +1,5 @@
// UnixIpcBridge.cxx // UnixIpcBridge.cxx
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-only
// Author: Unai Blazquez <unaibg2000@gmail.com> // Author: Unai Blazquez <unaibg2000@gmail.com>
#include "UnixIpcBridge.hpp" #include "UnixIpcBridge.hpp"

View File

@ -1,5 +1,5 @@
# Author: Unai Blazquez # Author: Unai Blazquez
# License: GPL-3-or-later # License: GPL-3-only
add_executable(test_sysfs_reader add_executable(test_sysfs_reader
test_sysfs_read.cxx test_sysfs_read.cxx
@ -73,18 +73,3 @@ target_link_libraries(test_main_window
) )
add_test(NAME test_main_window COMMAND test_main_window) add_test(NAME test_main_window COMMAND test_main_window)
add_executable(test_race_conditions
test_race_conditions.cxx
)
target_link_libraries(test_race_conditions
PRIVATE
core
gtest
gtest_main
Qt5::Core
Qt5::Test
)
add_test(NAME test_race_conditions COMMAND test_race_conditions)

View File

@ -1,11 +1,7 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <QCoreApplication> #include <QCoreApplication>
#include <QSignalSpy> #include <QSignalSpy>
#include <cstring>
#include "Consumer.hpp" #include "Consumer.hpp"
#include "UnixIpcBridge.hpp" #include "UnixIpcBridge.hpp"
@ -122,94 +118,3 @@ TEST(ConsumerThreadTest, StopsCleanlyWhenNeverStarted)
// stop() on a consumer that was never started must not crash // stop() on a consumer that was never started must not crash
consumer.stop(); consumer.stop();
} }
// ---------------------------------------------------------------------------
// Requirement 2: Consumer receiving corrupted data (non-numeric)
// ---------------------------------------------------------------------------
/// Helper: raw-connect to a UNIX socket and send arbitrary bytes.
static void send_raw_bytes(const std::string& path, const void* data,
size_t len)
{
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
ASSERT_GE(fd, 0);
struct sockaddr_un addr = {};
addr.sun_family = AF_UNIX;
std::strncpy(addr.sun_path, path.c_str(), sizeof(addr.sun_path) - 1);
ASSERT_EQ(
connect(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)), 0);
if (len > 0)
{
::send(fd, data, len, 0);
}
close(fd);
}
TEST(ConsumerThreadTest, DropsCorruptedShortMessage)
{
const std::string sock = "/tmp/test_ct_corrupt_short.sock";
ConsumerThread consumer(sock);
QSignalSpy spy(&consumer, &ConsumerThread::valueReceived);
consumer.start();
// Send only 2 bytes instead of sizeof(int)==4 — corrupted / partial message
uint16_t garbage = 0xBEEF;
send_raw_bytes(sock, &garbage, sizeof(garbage));
// Give the consumer time to process (or not)
spy.wait(500);
consumer.stop();
// No signal should have been emitted
EXPECT_EQ(spy.count(), 0);
}
TEST(ConsumerThreadTest, DropsEmptyConnection)
{
const std::string sock = "/tmp/test_ct_corrupt_empty.sock";
ConsumerThread consumer(sock);
QSignalSpy spy(&consumer, &ConsumerThread::valueReceived);
consumer.start();
// Connect and immediately close — zero bytes sent
send_raw_bytes(sock, nullptr, 0);
spy.wait(500);
consumer.stop();
EXPECT_EQ(spy.count(), 0);
}
TEST(ConsumerThreadTest, SurvivesCorruptedThenReceivesValid)
{
const std::string sock = "/tmp/test_ct_corrupt_then_valid.sock";
ConsumerThread consumer(sock);
QSignalSpy spy(&consumer, &ConsumerThread::valueReceived);
consumer.start();
// First: send corrupted (1 byte)
uint8_t one_byte = 0xFF;
send_raw_bytes(sock, &one_byte, sizeof(one_byte));
std::this_thread::sleep_for(std::chrono::milliseconds(50));
// Then: send a valid int via the normal bridge
UnixIpcBridge bridge(sock);
bridge.send(777);
// Wait for the valid signal
for (int attempt = 0; spy.count() < 1 && attempt < 20; ++attempt)
{
spy.wait(100);
}
consumer.stop();
// The corrupted message must have been dropped, valid one received
ASSERT_EQ(spy.count(), 1);
EXPECT_EQ(spy.at(0).at(0).toInt(), 777);
}

View File

@ -1,200 +0,0 @@
// test_race_conditions.cxx
// SPDX-License-Identifier: GPL-3.0-or-later
// Author: Unai Blazquez <unaibg2000@gmail.com>
#include <gtest/gtest.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <QCoreApplication>
#include <QSignalSpy>
#include <atomic>
#include <chrono>
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <thread>
#include <vector>
#include "Consumer.hpp"
#include "Producer.hpp"
#include "UnixIpcBridge.hpp"
static int argc_ = 0;
static QCoreApplication app_(argc_, nullptr);
TEST(RaceConditionTest, RepeatedStartStopWhileProducerSends)
{
const std::string sock = "/tmp/test_race.sock";
constexpr int kCycles = 20;
// Watchdog: if the test takes longer than 15s, declare deadlock.
std::atomic<bool> test_done{false};
std::thread watchdog([&test_done]() {
for (int i = 0; i < 150 && !test_done.load(); ++i)
{
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
if (!test_done.load())
{
std::cerr
<< "DEADLOCK DETECTED: RepeatedStartStopWhileProducerSends timed out"
<< std::endl;
std::abort();
}
});
std::atomic<bool> producer_running{true};
std::thread producer([&]() {
while (producer_running.load())
{
try
{
UnixIpcBridge bridge(sock);
bridge.send(42);
}
catch (const std::runtime_error&)
{
// Expected: consumer socket not ready or just torn down.
}
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
});
for (int i = 0; i < kCycles; ++i)
{
ConsumerThread consumer(sock);
consumer.start();
// Let it run briefly so the producer can connect during some cycles.
std::this_thread::sleep_for(std::chrono::milliseconds(10 + (i % 5) * 5));
// stop() must return without deadlock every single time.
consumer.stop();
}
producer_running.store(false);
producer.join();
test_done.store(true);
watchdog.join();
// If we reach here, no deadlock across kCycles start/stop cycles.
SUCCEED();
}
TEST(RaceConditionTest, ProducerSurvivesConsumerCrash)
{
const std::string sock = "/tmp/test_crash.sock";
const std::string sysfs = "./fake_sysfs_race";
// Prepare sysfs file so the producer is in Enabled state.
{ std::ofstream(sysfs) << "1\n"; }
// Track what the producer sends.
std::vector<int> sent_values;
std::mutex sent_mutex;
std::vector<std::string> logs;
std::mutex log_mutex;
auto make_safe_send = [&](const std::string& path) {
return [&, path](int value) {
try
{
UnixIpcBridge bridge(path);
bridge.send(value);
std::lock_guard<std::mutex> lk(sent_mutex);
sent_values.push_back(value);
}
catch (const std::runtime_error&)
{
// Consumer is down — expected during the "crash" window.
}
};
};
Producer producer(
sysfs, make_safe_send(sock), []() { return 123; },
[&](const std::string& msg) {
std::lock_guard<std::mutex> lk(log_mutex);
logs.push_back(msg);
},
[](std::chrono::milliseconds) {
// Use a short sleep so the test runs fast.
std::this_thread::sleep_for(std::chrono::milliseconds(20));
});
// Phase 1: start consumer, start producer, let a few values flow.
{
ConsumerThread consumer(sock);
QSignalSpy spy(&consumer, &ConsumerThread::valueReceived);
consumer.start();
producer.start();
// Wait for at least 2 values to arrive.
for (int attempt = 0; spy.count() < 2 && attempt < 50; ++attempt)
{
spy.wait(100);
}
ASSERT_GE(spy.count(), 2) << "Phase 1: producer should have delivered values";
// Simulate a hard crash: force-close the consumer's server fd from
// outside its thread, causing accept() to fail with EBADF. This is
// what happens when the kernel reclaims fds on SIGKILL / abort().
//
// We find the server fd by calling getsockname() on open fds and
// matching against our socket path.
for (int fd = 3; fd < 1024; ++fd)
{
struct sockaddr_un addr = {};
socklen_t len = sizeof(addr);
if (getsockname(fd, reinterpret_cast<sockaddr*>(&addr), &len) == 0 &&
addr.sun_family == AF_UNIX &&
std::string(addr.sun_path) == sock)
{
::close(fd); // Yank the fd — consumer thread crashes out of accept()
break;
}
}
// Destructor calls stop(), which joins the (now-exited) thread and
// cleans up. In a real crash no cleanup runs, but we can't leak
// threads in a test process.
}
// Phase 2: producer is still running with no consumer (sends will fail).
std::this_thread::sleep_for(std::chrono::milliseconds(200));
// Phase 3: bring up a fresh consumer. Producer should resume delivering.
{
ConsumerThread consumer2(sock);
QSignalSpy spy2(&consumer2, &ConsumerThread::valueReceived);
consumer2.start();
for (int attempt = 0; spy2.count() < 2 && attempt < 50; ++attempt)
{
spy2.wait(100);
}
consumer2.stop();
ASSERT_GE(spy2.count(), 2)
<< "Phase 3: producer must deliver to a new consumer after crash";
// Values received by the second consumer should all be 123.
for (int i = 0; i < spy2.count(); ++i)
{
EXPECT_EQ(spy2.at(i).at(0).toInt(), 123);
}
}
producer.stop();
// Producer logged throughout all three phases.
{
std::lock_guard<std::mutex> lk(log_mutex);
EXPECT_GE(logs.size(), 3u) << "Producer should have kept logging";
}
}