Conversation
There was a problem hiding this comment.
🟡 Changes recommended
One or more issues must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds configurable foreground waits with cache fallback and durable background report delivery.
Changes:
- Adds asynchronous authorization and cancellable network sessions.
- Introduces transactional report storage, retries, ordering, and migration.
- Adds extensive tests and documentation.
File summaries
| File | Description |
|---|---|
| tests/report_delivery_test.cpp | Updated as part of this pull request. |
| tests/foreground_backend_test.cpp | Updated as part of this pull request. |
| tests/controller_test.cpp | Updated as part of this pull request. |
| tests/cares_resolver_test.cpp | Updated as part of this pull request. |
| tests/backlog_worker_test.cpp | Updated as part of this pull request. |
| tests/backend_test.cpp | Updated as part of this pull request. |
| src/user_cache.cpp | Updated as part of this pull request. |
| src/state_machine.cpp | Updated as part of this pull request. |
| src/message_storage.cpp | Updated as part of this pull request. |
| src/main.cpp | Updated as part of this pull request. |
| src/controller.cpp | Updated as part of this pull request. |
| src/cares_resolver.cpp | Updated as part of this pull request. |
| src/cache_manager.cpp | Updated as part of this pull request. |
| src/backlog_worker.cpp | Updated as part of this pull request. |
| src/backend.cpp | Updated as part of this pull request. |
| src/backend_base.cpp | Updated as part of this pull request. |
| README.md | Updated as part of this pull request. |
| include/user_cache.h | Updated as part of this pull request. |
| include/types.h | Updated as part of this pull request. |
| include/timing_config.h | Updated as part of this pull request. |
| include/message_storage.h | Updated as part of this pull request. |
| include/controller.h | Updated as part of this pull request. |
| include/cares_resolver.h | Updated as part of this pull request. |
| include/cache_manager.h | Updated as part of this pull request. |
| include/backlog_worker.h | Updated as part of this pull request. |
| include/backend.h | Updated as part of this pull request. |
| include/authorization_snapshot.h | Updated as part of this pull request. |
| docs/state_machine.md | Updated as part of this pull request. |
| docs/foreground_backend_waits.md | Updated as part of this pull request. |
| CMakeLists.txt | Updated as part of this pull request. |
| .gitignore | Updated as part of this pull request. |
Review details
Suppressed comments (6)
include/controller.h:258
threadExited_starts astrue, butrun()changes it tofalseonly after the caller launches the thread. Ifshutdown()races with a newly created controller beforerun()enters, this loop is skipped andshutdownPeripherals()/worker teardown can race with the still-starting event-loop thread; the documented wait is not guaranteed. Track whether the run thread has started (or otherwise synchronize startup) before using this flag to decide that shutdown is complete.
std::atomic<bool> threadExited_{true};
src/backend_base.cpp:515
- The canonical branch now sends directly to the API after checking only
IsAuthorized(), unlikeRefuel()which enforcesUserRole::Customerand allowance limits.Controller::enterVolume()only applies the role/allowance check to customers, so a non-customer session can reachsubmitReport()and this path can transmit a refuel that the old backend validation would reject. Preserve the method-specific authorization checks (at minimum the role check) before sending canonical payloads.
if (!session_.IsAuthorized()) { lastError_ = StdControllerError; return false; }
const auto body = nlohmann::json::parse(payload, nullptr, false);
if (body.is_discarded()) { lastError_ = StdControllerError; return false; }
const auto response = HttpRequestWrapper(intake ? "/api/pump/fuel-intake" : "/api/pump/refuel", "POST", body, true);
src/backlog_worker.cpp:27
RecoverInFlight()can fail (for example, when the SQLite database is temporarily read-only or locked), but the worker starts regardless. Those rows remainin_flight=1, andClaimNextBacklog()explicitly excludes them, so the affected reports are silently stranded for the lifetime of this worker. Treat recovery failure as a startup failure or retry it before accepting normal delivery.
storage_->RecoverInFlight();
src/controller.cpp:687
- The interface's default
CreateIndependentSession()returns an empty pointer, but this branch treats that as an authorization failure. AnyIBackendimplementation that does not add the new override (the default was kept precisely so implementations still compile) will now reject every card/PIN attempt, whileCacheManagerandBacklogWorkerfall back to their existing backend when no independent session is available. Preserve the old behavior with a documented fallback or make the new requirement explicit instead of silently postingAuthorizationFailed.
auto session = backendPrototype_->CreateIndependentSession();
if (!session) { postEvent(Event::AuthorizationFailed); return; }
src/main.cpp:388
- This cleanup is unreachable on the
MAX_RETRIESpath: that branch callsCleanupCaresLibrary()andLogger::shutdown()and then enters its permanent-failure loop without returning here. Any queued async deauthorization can therefore continue using DNS or logging after those subsystems are torn down. Stop the shared deauthorization executor before the failure-path cleanup as well as on normal exit.
BackendBase::ShutdownAsyncRequests();
src/message_storage.cpp:435
ClaimNextBacklog()already incrementsattemptwhen it claims a row. Incrementing it here as well makes one delivery resumed after restart jump two attempt IDs, so the stored attempt no longer represents delivery attempts and diagnostics/limits can be wrong. Recovery should clearin_flight/retry_afterand let the next claim perform the single increment.
return Execute("UPDATE backlog SET in_flight=0,attempt=attempt+1,retry_after=0 WHERE in_flight=1");
- Files reviewed: 30/31 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Critical compatibility and authorization-safety findings remain, along with session cancellation and cleanup gaps.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (5)
include/backend.h:56
- The no-op default lets an adapter return a non-null independent session, pass the controller's only capability check, and still never cancel a blocked request. Slow-authorization cancellation and shutdown can then wait for the transport deadline instead of stopping promptly. Make cancellation mandatory for controller sessions (for example, a pure virtual method or an explicit capability that is validated at construction).
virtual void CancelPendingRequests() {}
include/backend.h:60
- The new
canonicalTankIdargument is discarded by the default implementation, so canonical reports are routed through the legacy payload methods.BackendBase::RefuelPayload()andIntakePayload()apply visual-number mapping, which can remap an already-canonical backend tank ID for adapters that inherit this default. Make canonical delivery an explicit override/contract (or fail closed) instead of silently ignoring the flag.
virtual bool SendReportPayload(const std::string& payload, bool intake, bool canonicalTankId) {
(void)canonicalTankId;
return intake ? IntakePayload(payload) : RefuelPayload(payload);
}
src/controller.cpp:787
- A successful foreground report resets
backend_after handing the session to the delivery worker, whilebackendPrototype_remains available for the next operation. This public synchronous compatibility helper checks onlybackend_, so calling it after one completed report always reports “Backend unavailable” instead of creating another session. Recreate a session frombackendPrototype_whenbackend_is empty before applying this guard.
if (!backend_) {
showError("Backend unavailable");
src/controller.cpp:1036
- When
EnqueueReportfails, the supplied online session remains authorized becauseBacklogWorker::Submitdoes not deauthorize a failed submission. Refueling happens to clean this up afterward, butcompleteIntakeOperation()has no equivalent cleanup; the queuedErrorevent therefore leaves a failed intake session authorized until a later timeout or restart. Deauthorize and clear the supplied session on this failure path.
if (!id) {
showError("Ошибка записи");
postEvent(Event::Error);
return false;
src/controller.cpp:661
- This destructor only deauthorizes an unadopted attempt when
successis true. The authorization worker setssuccessback to false in its catch path after any exception, including one while copying tank data after the backend has already authorized, so the server session can be leaked. Cleanup should instead check and deauthorize any authorized unadopted backend once the attempt is done.
if (done.load() && success && !adopted && backend) {
try { backend->Deauthorize(); } catch (...) {}
}
- Files reviewed: 31/32 changed files
- Comments generated: 3
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings remain in report delivery, controller/backend lifecycle, storage, and cache handling.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (6)
src/backlog_worker.cpp:86
Stop()can win between the loop'srunning_check and this claim. It then sees no active backend and starts joining, whileProcessOnce()can claim the row and create a fresh backend afterward, leaving shutdown waiting on a request that was never cancelled. Recheckrunning_while holding this mutex immediately before claiming (or otherwise serialize the claim withStop()).
message = storage_->ClaimNextBacklog();
src/controller.cpp:710
CaptureAuthorizationStatecan returnreportStorageAvailable == falsewhen the write probe or a database read fails, but this branch only discardssavedand continues to create an online authorization attempt. That allows a card to reach fueling even thoughsubmitReportcannot persist the transaction, violating the fail-closed storage contract. Reject the authorization before starting the backend session wheneverlocal.reportStorageAvailableis false.
const auto saved = local.reportStorageAvailable ? local.saved : std::nullopt;
if (messageStorage_ && local.pendingReports) {
src/controller.cpp:685
- This persists an online authorization before
StateMachine::onAuthorizationSuccess()rejects snapshots with no available tanks (src/state_machine.cpp:752-757). A successful response with an emptyfuelTanksarray can therefore overwrite a valid protected snapshot and the general cache; after a later network failure, fallback uses unusable data. Only persist a non-cache authorization after validating that it has usable tanks.
if (!fromCache) {
// Keep protection until a synchronization fetch begun after resolution
// commits. Otherwise an older in-flight sync could undo this fresh reply.
if (messageStorage_) messageStorage_->RefreshResolvedSnapshot(snapshot);
src/controller.cpp:120
initializePeripherals()already callsshutdownPeripherals()when a critical peripheral fails (src/controller.cpp:1385-1390), but this unconditionally marks every peripheral for another shutdown. Consequently, a failed initialization followed by the normalshutdown()path tears down the same objects twice; the peripheral interface does not guarantee thatshutdown()is idempotent. Track whether initialization actually left live peripherals (and update that flag on reinitialization) instead of setting it before the call.
peripheralsNeedShutdown_ = true;
bool ok = initializePeripherals();
src/controller.cpp:685
RefreshResolvedSnapshotonly updates rows that are alreadyprotected=1, so this call is a no-op for a card with no pending report and an unprotected (or absent) state row. If a cache population captured the old general cache before this online authorization and commits afterward, its stale data can overwriteUpdateCacheEntry; the next network failure then falls back to that stale general snapshot because no protected row exists. Preserve/protect the fresh snapshot without overwriting pending state, and cover this ordering with a regression test.
if (!fromCache) {
// Keep protection until a synchronization fetch begun after resolution
// commits. Otherwise an older in-flight sync could undo this fresh reply.
if (messageStorage_) messageStorage_->RefreshResolvedSnapshot(snapshot);
src/controller.cpp:95
- The cache manager is built with
CreateDefaultBackendShared(...)rather than an independent session from the backend injected into thisController. As a result, a custom/mock adapter authorizes foreground users through one backend but synchronization silently uses the production REST adapter, potentially populating the cache with unrelated data and bypassing the adapter's session contract. Construct the synchronization session frombackendPrototype_(or inject an explicit factory) so cache and foreground operations use the same backend implementation.
try {
messageStorage_ = std::make_shared<MessageStorage>(persistencePaths.messageStorageDbPath);
reportWorker_ = std::make_unique<BacklogWorker>(messageStorage_, backendPrototype_, timing::kBacklogWorkerInterval);
if (cacheManager_) cacheManager_->SetReportStorage(messageStorage_);
- Files reviewed: 32/33 changed files
- Comments generated: 3
- Review effort level: Lite
Co-authored-by: maxirmx <2081498+maxirmx@users.noreply.github.com>
Addressed the remaining findings from this review in ce50b95. |
There was a problem hiding this comment.
🟡 Changes recommended
Three unresolved findings remain, including one critical delivery-handling issue and two moderate lifecycle/cleanup issues.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
src/backlog_worker.cpp:44
- This cleanup is unreachable when
Stop()is called whilerunning_is already false.Submit()can retain a supplied online session insessions_beforeStart()(for example, controller APIs can enqueue before initialization);submitReport()then resetsbackend_, so the session is destroyed withoutDeauthorize(), leaving the server-side authorization open. Drain/deauthorize retained sessions even for an unstarted worker, while still joining any joinable thread.
std::lock_guard<std::mutex> lock(mutex_);
for (auto& entry : sessions_) entry.second->Deauthorize();
sessions_.clear();
src/controller.cpp:184
- The shutdown deadline only waits for
loopActive_; these unconditional worker shutdowns happen afterward and join without a deadline. If an authorization/report backend call cannot be interrupted (for example during a transport or SQLite wait),shutdown()can block until that operation's much longer timeout instead of returningfalseatkShutdownDeadline, so the caller cannot preserve live state and retry as documented. Bound these joins or include the workers in the deadline/cancellation state.
authorizationExecutor_.Shutdown();
if (reportWorker_) reportWorker_->Stop();
- Files reviewed: 32/33 changed files
- Comments generated: 1
- Review effort level: Lite
| // Deauthorize is treated as fire-and-forget at this call site. | ||
| // Return value and potential errors are intentionally ignored. | ||
| backend_->Deauthorize(); | ||
| activeBackend_->Deauthorize(); |
No description provided.