Show verified geocache descriptions online and offline

This commit is contained in:
liu weikai committed 2026-09-28 16:47:36 +08:00
1 parent 2c3f47a50d
commit f330c7b3b6
7 files changed
+401 -6

No files matched your search

+8
View File
@@ -12,6 +12,14 @@ a rejected submission retries after one second. The query client's existing
120-second deadline cancels further sends. Closing abandons the RAM request.
Exact accepted duplicates can be acknowledged without disk access.
Opening an item requests its exact signed revision and shows the full description
and hint after author verification. Only the open detail owns these text buffers;
closing it cancels further requests. Online detail reads do not require SD.
Saved items read the complete signed record already retained by the download
transaction, so descriptions and hints remain available offline after restart.
The GPX also includes both fields and the signed record; no second sidecar file
or background detail synchronization is introduced.
Storage starts when the user opens Downloaded or Published, or saves a cache.
Downloads and publications retain signature checks, durable transaction state,
verified GPX installation, restart recovery and durable reply receipts. Storage
@@ -218,10 +218,34 @@ void appendMaintenanceHistory(unsigned count)
copy = 1 - copy;
}
}
struct DetailText
{
ui::geocaching::DetailStatus status = ui::geocaching::DetailStatus::Pending;
std::string description, hint, error;
};
DetailText detailText(const ui::geocaching::Item& item)
{
test::in_ui = true;
DetailText result;
test::source->readDetail(
item.id, item.revision_hash, [](const ui::geocaching::DetailView& value, void* context)
{
auto& out = *static_cast<DetailText*>(context);
out.status = value.status;
out.description = value.description;
out.hint = value.hint;
out.error = value.error; },
&result);
require(!test::ui_io, "detail UI callback touched SD");
return result;
}
int main(int argc, char** argv)
{
require(argc == 2, "expected fixture directory");
const std::string folder(argv[1]);
const auto encoded_record = fixture(folder, "record-v1.bin");
geocaching::RecordView expected_record;
require(geocaching::protocol::decodeGeocacheRecord({encoded_record.data(), encoded_record.size()}, expected_record), "detail record fixture invalid");
std::fprintf(stderr, "Runtime: discovery/download/publication\n");
chat::MeshAdapterRouter router;
LoraBoard board;
@@ -321,9 +345,35 @@ int main(int argc, char** argv)
generation = view.generation;
return test::source->item(Section::Discover, 0, generation, item) && item.can_download; },
"discovered row did not become downloadable");
require(test::source->download(item, generation), "download not queued");
test::card_ready = false;
test::source->open(item, generation);
until([&]
{ return router.sends == 3; },
"online detail request missing");
auto invalid_detail = fixture(folder, "get-response-v1.bin");
invalid_detail[invalid_detail.size() - 4] ^= 1;
reply(router, invalid_detail, 3);
for (unsigned i = 0; i < 30; ++i) tick();
require(detailText(item).status == ui::geocaching::DetailStatus::Pending, "invalid detail signature was displayed");
reply(router, fixture(folder, "get-response-v1.bin"), 3);
until([&]
{ return detailText(item).status == ui::geocaching::DetailStatus::Ready; },
"online detail not displayed");
require(detailText(item).description == expected_record.description && detailText(item).hint == expected_record.hint,
"online detail omitted full description or hint");
require(test::files.empty() && !test::blocked_io, "online detail required SD");
require(router.delivery({{reply_remote.data(), 16}, {router.local.data(), 16}, {}, {last_reply.data(), last_reply.size()}}, router.context),
"verified detail duplicate was not acknowledged");
require(xSemaphoreTake(semaphore, 0) == pdTRUE, "cannot lock during detail close");
test::source->closeDetail();
xSemaphoreGive(semaphore);
tick();
require(detailText(item).status == ui::geocaching::DetailStatus::Failed, "busy close lost the cancellation");
require(!test::allocated("geocaching.detail.rx") && !test::allocated("geocaching.detail.verify"), "closed detail retained temporary buffers");
test::card_ready = true;
require(test::source->download(item, generation), "download not queued");
until([&]
{ return router.sends == 4; },
"download request not dispatched");
reply(router, fixture(folder, "get-response-v1.bin"), 3);
until([&]
@@ -362,7 +412,7 @@ int main(int argc, char** argv)
"draft not ready for publication confirmation");
require(from == 0 && to == 1 && test::source->publishDraft(draft.id, draft_generation, author, to), "publication confirmation rejected");
until([&]
{ return router.sends == 4; },
{ return router.sends == 5; },
"publication request not dispatched");
reply(router, publicationReply(router, 1), 1);
until([&]
@@ -391,7 +441,7 @@ int main(int argc, char** argv)
"edited draft not ready for publication");
require(from == 1 && to == 2 && test::source->publishDraft(draft.id, draft_generation, author, to), "second publication confirmation rejected");
until([&]
{ return router.sends == 5; },
{ return router.sends == 6; },
"second publication request not dispatched");
reply(router, publicationReply(router, 2), 1);
until([&]
@@ -420,6 +470,18 @@ int main(int argc, char** argv)
return view.count == 1 && test::source->item(Section::Downloaded, 0, view.generation, item) && item.id == saved_id && item.downloaded; },
"restart lost downloaded map row");
require(test::files == disk, "read-only restart changed persisted files");
router.ready = false;
const auto offline_sends = router.sends;
test::source->open(item, snapshot(Section::Downloaded).generation);
until([&]
{ return detailText(item).status == ui::geocaching::DetailStatus::Ready; },
"saved detail unavailable offline after restart");
require(detailText(item).description == expected_record.description && detailText(item).hint == expected_record.hint,
"saved detail lost description or hint");
require(router.sends == offline_sends && test::files == disk, "offline detail used network or rewrote storage");
test::source->closeDetail();
tick();
router.ready = true;
test::source->requestWindow(Section::Published, 0, 4);
until([&]
{
@@ -62,6 +62,17 @@ enum class DraftReadStatus : uint8_t
Pending,
Ready
};
enum class DetailStatus : uint8_t
{
Pending,
Ready,
Failed
};
struct DetailView
{
DetailStatus status = DetailStatus::Pending;
std::string_view description, hint, error;
};
class Source
{
public:
@@ -79,6 +90,11 @@ class Source
virtual bool item(Section section, std::size_t index, std::uint64_t generation, Item& out) = 0;
virtual void refresh(Section section) = 0;
virtual void open(const Item& item, std::uint64_t generation) = 0;
// Borrowed text is consumed synchronously under the source lock. No I/O
// or retained pointers; large descriptions never become stack-local Items.
virtual bool readDetail(const std::array<uint8_t, 32>&, const std::array<uint8_t, 32>&,
void (*)(const DetailView&, void*), void*) { return false; }
virtual void closeDetail() {}
virtual bool loadMore() { return false; }
virtual bool download(const Item&, std::uint64_t) { return false; }
// UI-thread call: enqueue/poll without I/O. Pending never calls or retains
@@ -45,6 +45,7 @@ struct PageState
size_t offset = 0, rendered_offset = 0, row_count = 0, window = kVisibleRows;
size_t detail_index = 0;
uint64_t detail_generation = 0;
uint8_t detail_status = 255;
std::array<uint8_t, 32> detail_id{}, detail_hash{};
bool valid = false, details = false;
};
@@ -197,6 +198,36 @@ lv_obj_t* button(lv_obj_t* parent, const char* text, lv_coord_t height)
addFocusable(object);
return object;
}
void refreshDetailText()
{
if (!page || !page->details || !source) return;
source->readDetail(
page->detail_id, page->detail_hash,
[](const ::ui::geocaching::DetailView& view, void*)
{
auto& p = *page;
const auto status = static_cast<uint8_t>(view.status);
if (p.detail_status == status) return;
p.detail_status = status;
auto* description = lv_obj_get_child(p.list, 2);
auto* hint = lv_obj_get_child(p.list, 3);
if (view.status == ::ui::geocaching::DetailStatus::Ready)
{
lv_label_set_text_fmt(description, "Description\n%.*s", int(view.description.size()), view.description.empty() ? "" : view.description.data());
if (view.description.empty()) lv_label_set_text(description, "No description provided.");
lv_label_set_text_fmt(hint, "Hint\n%.*s", int(view.hint.size()), view.hint.empty() ? "" : view.hint.data());
if (view.hint.empty()) lv_obj_add_flag(hint, LV_OBJ_FLAG_HIDDEN);
else lv_obj_remove_flag(hint, LV_OBJ_FLAG_HIDDEN);
}
else
{
if (view.status == ::ui::geocaching::DetailStatus::Pending) lv_label_set_text(description, "Loading details...");
else lv_label_set_text_fmt(description, "%.*s", int(view.error.size()), view.error.empty() ? "" : view.error.data());
lv_obj_add_flag(hint, LV_OBJ_FLAG_HIDDEN);
}
},
nullptr);
}
void refreshView()
{
if (!page) return;
@@ -238,6 +269,7 @@ void refreshView()
if (page->details)
{
if (!source) return;
refreshDetailText();
::ui::geocaching::Snapshot current;
source->snapshot(page->section, current);
if (current.busy) return;
@@ -353,6 +385,7 @@ void refreshView()
void closeDetails()
{
if (!page) return;
if (page->details && source) source->closeDetail();
page->details = false;
page->valid = false;
lv_obj_remove_flag(page->previous, LV_OBJ_FLAG_HIDDEN);
@@ -371,6 +404,7 @@ void showDetails(const ::ui::geocaching::Item& item, size_t index)
}
auto& p = *page;
p.details = true;
p.detail_status = 255;
p.detail_index = index;
p.detail_generation = p.snapshot.generation;
p.detail_id = item.id;
@@ -387,7 +421,18 @@ void showDetails(const ::ui::geocaching::Item& item, size_t index)
lv_obj_set_width(body, LV_PCT(100));
lv_label_set_text(body, item.detail.data());
styles::apply_label_muted(body);
lv_label_set_text(p.range, "Preview");
auto* description = lv_label_create(p.list);
lv_obj_set_width(description, LV_PCT(100));
lv_label_set_long_mode(description, LV_LABEL_LONG_WRAP);
lv_label_set_text(description, "Loading details...");
styles::apply_label_primary(description);
auto* hint = lv_label_create(p.list);
lv_obj_set_width(hint, LV_PCT(100));
lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP);
styles::apply_label_primary(hint);
lv_obj_add_flag(hint, LV_OBJ_FLAG_HIDDEN);
refreshDetailText();
lv_label_set_text(p.range, "Details");
lv_label_set_text(lv_obj_get_child(p.refresh, 0), "Back");
setEnabled(p.refresh, true);
setEnabled(p.previous, false);
@@ -34,6 +34,22 @@ struct TestSource : ui::geocaching::Source
std::string draft_name, draft_description, draft_hint;
bool has_draft = false, fail_save = false, snapshot_busy = false;
unsigned pending_reads = 0;
bool detail_ready = false;
unsigned detail_closes = 0;
bool readDetail(const std::array<uint8_t, 32>&, const std::array<uint8_t, 32>&,
void (*sink)(const ui::geocaching::DetailView&, void*), void* context) override
{
ui::geocaching::DetailView view;
if (detail_ready)
{
view.status = ui::geocaching::DetailStatus::Ready;
view.description = "A woodland cache beside the old trail.\nFollow the stream to the stone bridge.";
view.hint = "Look beneath the large flat stone.";
}
sink(view, context);
return true;
}
void closeDetail() override { ++detail_closes; }
unsigned cancelled_reads = 0;
bool fail_read = false;
unsigned publications = 0;
@@ -242,7 +258,18 @@ int main(int argc, char** argv)
if (lv_obj_get_child_count(list) != visible) return 62;
lv_obj_send_event(lv_obj_get_child(list, 0), LV_EVENT_CLICKED, nullptr);
if (source.opens != 1 || source.last_open != 0) return 6;
if (std::strcmp(lv_label_get_text(lv_obj_get_child(list, 2)), "Loading details...")) return 64;
source.detail_ready = true;
// Detail completion is independent of list generation changes.
lv_tick_inc(600);
lv_timer_handler();
if (!std::strstr(lv_label_get_text(lv_obj_get_child(list, 2)), "Follow the stream") ||
!std::strstr(lv_label_get_text(lv_obj_get_child(list, 3)), "large flat stone") ||
lv_obj_has_flag(lv_obj_get_child(list, 3), LV_OBJ_FLAG_HIDDEN)) return 65;
if (!save(std::string(argv[3]) + "-detail.ppm", screen)) return 11;
lv_obj_scroll_to_y(list, LV_COORD_MAX, LV_ANIM_OFF);
if (!save(std::string(argv[3]) + "-detail-hint.ppm", screen)) return 66;
if (lv_obj_get_scroll_y(list) <= 0) return 67;
auto* details_footer = lv_obj_get_child(root, 4);
const auto detail_reads = source.reads;
source.snapshot_busy = true;
@@ -0,0 +1,117 @@
#pragma once
#include "geocaching/protocol/cmp_writer.h"
#include "geocaching/protocol/get_response.h"
#include "geocaching/protocol/verify_record.h"
#include "platform/esp/arduino_common/chat/infra/mesh_adapter_router.h"
#include "platform/esp/arduino_common/geocaching/sd_indexed_saved_cache.h"
#include "platform/esp/common/memory_budget.h"
#include "ui_presentation/geocaching/geocaching_source.h"
#include <esp_heap_caps.h>
#include <memory>
namespace platform::esp::arduino_common::geocaching
{
// One open detail, allocated off the ESP stack. Text is copied only after
// author verification against the selected cache ID and revision hash.
struct CacheDetail
{
enum class State
{
Network,
Saved,
Ready,
Failed
};
::geocaching::GeocacheId id;
::geocaching::RevisionHash hash;
::geocaching::Destination remote;
::geocaching::RequestId request;
std::array<uint8_t, 128> request_bytes{};
size_t request_size = 0;
std::array<char, 2049> description{};
std::array<char, 513> hint{};
std::array<uint8_t, 32> receipt{};
bool has_receipt = false;
uint8_t* response = nullptr;
size_t response_size = 0;
std::unique_ptr<SdIndexedSavedCache> saved_read;
uint64_t started = 0, next_send = 0;
State state = State::Network;
const char* error = "Details unavailable. Go back and reopen to retry.";
~CacheDetail()
{
if (response) heap_caps_free(response);
}
bool matches(const std::array<uint8_t, 32>& cache, const std::array<uint8_t, 32>& revision) const
{
return id.bytes == cache && hash.bytes == revision;
}
bool matches(const ::geocaching::Destination& source, const ::geocaching::RequestId& incoming) const
{
return request_size && source.bytes == remote.bytes && incoming.bytes == request.bytes;
}
bool verify(::geocaching::ByteView bytes, ::geocaching::protocol::RecordCrypto& crypto)
{
constexpr size_t capacity = ::geocaching::kMaxRecordBytes + 64;
auto* scratch = static_cast<uint8_t*>(::platform::esp::common::memory::allocatePreferred("geocaching.detail.verify", capacity, false));
if (!scratch) return false;
::geocaching::protocol::VerifiedRecordView verified;
const bool valid = ::geocaching::protocol::verifyGeocache(bytes, crypto, scratch, capacity, verified, &id, &hash) ==
::geocaching::protocol::VerificationResult::Valid;
if (valid)
{
description.fill(0);
hint.fill(0);
std::memcpy(description.data(), verified.record.description.data(), verified.record.description.size());
std::memcpy(hint.data(), verified.record.hint.data(), verified.record.hint.size());
state = State::Ready;
}
heap_caps_free(scratch);
return valid;
}
bool receive(const ::geocaching::Destination& source, const ::geocaching::RequestId& incoming,
::geocaching::ByteView bytes, ::geocaching::protocol::RecordCrypto& crypto, uint8_t** owned)
{
if (!matches(source, incoming)) return false;
if (state == State::Ready && has_receipt)
{
std::array<uint8_t, 32> digest{};
return crypto.sha256(bytes, digest.data()) && digest == receipt;
}
if (state != State::Network || response) return false;
response = owned ? *owned : static_cast<uint8_t*>(::platform::esp::common::memory::allocatePreferred("geocaching.detail.rx", bytes.size, false));
if (!response) return false;
if (owned) *owned = nullptr;
else std::memcpy(response, bytes.data, bytes.size);
response_size = bytes.size;
return false;
}
void advanceNetwork(chat::MeshAdapterRouter& router, const ::geocaching::Destination& local,
::geocaching::protocol::RecordCrypto& crypto, uint64_t now)
{
if (state != State::Network) return;
if (response)
{
::geocaching::protocol::GetResponseView parsed;
if (::geocaching::protocol::decodeGetResponse({response, response_size}, request, 8192, parsed) && !parsed.has_conflict &&
verify(parsed.signed_cache, crypto))
has_receipt = crypto.sha256({response, response_size}, receipt.data());
heap_caps_free(response);
response = nullptr;
response_size = 0;
if (state == State::Ready) return;
}
if (now - started >= 120000)
{
state = State::Failed;
error = "Directory did not return verified details. Go back and reopen to retry.";
return;
}
if (now < next_send) return;
std::array<uint8_t, 32> accepted{};
const auto sent = router.sendGeocachingData(remote.bytes.data(), {request_bytes.data(), request_size}, false, &accepted, local.bytes.data());
next_send = now + (sent.ok ? 15000 : 1000);
}
};
static_assert(sizeof(CacheDetail) < 4096, "Only the open detail owns full text");
} // namespace platform::esp::arduino_common::geocaching
@@ -7,6 +7,7 @@
#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h"
#include "platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h"
#include "platform/esp/arduino_common/geocaching/author_issue_port.h"
#include "platform/esp/arduino_common/geocaching/cache_detail.h"
#include "platform/esp/arduino_common/geocaching/indexed_dispatch_store.h"
#include "platform/esp/arduino_common/geocaching/indexed_download_store.h"
#include "platform/esp/arduino_common/geocaching/indexed_publication_store.h"
@@ -191,6 +192,7 @@ struct Session
std::unique_ptr<IndexedPublicationStore> store;
std::unique_ptr<IndexedDispatchStore> dispatch_store;
std::unique_ptr<LiveQueryPort> port;
std::unique_ptr<CacheDetail> detail, pending_detail;
std::unique_ptr<StoredReplyReceipt> receipts;
std::unique_ptr<gc::QueryClient> client;
std::unique_ptr<RequestDispatcher> dispatcher;
@@ -261,6 +263,8 @@ struct Session
}
~Session()
{
workspace_owner.release(detail.get());
detail.reset();
workspace_owner.release(checkpoint.get());
checkpoint.reset();
if (store && store->commitPending()) store->cancelCommit();
@@ -288,6 +292,7 @@ std::unique_ptr<Session> session;
std::array<uint8_t, 16> boot{};
std::atomic<bool> wanted{false}, active{false}, restart{false};
std::atomic<bool> cancel_draft_read{false};
std::atomic<bool> cancel_detail{false};
std::atomic<uint32_t> next_step{0};
std::atomic<uint32_t> replies_seen{0}, replies_busy{0}, replies_queued{0};
uint32_t replies_processed = 0, replies_accepted = 0;
@@ -421,7 +426,7 @@ void fail(const char* reason)
bool advanceCheckpoint(Session& s)
{
if (!s.checkpoint) return false;
const bool foreground = s.response || downloadActive() || publicationActive() || draftSaveActive() ||
const bool foreground = s.response || (s.detail && s.detail->state == CacheDetail::State::Saved) || downloadActive() || publicationActive() || draftSaveActive() ||
(s.saved && s.saved->pending() && !s.draft_catalog_wanted) ||
(s.draft_read && s.draft_read->status == ::ui::geocaching::DraftReadStatus::Pending) ||
(s.draft_catalog_wanted && !draftCatalogReady(s));
@@ -631,6 +636,12 @@ bool receiveResponse(const chat::lxmf::CustomDeliveryView& message, uint8_t** ow
!reader.binary(id, 16) || id.size != 16) return false;
gc::RequestId request;
std::memcpy(request.bytes.data(), id.data, 16);
if (value == 3 && session->detail && session->detail->matches(source, request))
{
const bool accepted = session->detail->receive(source, request, {message.data.data, message.data.size}, session->crypto, owned);
next_step.store(0);
return accepted;
}
if (value == 0 || value == 2)
{
const gc::ByteView bytes{message.data.data, message.data.size};
@@ -805,6 +816,45 @@ void startRecovery()
++epoch;
}
// Use the same installed-record lookup as the saved list, but retain the full
// verified text for the open detail. No new files or persistent tasks.
bool advanceSavedDetail(Session& s)
{
if (!s.detail || s.detail->state != CacheDetail::State::Saved) return false;
auto& job = *s.detail;
const auto finish = [&](const char* error)
{
job.saved_read.reset();
s.workspace_owner.release(&job);
if (error)
{
job.error = error;
job.state = CacheDetail::State::Failed;
}
return true;
};
if (s.phase == Phase::Failed || s.needsRecovery()) return finish("Saved details need storage recovery.");
if (!storage::sd_card_ready() || storage::sd_external_block_owner_active()) return finish("SD card unavailable. Go back and reopen to retry.");
if (now(nullptr).monotonic_ms - job.started >= 120000) return finish("Saved details timed out. Go back and reopen to retry.");
if (s.phase != Phase::Ready) return false;
if (!job.saved_read)
{
if (downloadActive() || publicationActive() || draftSaveActive()) return false;
if (!s.workspace_owner.acquire(&job)) return false;
job.saved_read.reset(new (std::nothrow) SdIndexedSavedCache(s.volume));
if (!job.saved_read || !job.saved_read->begin(s.root, {job.id.bytes.data(), job.id.bytes.size()}, true, s.frame, kFrameCapacity))
return finish("Insufficient memory to read saved details.");
}
const auto result = job.saved_read->step();
if (result == IndexScanStep::Working) return true;
gc::storage::SavedCacheRecord saved;
gc::ByteView signed_cache;
if (result != IndexScanStep::Item || !job.saved_read->result(saved, signed_cache) ||
saved.hash != job.hash.bytes || !job.verify(signed_cache, s.crypto))
return finish("Saved details could not be verified.");
return finish(nullptr);
}
enum class PublicationRestore : uint8_t
{
None,
@@ -1160,6 +1210,7 @@ class Facade final : public ::ui::geocaching::Source
void activate(bool open) override
{
wanted.store(open);
if (!open) cancel_detail.store(true);
next_step.store(0);
}
void snapshot(::ui::geocaching::Section section, ::ui::geocaching::Snapshot& out) override
@@ -1358,7 +1409,62 @@ class Facade final : public ::ui::geocaching::Source
if (begun) next_step.store(0);
return begun;
}
void open(const ::ui::geocaching::Item&, uint64_t) override {}
void open(const ::ui::geocaching::Item& item, uint64_t) override
{
Guard guard;
if (!guard.locked || !session || item.is_draft) return;
auto job = std::unique_ptr<CacheDetail>(new (std::nothrow) CacheDetail);
if (!job) return;
job->id.bytes = item.id;
job->hash.bytes = item.revision_hash;
job->started = now(nullptr).monotonic_ms;
if (item.downloaded)
{
job->state = CacheDetail::State::Saved;
session->storage_requested = true;
}
else if (!session->port || !session->port->pageSource(job->remote) ||
!randomId(nullptr, job->request.bytes.data()) ||
!gc::protocol::encodeGetRequest(job->request, job->id, &job->hash, nullptr, 8192,
job->request_bytes.data(), job->request_bytes.size(), job->request_size))
job->state = CacheDetail::State::Failed;
session->pending_detail = std::move(job);
cancel_detail.store(false);
next_step.store(0);
}
bool readDetail(const std::array<uint8_t, 32>& id, const std::array<uint8_t, 32>& hash,
void (*sink)(const ::ui::geocaching::DetailView&, void*), void* context) override
{
Guard guard;
if (!guard.locked || !session || !sink) return false;
const auto* job = session->pending_detail ? session->pending_detail.get() : session->detail.get();
::ui::geocaching::DetailView view;
if (!job || cancel_detail.load() || !job->matches(id, hash))
{
view.status = ::ui::geocaching::DetailStatus::Failed;
view.error = "Details unavailable. Go back and reopen to retry.";
sink(view, context);
return true;
}
if (job->state == CacheDetail::State::Ready)
{
view.status = ::ui::geocaching::DetailStatus::Ready;
view.description = job->description.data();
view.hint = job->hint.data();
}
else if (job->state == CacheDetail::State::Failed)
{
view.status = ::ui::geocaching::DetailStatus::Failed;
view.error = job->error;
}
sink(view, context);
return true;
}
void closeDetail() override
{
cancel_detail.store(true);
next_step.store(0);
}
bool publicationAuthor(const std::array<uint8_t, 16>& id, uint64_t generation, std::array<uint8_t, 64>& author, uint32_t* from, uint32_t* to) override
{
Guard guard;
@@ -1684,13 +1790,25 @@ void step()
++epoch;
}
auto& s = *session;
const bool close_detail = cancel_detail.exchange(false);
if (close_detail || s.pending_detail)
{
s.workspace_owner.release(s.detail.get());
s.detail.reset();
if (close_detail) s.pending_detail.reset();
else s.detail = std::move(s.pending_detail);
}
advanceBrowse(s);
if (s.detail) s.detail->advanceNetwork(*router, s.local, s.crypto, now(nullptr).monotonic_ms);
if (!s.storage_requested || !s.client)
{
next_step.store(millis() + 100);
return;
}
WorkspaceSlice workspace_slice{s};
if (s.detail && s.detail->state == CacheDetail::State::Saved &&
(s.phase == Phase::Failed || !storage::sd_card_ready() || storage::sd_external_block_owner_active()))
advanceSavedDetail(s);
if (s.phase == Phase::Failed)
{
next_step.store(millis() + 2000);
@@ -1766,6 +1884,7 @@ void step()
// The active holder must run before new foreground work waiting for it.
// Saved reply receipts share the lease with persistent operations.
if (advanceCheckpoint(s)) return;
if (s.detail && s.workspace_owner.heldBy(s.detail.get()) && advanceSavedDetail(s)) return;
if (s.dispatcher && s.dispatch_store->busy() && s.workspace_owner.heldBy(s.dispatch_store.get()))
{
const auto sent = s.dispatcher->dispatchOne(now(nullptr));
@@ -1849,6 +1968,7 @@ void step()
// Advance the operation that currently owns storage before retrying.
}
if (s.phase == Phase::Ready && s.download_start && advanceDownloadStart(s)) return;
if (advanceSavedDetail(s)) return;
if (s.saved && s.saved->pending() && !s.draft_catalog_wanted && s.phase != Phase::ResumeDownloads && !downloadActive() && !publicationActive() && !draftSaveActive() &&
!s.store->commitPending() && !s.needsRecovery())
{