diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp index 80a216cb..421529eb 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp @@ -114,7 +114,10 @@ bool CompactPage::assign(const Document& document) { arena_size += bytes; ++anchors_accounted; } - _arena.reserve(arena_size); + const std::size_t notice_slack = std::min( + MAX_NOTICE_BYTES + 1, + MAX_ARENA_BYTES - std::min(arena_size, MAX_ARENA_BYTES)); + _arena.reserve(arena_size + notice_slack); _blocks.reserve(block_count); _runs.reserve(std::min(run_count, run_limit)); _links.reserve(link_count); @@ -213,6 +216,9 @@ bool CompactPage::assign(const Document& document) { block.partial_index = source_block.partial_index >= 0 && static_cast(source_block.partial_index) < partial_count ? source_block.partial_index : -1; + block.partial_region_index = source_block.partial_region_index >= 0 && + static_cast(source_block.partial_region_index) < partial_count + ? source_block.partial_region_index : -1; for (const auto& source_run : source_block.runs) { if (_runs.size() >= run_limit || block.run_count == std::numeric_limits::max()) { _truncated = true; @@ -229,6 +235,12 @@ bool CompactPage::assign(const Document& document) { run.field_index = source_run.field_index >= 0 && static_cast(source_run.field_index) < _fields.size() ? static_cast(source_run.field_index) : -1; + if (run.link_index >= 0) + _links[run.link_index].partial_region_index = + block.partial_region_index; + if (run.field_index >= 0) + _fields[run.field_index].partial_region_index = + block.partial_region_index; if (source_run.bold) run.style |= BOLD; if (source_run.italic) run.style |= ITALIC; if (source_run.underline) run.style |= UNDERLINE; @@ -321,7 +333,376 @@ bool CompactPage::assign(const Document& document) { } } +PartialReplaceResult CompactPage::assign_replacing_partial( + const CompactPage& base, std::size_t partial_index, + const Document& fragment, std::size_t max_arena_bytes) { + clear(); + if (partial_index >= base._partials.size()) + return PartialReplaceResult::INVALID_PARTIAL; + if (fragment.allocation_failed) + return PartialReplaceResult::ALLOCATION_FAILED; + if (fragment.malformed || fragment.truncated || + max_arena_bytes > MAX_ARENA_BYTES) + return PartialReplaceResult::LIMIT_EXCEEDED; + + bool region_found = false; + for (const auto& block : base._blocks) + if (block.partial_region_index == static_cast(partial_index)) { + region_found = true; + break; + } + if (!region_found) return PartialReplaceResult::INVALID_PARTIAL; + + CompactPage fragment_page; + if (!fragment_page.assign(fragment)) + return PartialReplaceResult::ALLOCATION_FAILED; + if (fragment_page.truncated()) + return PartialReplaceResult::LIMIT_EXCEEDED; + + bool limit_failed = false; + try { + const std::size_t source_arena = std::min( + max_arena_bytes, base._arena.size() + fragment_page._arena.size()); + const std::size_t reserve_arena = source_arena + std::min( + MAX_NOTICE_BYTES + 1, max_arena_bytes - source_arena); + _arena.reserve(reserve_arena); + _blocks.reserve(std::min(MAX_BLOCKS, + base._blocks.size() + fragment_page._blocks.size())); + _runs.reserve(std::min(MAX_RUNS, + base._runs.size() + fragment_page._runs.size())); + _links.reserve(std::min(MAX_LINKS, + base._links.size() + fragment_page._links.size())); + _fields.reserve(std::min(MAX_FIELDS, + base._fields.size() + fragment_page._fields.size())); + _anchors.reserve(std::min(MAX_ANCHORS, + base._anchors.size() + fragment_page._anchors.size())); + _tables.reserve(std::min(MAX_TABLES, + base._tables.size() + fragment_page._tables.size())); + _table_cells.reserve(std::min(MAX_TABLE_CELLS, + base._table_cells.size() + fragment_page._table_cells.size())); + _partials.reserve(base._partials.size()); + _partial_fields.reserve(base._partial_fields.size()); + + auto append_view = [&](TextView value, uint32_t& offset, + uint16_t& length) -> bool { + if ((!value.data() && !value.empty()) || + value.size() > std::numeric_limits::max() || + value.size() + 1 > max_arena_bytes - + std::min(_arena.size(), max_arena_bytes)) { + limit_failed = true; + return false; + } + offset = static_cast(_arena.size()); + length = static_cast(value.size()); + if (!value.empty()) + _arena.insert(_arena.end(), value.data(), value.data() + value.size()); + _arena.push_back('\0'); + return true; + }; + + for (const auto& source : base._partials) { + PartialRecord partial; + partial.first_field = static_cast(_partial_fields.size()); + if (!append_view(base.partial_descriptor(source), partial.descriptor_offset, + partial.descriptor_length) || + !append_view(base.partial_url(source), partial.url_offset, + partial.url_length) || + !append_view(base.partial_selectors(source), partial.selectors_offset, + partial.selectors_length) || + !append_view(base.partial_id(source), partial.id_offset, + partial.id_length)) { + clear(); + return PartialReplaceResult::LIMIT_EXCEEDED; + } + partial.refresh_interval_ms = source.refresh_interval_ms; + partial.descriptor_hash = source.descriptor_hash; + for (std::size_t field_index = 0; + field_index < source.field_count; ++field_index) { + if (_partial_fields.size() >= MAX_PARTIAL_FIELDS) { + clear(); + return PartialReplaceResult::LIMIT_EXCEEDED; + } + PartialFieldRecord field; + if (!append_view(base.partial_field(source, field_index), + field.value_offset, field.value_length)) { + clear(); + return PartialReplaceResult::LIMIT_EXCEEDED; + } + _partial_fields.push_back(field); + ++partial.field_count; + } + _partials.push_back(partial); + } + + struct CopyMaps { + ExternalVector links; + ExternalVector fields; + ExternalVector tables; + ExternalVector blocks; + }; + auto make_maps = [](const CompactPage& source) { + CopyMaps maps; + maps.links.assign(source._links.size(), -1); + maps.fields.assign(source._fields.size(), -1); + maps.tables.assign(source._tables.size(), -1); + maps.blocks.assign(source._blocks.size(), -1); + return maps; + }; + CopyMaps base_maps = make_maps(base); + CopyMaps fragment_maps = make_maps(fragment_page); + + auto copy_link = [&](const CompactPage& source, CopyMaps& maps, + int16_t source_index) -> int16_t { + if (source_index < 0 || + static_cast(source_index) >= source._links.size()) + return -1; + int16_t& mapped = maps.links[source_index]; + if (mapped >= 0) return mapped; + if (_links.size() >= MAX_LINKS) { + limit_failed = true; + return -1; + } + LinkRecord link; + if (!append_view(source.target(source_index), link.target_offset, + link.target_length)) + return -1; + link.partial_region_index = + source._links[source_index].partial_region_index; + mapped = static_cast(_links.size()); + _links.push_back(link); + return mapped; + }; + auto copy_field = [&](const CompactPage& source, CopyMaps& maps, + int16_t source_index) -> int16_t { + if (source_index < 0 || + static_cast(source_index) >= source._fields.size()) + return -1; + int16_t& mapped = maps.fields[source_index]; + if (mapped >= 0) return mapped; + if (_fields.size() >= MAX_FIELDS) { + limit_failed = true; + return -1; + } + const auto& old = source._fields[source_index]; + FieldRecord field; + if (!append_view(source.field_name(source_index), field.name_offset, + field.name_length) || + !append_view(source.field_value(source_index), field.value_offset, + field.value_length) || + !append_view(source.field_label(source_index), field.label_offset, + field.label_length)) + return -1; + field.width = old.width; + field.type = old.type; + field.checked = old.checked; + field.masked = old.masked; + field.partial_region_index = old.partial_region_index; + mapped = static_cast(_fields.size()); + _fields.push_back(field); + return mapped; + }; + auto copy_run = [&](const CompactPage& source, CopyMaps& maps, + const RunRecord& old, RunRecord& run) -> bool { + if (_runs.size() >= MAX_RUNS) { + limit_failed = true; + return false; + } + if (!append_view(source.text(old), run.text_offset, run.text_length)) + return false; + run.link_index = copy_link(source, maps, old.link_index); + run.field_index = copy_field(source, maps, old.field_index); + if (&source == &fragment_page) { + if (run.link_index >= 0) + _links[run.link_index].partial_region_index = + static_cast(partial_index); + if (run.field_index >= 0) + _fields[run.field_index].partial_region_index = + static_cast(partial_index); + } + run.style = old.style; + run.foreground = old.foreground; + run.background = old.background; + if ((old.link_index >= 0 && run.link_index < 0) || + (old.field_index >= 0 && run.field_index < 0)) + return false; + return true; + }; + auto copy_table = [&](const CompactPage& source, CopyMaps& maps, + int16_t source_index) -> int16_t { + if (source_index < 0 || + static_cast(source_index) >= source._tables.size()) + return -1; + int16_t& mapped = maps.tables[source_index]; + if (mapped >= 0) return mapped; + if (_tables.size() >= MAX_TABLES) { + limit_failed = true; + return -1; + } + const auto& old_table = source._tables[source_index]; + const std::size_t cell_count = static_cast(old_table.row_count) * + old_table.column_count; + if (old_table.first_cell > source._table_cells.size() || + cell_count > source._table_cells.size() - old_table.first_cell || + cell_count > MAX_TABLE_CELLS - + std::min(_table_cells.size(), MAX_TABLE_CELLS)) { + limit_failed = true; + return -1; + } + TableRecord table; + table.first_cell = static_cast(_table_cells.size()); + table.row_count = old_table.row_count; + table.column_count = old_table.column_count; + table.alignment = old_table.alignment; + table.max_width = old_table.max_width; + for (std::size_t cell_index = 0; cell_index < cell_count; ++cell_index) { + const auto& old_cell = source._table_cells[old_table.first_cell + cell_index]; + if (old_cell.first_run > source._runs.size() || + old_cell.run_count > source._runs.size() - old_cell.first_run) { + limit_failed = true; + return -1; + } + TableCellRecord cell; + cell.first_run = static_cast(_runs.size()); + cell.alignment = old_cell.alignment; + for (std::size_t run_index = 0; run_index < old_cell.run_count; ++run_index) { + RunRecord run; + if (!copy_run(source, maps, + source._runs[old_cell.first_run + run_index], run)) + return -1; + _runs.push_back(run); + ++cell.run_count; + } + _table_cells.push_back(cell); + } + mapped = static_cast(_tables.size()); + _tables.push_back(table); + return mapped; + }; + + auto copy_block = [&](const CompactPage& source, CopyMaps& maps, + std::size_t source_index, int16_t region_override, + bool retain_partial_index) -> bool { + if (source_index >= source._blocks.size() || _blocks.size() >= MAX_BLOCKS) { + limit_failed = true; + return false; + } + const auto& old = source._blocks[source_index]; + if (old.first_run > source._runs.size() || + old.run_count > source._runs.size() - old.first_run) { + limit_failed = true; + return false; + } + BlockRecord block; + block.first_run = static_cast(_runs.size()); + block.type = old.type; + block.depth = old.depth; + block.alignment = old.alignment; + block.divider_codepoint = old.divider_codepoint; + block.partial_index = retain_partial_index ? old.partial_index : -1; + block.partial_region_index = region_override >= 0 + ? region_override : old.partial_region_index; + if (old.table_index >= 0) { + block.table_index = copy_table(source, maps, old.table_index); + if (block.table_index < 0) return false; + } + for (std::size_t run_index = 0; run_index < old.run_count; ++run_index) { + RunRecord run; + if (!copy_run(source, maps, + source._runs[old.first_run + run_index], run)) + return false; + _runs.push_back(run); + ++block.run_count; + } + maps.blocks[source_index] = static_cast(_blocks.size()); + _blocks.push_back(block); + return true; + }; + + bool inserted = false; + for (std::size_t block_index = 0; block_index < base._blocks.size(); ++block_index) { + const auto& block = base._blocks[block_index]; + if (block.partial_region_index == static_cast(partial_index)) { + if (inserted) continue; + inserted = true; + if (fragment_page._blocks.empty()) { + if (_blocks.size() >= MAX_BLOCKS) { + limit_failed = true; + break; + } + BlockRecord marker; + marker.first_run = static_cast(_runs.size()); + marker.partial_region_index = static_cast(partial_index); + _blocks.push_back(marker); + } else { + for (std::size_t fragment_index = 0; + fragment_index < fragment_page._blocks.size(); ++fragment_index) + if (!copy_block(fragment_page, fragment_maps, fragment_index, + static_cast(partial_index), false)) { + limit_failed = true; + break; + } + } + if (limit_failed) break; + continue; + } + if (!copy_block(base, base_maps, block_index, -1, true)) { + limit_failed = true; + break; + } + } + if (!inserted || limit_failed) { + clear(); + return PartialReplaceResult::LIMIT_EXCEEDED; + } + + auto copy_anchors = [&](const CompactPage& source, const CopyMaps& maps) -> bool { + for (const auto& old : source._anchors) { + if (old.block_index >= maps.blocks.size()) continue; + const int32_t mapped_block = maps.blocks[old.block_index]; + if (mapped_block < 0) continue; + if (_anchors.size() >= MAX_ANCHORS) { + limit_failed = true; + return false; + } + AnchorRecord anchor; + const TextView name(source._arena.data() + old.name_offset, + old.name_length); + if (!append_view(name, anchor.name_offset, anchor.name_length)) + return false; + anchor.block_index = static_cast(mapped_block); + _anchors.push_back(anchor); + } + return true; + }; + if (!copy_anchors(base, base_maps) || + !copy_anchors(fragment_page, fragment_maps) || limit_failed) { + clear(); + return PartialReplaceResult::LIMIT_EXCEEDED; + } + std::stable_sort(_anchors.begin(), _anchors.end(), + [](const AnchorRecord& left, const AnchorRecord& right) { + return left.block_index < right.block_index; + }); + + _has_background = base._has_background; + _background = base._background; + _has_foreground = base._has_foreground; + _foreground = base._foreground; + _truncated = base._truncated; + _unsupported = base._unsupported || fragment_page._unsupported; + return PartialReplaceResult::APPLIED; + } catch (const std::bad_alloc&) { + clear(); + return PartialReplaceResult::ALLOCATION_FAILED; + } +} + void CompactPage::clear() { + if (!_arena.empty()) { + volatile char* bytes = _arena.data(); + for (std::size_t index = 0; index < _arena.size(); ++index) + bytes[index] = 0; + } ExternalVector().swap(_arena); ExternalVector().swap(_blocks); ExternalVector().swap(_runs); @@ -340,6 +721,28 @@ void CompactPage::clear() { _unsupported = false; } +CompactPage& CompactPage::operator=(CompactPage&& other) noexcept { + if (this == &other) return *this; + clear(); + _arena.swap(other._arena); + _blocks.swap(other._blocks); + _runs.swap(other._runs); + _links.swap(other._links); + _anchors.swap(other._anchors); + _tables.swap(other._tables); + _table_cells.swap(other._table_cells); + _fields.swap(other._fields); + _partials.swap(other._partials); + _partial_fields.swap(other._partial_fields); + std::swap(_has_background, other._has_background); + std::swap(_background, other._background); + std::swap(_has_foreground, other._has_foreground); + std::swap(_foreground, other._foreground); + std::swap(_truncated, other._truncated); + std::swap(_unsupported, other._unsupported); + return *this; +} + bool CompactPage::append_notice(const std::string& value) { if (value.size() > MAX_NOTICE_BYTES || value.size() + 1 > MAX_ARENA_BYTES - std::min(_arena.size(), MAX_ARENA_BYTES)) diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h b/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h index 8b32bc7b..6b5e9e9c 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "NomadNetDocument.h" #include "NomadNetMemory.h" @@ -22,6 +23,12 @@ inline bool block_has_layout_content(BlockType type, uint16_t run_count) { } enum class TableLayoutTier : uint8_t { FIT, REFLOW, STACKED }; +enum class PartialReplaceResult : uint8_t { + APPLIED, + INVALID_PARTIAL, + LIMIT_EXCEEDED, + ALLOCATION_FAILED, +}; inline TableLayoutTier choose_table_layout(int32_t structural_minimum_width, int32_t natural_width, @@ -91,6 +98,12 @@ inline uint8_t heading_bottom_spacing(uint8_t depth) { class CompactPage { public: + CompactPage() = default; + ~CompactPage() { clear(); } + CompactPage(const CompactPage&) = delete; + CompactPage& operator=(const CompactPage&) = delete; + CompactPage(CompactPage&& other) noexcept { *this = std::move(other); } + CompactPage& operator=(CompactPage&& other) noexcept; static constexpr std::size_t MAX_BLOCKS = DocumentParser::MAX_BLOCKS; static constexpr std::size_t MAX_RUNS = DocumentParser::MAX_TOTAL_RUNS; static constexpr std::size_t MAX_LINKS = DocumentParser::MAX_LINKS; @@ -129,6 +142,7 @@ public: uint32_t divider_codepoint = 0x2500; int16_t table_index = -1; int16_t partial_index = -1; + int16_t partial_region_index = -1; }; struct RunRecord { @@ -144,6 +158,7 @@ public: struct LinkRecord { uint32_t target_offset = 0; uint16_t target_length = 0; + int16_t partial_region_index = -1; }; struct AnchorRecord { @@ -175,6 +190,7 @@ public: uint16_t label_length = 0; uint16_t width = DocumentParser::DEFAULT_FIELD_WIDTH; FormFieldType type = FormFieldType::TEXT; + int16_t partial_region_index = -1; bool checked = false; bool masked = false; }; @@ -215,6 +231,9 @@ public: }; bool assign(const Document& document); + PartialReplaceResult assign_replacing_partial( + const CompactPage& base, std::size_t partial_index, + const Document& fragment, std::size_t max_arena_bytes); void clear(); bool empty() const { return _blocks.empty(); } diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp index 15a0d8d2..65207a6a 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp @@ -1174,6 +1174,7 @@ Document DocumentParser::parse(const char* source, std::size_t size) const { block.type = BlockType::PARTIAL; doc.partials.push_back(std::move(partial)); block.partial_index = static_cast(doc.partials.size() - 1); + block.partial_region_index = block.partial_index; Run run; run.text = "[Dynamic content loading]"; block.runs.push_back(std::move(run)); diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h index e3945dba..71b66739 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h @@ -97,6 +97,7 @@ struct Block { uint32_t divider_codepoint = 0x2500; int16_t table_index = -1; int16_t partial_index = -1; + int16_t partial_region_index = -1; std::vector runs; }; diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetForm.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetForm.cpp index ab054eba..eed75ff7 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetForm.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetForm.cpp @@ -132,12 +132,23 @@ bool FormState::assign(const CompactPage& page) { FieldState field; field.id = static_cast(i); field.type = page.fields()[i].type; + field.partial_region_index = page.fields()[i].partial_region_index; field.name_length = static_cast(name.size()); field.value_length = static_cast(value.size()); if (!name.empty()) std::memcpy(field.name.data(), name.data(), name.size()); if (!value.empty()) std::memcpy(field.value.data(), value.data(), value.size()); field.checked = page.fields()[i].checked; field.masked = page.fields()[i].masked; + if (field.type == FormFieldType::RADIO && field.checked) { + for (auto& existing : _fields) { + if (existing.type == FormFieldType::RADIO && + existing.name_length == field.name_length && + (field.name_length == 0 || std::memcmp( + existing.name.data(), field.name.data(), + field.name_length) == 0)) + existing.checked = false; + } + } _fields.push_back(std::move(field)); } return true; @@ -147,6 +158,95 @@ bool FormState::assign(const CompactPage& page) { } } +bool FormState::assign_preserving(const CompactPage& page, + const FormState& previous) { + if (!assign(page)) return false; + auto same_identity = [](const FieldState& left, const FieldState& right) { + if (left.type != right.type || + left.partial_region_index != right.partial_region_index || + left.name_length != right.name_length || + (left.name_length != 0 && std::memcmp( + left.name.data(), right.name.data(), left.name_length) != 0)) + return false; + if (left.type != FormFieldType::RADIO && + left.type != FormFieldType::CHECKBOX) + return true; + return left.value_length == right.value_length && + (left.value_length == 0 || std::memcmp( + left.value.data(), right.value.data(), left.value_length) == 0); + }; + auto same_radio_group = [](const FieldState& left, const FieldState& right) { + return left.type == FormFieldType::RADIO && + right.type == FormFieldType::RADIO && + left.name_length == right.name_length && + (left.name_length == 0 || std::memcmp( + left.name.data(), right.name.data(), left.name_length) == 0); + }; + for (std::size_t index = 0; index < _fields.size(); ++index) { + auto& field = _fields[index]; + if (field.type == FormFieldType::RADIO) { + const FieldState* old_selection = nullptr; + std::size_t old_selection_index = previous._fields.size(); + for (std::size_t old_index = 0; + old_index < previous._fields.size(); ++old_index) { + const auto& old = previous._fields[old_index]; + if (old.checked && same_radio_group(old, field)) { + old_selection = &old; + old_selection_index = old_index; + break; + } + } + std::size_t selected_occurrence = 0; + if (old_selection) { + for (std::size_t prior = 0; prior < old_selection_index; ++prior) + if (same_identity(previous._fields[prior], *old_selection)) + ++selected_occurrence; + } + const FieldState* surviving_selection = nullptr; + if (old_selection) { + std::size_t occurrence = 0; + for (const auto& current : _fields) { + if (!same_identity(*old_selection, current)) continue; + if (occurrence++ == selected_occurrence) { + surviving_selection = ¤t; + break; + } + } + } + if (!surviving_selection) continue; + field.checked = &field == surviving_selection; + continue; + } + std::size_t occurrence = 0; + for (std::size_t prior = 0; prior < index; ++prior) { + const auto& candidate = _fields[prior]; + if (same_identity(candidate, field)) + ++occurrence; + } + std::size_t seen = 0; + const FieldState* matched = nullptr; + for (const auto& old : previous._fields) { + if (!same_identity(old, field)) continue; + if (seen++ == occurrence) { + matched = &old; + break; + } + } + if (!matched) continue; + if (field.type == FormFieldType::TEXT || + field.type == FormFieldType::PASSWORD) { + wipe(field.value.data(), field.value.size()); + if (matched->value_length != 0) + std::memcpy(field.value.data(), matched->value.data(), + matched->value_length); + field.value_length = matched->value_length; + } else { + field.checked = matched->checked; + } + } + return true; +} + void FormState::clear() { for (auto& field : _fields) wipe(field.value.data(), field.value.size()); ExternalVector().swap(_fields); diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetForm.h b/lib/tdeck_ui/UI/LXMF/NomadNetForm.h index 00143ed0..16402837 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetForm.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetForm.h @@ -48,6 +48,7 @@ public: std::array value{}; uint16_t name_length = 0; uint16_t value_length = 0; + int16_t partial_region_index = -1; bool checked = false; bool masked = false; ~FieldState() { @@ -59,6 +60,7 @@ public: }; bool assign(const CompactPage& page); + bool assign_preserving(const CompactPage& page, const FormState& previous); void clear(); bool set_value(uint16_t id, const std::string& value); bool set_value(uint16_t id, const char* value, std::size_t size); diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetMailbox.h b/lib/tdeck_ui/UI/LXMF/NomadNetMailbox.h index ba8e4416..1cf868eb 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetMailbox.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetMailbox.h @@ -70,7 +70,7 @@ public: if (token.empty()) return false; if (_request_token.empty()) _request_token = token; if (token != _request_token) return false; - if (size > MAX_WIRE_BYTES || (!data && size != 0)) { + if (size > _max_wire_bytes || (!data && size != 0)) { set_oversized(size); return true; } @@ -93,12 +93,17 @@ public: return true; } - bool publish_failed(const std::vector& token) { + bool publish_failed(const std::vector& token, + std::size_t response_size = 0) { Guard guard(_lock); if (_sealed) return false; if (token.empty()) return false; if (_request_token.empty()) _request_token = token; if (token != _request_token) return false; + if (response_size > _max_wire_bytes) { + set_oversized(response_size); + return true; + } if (_event.kind == Kind::OVERSIZED || _event.kind == Kind::RESPONSE) return false; _event.kind = Kind::FAILED; _event.generation = _generation; @@ -147,10 +152,13 @@ public: // Open an explicit pre-arm window before constructing a Link. Some // implementations can call back before begin() receives its token. - void prepare(std::uint32_t generation = 0) { + void prepare(std::uint32_t generation = 0, + std::size_t max_wire_bytes = MAX_WIRE_BYTES) { Guard guard(_lock); reset(false); _generation = generation; + _max_wire_bytes = max_wire_bytes > MAX_WIRE_BYTES + ? MAX_WIRE_BYTES : max_wire_bytes; } // Terminal cleanup can synchronously invoke RequestReceipt's failed @@ -190,6 +198,7 @@ private: std::vector _link_token; std::vector _request_token; std::uint32_t _generation = 0; + std::size_t _max_wire_bytes = MAX_WIRE_BYTES; Event _event; }; diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetPartialController.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetPartialController.cpp new file mode 100644 index 00000000..5382b44b --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetPartialController.cpp @@ -0,0 +1,148 @@ +#include "NomadNetPartialController.h" + +#include "NomadNetPartialHash.h" + +#include +#include + +namespace UI::LXMF::NomadNet { + +namespace { + +void wipe(void* memory, std::size_t size) noexcept { + volatile uint8_t* bytes = static_cast(memory); + for (std::size_t index = 0; index < size; ++index) bytes[index] = 0; +} + +std::array descriptor_identity_hash( + CompactPage::TextView descriptor) noexcept { + std::array identity{}; + std::size_t size = 0; + for (std::size_t index = 0; index < descriptor.size(); ++index) { + const char value = descriptor.data()[index]; + identity[size++] = value == '`' ? '|' : value; + } + return partial_descriptor_sha256(identity.data(), size); +} + +} // namespace + +bool PartialController::prepare(const PartialRequest& request, + const CompactPage& page, + const uint8_t* request_data, + std::size_t request_size) noexcept { + clear_lease(); + if (request.page_generation == 0 || request.request_token == 0 || + request.partial_generation == 0 || + request.partial_index >= page.partials().size() || + request_size > _request_data.size() || + (!request_data && request_size != 0)) + return false; + const auto& partial = page.partials()[request.partial_index]; + if (partial.descriptor_hash != request.descriptor_hash) return false; + const auto descriptor = page.partial_descriptor(partial); + const auto url = page.partial_url(partial); + const auto selectors = page.partial_selectors(partial); + if ((!descriptor.data() && !descriptor.empty()) || + (!url.data() && !url.empty()) || + (!selectors.data() && !selectors.empty()) || + descriptor.size() > DocumentParser::MAX_PARTIAL_DESCRIPTOR_BYTES || + url.size() > DocumentParser::MAX_PARTIAL_URL_BYTES || + selectors.size() > FormState::MAX_SELECTOR_BYTES || + descriptor_identity_hash(descriptor) != request.descriptor_hash) + return false; + + if (!descriptor.empty()) + std::memcpy(_descriptor.data(), descriptor.data(), descriptor.size()); + if (!url.empty()) std::memcpy(_url.data(), url.data(), url.size()); + if (!selectors.empty()) + std::memcpy(_selectors.data(), selectors.data(), selectors.size()); + if (request_size != 0) + std::memcpy(_request_data.data(), request_data, request_size); + _descriptor_size = static_cast(descriptor.size()); + _url_size = static_cast(url.size()); + _selectors_size = static_cast(selectors.size()); + _request_size = static_cast(request_size); + _request = request; + _active = true; + return true; +} + +void PartialController::reset_page(std::size_t base_source_bytes) noexcept { + clear_lease(); + _fragment_source_bytes = {}; + _base_source_bytes = std::min(base_source_bytes, MAX_EXPANDED_SOURCE_BYTES); +} + +void PartialController::cancel() noexcept { + clear_lease(); + _fragment_source_bytes = {}; + _base_source_bytes = 0; +} + +bool PartialController::matches(const PartialRequest& request) const noexcept { + return _active && request.partial_index == _request.partial_index && + request.page_generation == _request.page_generation && + request.partial_generation == _request.partial_generation && + request.request_token == _request.request_token && + request.descriptor_hash == _request.descriptor_hash; +} + +bool PartialController::matches(const PartialRequest& request, + const CompactPage& page) const noexcept { + if (!matches(request) || request.partial_index >= page.partials().size()) + return false; + const auto& partial = page.partials()[request.partial_index]; + const auto descriptor = page.partial_descriptor(partial); + return partial.descriptor_hash == request.descriptor_hash && + descriptor.size() == _descriptor_size && + (_descriptor_size == 0 || std::memcmp( + descriptor.data(), _descriptor.data(), _descriptor_size) == 0); +} + +std::size_t PartialController::expanded_source_bytes() const noexcept { + std::size_t total = _base_source_bytes; + for (const uint32_t bytes : _fragment_source_bytes) { + if (bytes > MAX_EXPANDED_SOURCE_BYTES - + std::min(total, MAX_EXPANDED_SOURCE_BYTES)) + return MAX_EXPANDED_SOURCE_BYTES + 1; + total += bytes; + } + return total; +} + +bool PartialController::can_accept_fragment(std::size_t partial_index, + std::size_t source_bytes) const noexcept { + if (!_active || partial_index != _request.partial_index || + partial_index >= _fragment_source_bytes.size() || + source_bytes > MAX_RESPONSE_BYTES) + return false; + const std::size_t current = expanded_source_bytes(); + if (current > MAX_EXPANDED_SOURCE_BYTES) return false; + const std::size_t old = _fragment_source_bytes[partial_index]; + const std::size_t without_old = current >= old ? current - old : 0; + return source_bytes <= MAX_EXPANDED_SOURCE_BYTES - + std::min(without_old, MAX_EXPANDED_SOURCE_BYTES); +} + +bool PartialController::commit_fragment(std::size_t partial_index, + std::size_t source_bytes) noexcept { + if (!can_accept_fragment(partial_index, source_bytes)) return false; + _fragment_source_bytes[partial_index] = static_cast(source_bytes); + return true; +} + +void PartialController::clear_lease() noexcept { + wipe(_descriptor.data(), _descriptor.size()); + wipe(_url.data(), _url.size()); + wipe(_selectors.data(), _selectors.size()); + wipe(_request_data.data(), _request_data.size()); + _request = PartialRequest{}; + _descriptor_size = 0; + _url_size = 0; + _selectors_size = 0; + _request_size = 0; + _active = false; +} + +} // namespace UI::LXMF::NomadNet diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetPartialController.h b/lib/tdeck_ui/UI/LXMF/NomadNetPartialController.h new file mode 100644 index 00000000..62257299 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetPartialController.h @@ -0,0 +1,67 @@ +#pragma once + +#include "NomadNetForm.h" +#include "NomadNetPartialScheduler.h" + +#include +#include +#include + +namespace UI::LXMF::NomadNet { + +class PartialController { +public: + static constexpr std::size_t MAX_RESPONSE_BYTES = 16 * 1024; + static constexpr std::size_t MAX_RESPONSE_WIRE_BYTES = MAX_RESPONSE_BYTES + 64; + static constexpr std::size_t MAX_EXPANDED_SOURCE_BYTES = + DocumentParser::MAX_DOCUMENT_BYTES; + + PartialController() = default; + ~PartialController() { clear_lease(); } + PartialController(const PartialController&) = delete; + PartialController& operator=(const PartialController&) = delete; + + bool prepare(const PartialRequest& request, const CompactPage& page, + const uint8_t* request_data, std::size_t request_size) noexcept; + void reset_page(std::size_t base_source_bytes) noexcept; + void abandon_request() noexcept { clear_lease(); } + void cancel() noexcept; + + bool active() const noexcept { return _active; } + const PartialRequest& request() const noexcept { return _request; } + const char* descriptor_data() const noexcept { return _descriptor.data(); } + std::size_t descriptor_size() const noexcept { return _descriptor_size; } + const char* url_data() const noexcept { return _url.data(); } + std::size_t url_size() const noexcept { return _url_size; } + const char* selectors_data() const noexcept { return _selectors.data(); } + std::size_t selectors_size() const noexcept { return _selectors_size; } + const uint8_t* request_data() const noexcept { return _request_data.data(); } + std::size_t request_size() const noexcept { return _request_size; } + + bool matches(const PartialRequest& request) const noexcept; + bool matches(const PartialRequest& request, + const CompactPage& page) const noexcept; + bool can_accept_fragment(std::size_t partial_index, + std::size_t source_bytes) const noexcept; + bool commit_fragment(std::size_t partial_index, + std::size_t source_bytes) noexcept; + std::size_t expanded_source_bytes() const noexcept; + +private: + void clear_lease() noexcept; + + PartialRequest _request{}; + std::array _descriptor{}; + std::array _url{}; + std::array _selectors{}; + std::array _request_data{}; + std::array _fragment_source_bytes{}; + std::size_t _base_source_bytes = 0; + uint16_t _descriptor_size = 0; + uint16_t _url_size = 0; + uint16_t _selectors_size = 0; + uint16_t _request_size = 0; + bool _active = false; +}; + +} // namespace UI::LXMF::NomadNet diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetPartialScheduler.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetPartialScheduler.cpp index fb1a9424..ca44ba47 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetPartialScheduler.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetPartialScheduler.cpp @@ -120,6 +120,25 @@ bool PartialScheduler::complete(const PartialRequest& request, bool success, return true; } +bool PartialScheduler::defer(const PartialRequest& request) noexcept { + if (_page_generation == 0 || request.page_generation != _page_generation || + request.partial_index >= _count || request.request_token == 0 || + request.request_token != _in_flight_token || + request.partial_index != _in_flight_index) return false; + Entry& entry = _entries[request.partial_index]; + if (!entry.in_flight || entry.request_token != request.request_token || + entry.partial_generation != request.partial_generation || + entry.descriptor_hash != request.descriptor_hash) return false; + + entry.in_flight = false; + entry.request_token = 0; + entry.due_at_ms = entry.started_at_ms; + entry.pending = true; + _in_flight_token = 0; + _in_flight_index = 0; + return true; +} + bool PartialScheduler::request_now(std::size_t partial_index, uint32_t page_generation, uint32_t now_ms) noexcept { diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetPartialScheduler.h b/lib/tdeck_ui/UI/LXMF/NomadNetPartialScheduler.h index 7485ce65..a5014f68 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetPartialScheduler.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetPartialScheduler.h @@ -32,6 +32,7 @@ public: PartialRequest& request) noexcept; bool complete(const PartialRequest& request, bool success, uint32_t now_ms) noexcept; + bool defer(const PartialRequest& request) noexcept; bool request_now(std::size_t partial_index, uint32_t page_generation, uint32_t now_ms) noexcept; diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp index 7be9d065..c709ea80 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp @@ -490,6 +490,306 @@ bool NomadNetScreen::set_page(const NomadNet::Document& document) { return true; } +bool NomadNetScreen::prepare_partial_request( + const NomadNet::PartialRequest& request, + NomadNet::PartialController& controller, + NomadNet::FormEncodeResult& result) const { + result = NomadNet::FormEncodeResult::INVALID_STATE; + if (request.partial_index >= _page.partials().size()) return false; + const auto& partial = _page.partials()[request.partial_index]; + if (partial.descriptor_hash != request.descriptor_hash) return false; + const auto selectors = _page.partial_selectors(partial); + if ((!selectors.data() && !selectors.empty()) || + selectors.size() > NomadNet::FormState::MAX_SELECTOR_BYTES) + return false; + NomadNet::ExternalVector encoded; + try { + NomadNet::FormState snapshot = _form_state; + if (_field_editor && _editing_field >= 0 && + static_cast(_editing_field) < snapshot.fields().size()) { + const char* value = lv_textarea_get_text(_field_editor); + const std::size_t size = value ? std::strlen(value) : 0; + if (!snapshot.set_value(static_cast(_editing_field), value, size)) { + result = NomadNet::FormEncodeResult::VALUE_TOO_LARGE; + return false; + } + } + result = snapshot.encode( + std::string(selectors.data(), selectors.size()), encoded); + if (result != NomadNet::FormEncodeResult::OK) return false; + const bool prepared = controller.prepare( + request, _page, encoded.data(), encoded.size()); + NomadNet::clear_encoded_form(encoded); + if (!prepared) result = NomadNet::FormEncodeResult::INVALID_STATE; + return prepared; + } catch (const std::bad_alloc&) { + NomadNet::clear_encoded_form(encoded); + result = NomadNet::FormEncodeResult::ALLOCATION_FAILED; + return false; + } +} + +bool NomadNetScreen::partial_id_matches(std::size_t partial_index, + const char* id, + std::size_t id_size) const { + if ((!id && id_size != 0) || partial_index >= _page.partials().size()) + return false; + const auto value = _page.partial_id(_page.partials()[partial_index]); + return value.size() == id_size && + (id_size == 0 || std::memcmp(value.data(), id, id_size) == 0); +} + +NomadNet::PartialReplaceResult NomadNetScreen::apply_partial_fragment( + const NomadNet::PartialRequest& request, + const NomadNet::Document& fragment, + const NomadNet::PartialController& controller) { + if (!controller.matches(request, _page)) + return NomadNet::PartialReplaceResult::INVALID_PARTIAL; + + NomadNet::CompactPage candidate_page; + const auto replacement = candidate_page.assign_replacing_partial( + _page, request.partial_index, fragment, + NomadNet::CompactPage::MAX_ARENA_BYTES); + if (replacement != NomadNet::PartialReplaceResult::APPLIED) + return replacement; + + NomadNet::FormState candidate_form; + if (!candidate_form.assign_preserving(candidate_page, _form_state)) + return NomadNet::PartialReplaceResult::ALLOCATION_FAILED; + + auto map_field = [&](int16_t old_index) -> int16_t { + if (old_index < 0 || + static_cast(old_index) >= _form_state.fields().size()) + return -1; + const auto& old = _form_state.fields()[old_index]; + auto matches_identity = [&](const NomadNet::FormState::FieldState& candidate) { + if (candidate.type != old.type || + candidate.partial_region_index != old.partial_region_index || + candidate.name_length != old.name_length || + (old.name_length != 0 && std::memcmp( + candidate.name.data(), old.name.data(), old.name_length) != 0)) + return false; + if (old.type != NomadNet::FormFieldType::RADIO && + old.type != NomadNet::FormFieldType::CHECKBOX) + return true; + return candidate.value_length == old.value_length && + (old.value_length == 0 || std::memcmp( + candidate.value.data(), old.value.data(), old.value_length) == 0); + }; + std::size_t occurrence = 0; + for (std::size_t index = 0; index < static_cast(old_index); ++index) { + const auto& prior = _form_state.fields()[index]; + if (matches_identity(prior)) + ++occurrence; + } + std::size_t seen = 0; + for (std::size_t index = 0; index < candidate_form.fields().size(); ++index) { + const auto& candidate = candidate_form.fields()[index]; + if (!matches_identity(candidate)) continue; + if (seen++ == occurrence) return static_cast(index); + } + return -1; + }; + auto map_link = [&](int16_t old_index) -> int16_t { + if (old_index < 0 || + static_cast(old_index) >= _page.links().size()) + return -1; + const auto old_target = _page.target(static_cast(old_index)); + const int16_t old_region = + _page.links()[static_cast(old_index)].partial_region_index; + std::size_t occurrence = 0; + for (std::size_t index = 0; index < static_cast(old_index); ++index) { + const auto prior = _page.target(index); + if (_page.links()[index].partial_region_index == old_region && + prior.size() == old_target.size() && + (old_target.empty() || std::memcmp( + prior.data(), old_target.data(), old_target.size()) == 0)) + ++occurrence; + } + std::size_t seen = 0; + for (std::size_t index = 0; index < candidate_page.links().size(); ++index) { + const auto candidate = candidate_page.target(index); + if (candidate_page.links()[index].partial_region_index != old_region || + candidate.size() != old_target.size() || + (!old_target.empty() && std::memcmp( + candidate.data(), old_target.data(), old_target.size()) != 0)) + continue; + if (seen++ == occurrence) return static_cast(index); + } + return -1; + }; + const int16_t mapped_selected_link = map_link(_selected_link); + const int16_t mapped_selected_field = map_field(_selected_field); + const int16_t mapped_editing_field = map_field(_editing_field); + int16_t fallback_link = -1; + int16_t fallback_field = -1; + auto map_focus_at = [&](std::size_t index) { + if (index >= _focus_order.size()) return false; + const auto& target = _focus_order[index]; + if (target.field) fallback_field = map_field( + static_cast(target.index)); + else fallback_link = map_link(static_cast(target.index)); + return fallback_field >= 0 || fallback_link >= 0; + }; + if (mapped_selected_link < 0 && mapped_selected_field < 0 && + _selected_focus >= 0 && + static_cast(_selected_focus) < _focus_order.size()) { + for (std::size_t index = static_cast(_selected_focus) + 1; + index < _focus_order.size(); ++index) + if (map_focus_at(index)) break; + if (fallback_link < 0 && fallback_field < 0) { + for (std::size_t index = static_cast(_selected_focus); + index-- > 0;) + if (map_focus_at(index)) break; + } + } + + bool has_top_anchor = false; + int16_t old_top_region = -1; + std::size_t old_top_region_ordinal = 0; + int32_t old_top_intra = 0; + const LayoutCheckpoint* old_top = nullptr; + for (const auto& checkpoint : _layout_checkpoints) { + if (checkpoint.y <= _logical_scroll && + (!old_top || checkpoint.y >= old_top->y)) + old_top = &checkpoint; + } + if (old_top && old_top->block_index < _page.blocks().size()) { + has_top_anchor = true; + old_top_region = _page.blocks()[old_top->block_index].partial_region_index; + for (std::size_t index = 0; index < old_top->block_index; ++index) + if (_page.blocks()[index].partial_region_index == old_top_region) + ++old_top_region_ordinal; + old_top_intra = std::max(0, _logical_scroll - old_top->y); + } + + if (_field_editor && mapped_editing_field >= 0) { + const char* value = lv_textarea_get_text(_field_editor); + const std::size_t size = value ? std::strlen(value) : 0; + if (!candidate_form.set_value( + static_cast(mapped_editing_field), value, size)) + return NomadNet::PartialReplaceResult::LIMIT_EXCEEDED; + } + + auto old_page = std::move(_page); + auto old_form = std::move(_form_state); + auto old_page_layout = std::move(_page_layout); + auto old_line_layout = std::move(_line_layout); + auto old_checkpoints = std::move(_layout_checkpoints); + auto old_link_y = std::move(_link_y); + auto old_link_bottom = std::move(_link_bottom); + auto old_field_y = std::move(_field_y); + auto old_field_bottom = std::move(_field_bottom); + auto old_focus = std::move(_focus_order); + const int32_t old_page_height = _page_height; + const int32_t old_physical_extent = _physical_extent; + const int32_t old_logical_scroll = _logical_scroll; + const int32_t old_widget_scroll = lv_obj_get_scroll_y(_content); + const int32_t old_window_top = _layout_window_top; + const int32_t old_window_bottom = _layout_window_bottom; + const TableLayoutObservation old_table_layout = _table_layout; + const int16_t old_selected_link = _selected_link; + const int16_t old_selected_field = _selected_field; + const int16_t old_selected_focus = _selected_focus; + const int16_t old_editing_field = _editing_field; + + _page = std::move(candidate_page); + _form_state = std::move(candidate_form); + bool laid_out = false; + try { laid_out = layout_page(); } catch (const std::bad_alloc&) { laid_out = false; } + auto rollback = [&]() { + _page = std::move(old_page); + _form_state = std::move(old_form); + _page_layout = std::move(old_page_layout); + _line_layout = std::move(old_line_layout); + _layout_checkpoints = std::move(old_checkpoints); + _link_y = std::move(old_link_y); + _link_bottom = std::move(old_link_bottom); + _field_y = std::move(old_field_y); + _field_bottom = std::move(old_field_bottom); + _focus_order = std::move(old_focus); + _page_height = old_page_height; + _physical_extent = old_physical_extent; + _logical_scroll = old_logical_scroll; + _layout_window_top = old_window_top; + _layout_window_bottom = old_window_bottom; + _table_layout = old_table_layout; + _selected_link = old_selected_link; + _selected_field = old_selected_field; + _selected_focus = old_selected_focus; + _editing_field = old_editing_field; + _transaction_scroll_restore = true; + lv_obj_scroll_to_y(_content, old_widget_scroll, LV_ANIM_OFF); + _transaction_scroll_restore = false; + }; + if (!laid_out) { + rollback(); + return NomadNet::PartialReplaceResult::ALLOCATION_FAILED; + } + + + _selected_link = mapped_selected_link >= 0 ? mapped_selected_link : fallback_link; + _selected_field = mapped_selected_field >= 0 ? mapped_selected_field : fallback_field; + _selected_focus = -1; + if (_selected_field >= 0 || _selected_link >= 0) { + for (std::size_t index = 0; index < _focus_order.size(); ++index) { + const bool matches_field = _selected_field >= 0 && + _focus_order[index].field && _focus_order[index].index == + static_cast(_selected_field); + const bool matches_link = _selected_field < 0 && + _selected_link >= 0 && !_focus_order[index].field && + _focus_order[index].index == + static_cast(_selected_link); + if (matches_field || matches_link) { + _selected_focus = static_cast(index); + break; + } + } + } + + if (_field_editor && mapped_editing_field >= 0) + _editing_field = mapped_editing_field; + if (!scroll_to_logical(old_logical_scroll, LV_ANIM_OFF)) { + rollback(); + lv_obj_invalidate(_content); + return NomadNet::PartialReplaceResult::ALLOCATION_FAILED; + } + if (has_top_anchor) { + std::size_t mapped_block = _page.blocks().size(); + std::size_t region_top = _page.blocks().size(); + bool anchor_survived = false; + std::size_t seen = 0; + for (std::size_t index = 0; index < _page.blocks().size(); ++index) { + if (_page.blocks()[index].partial_region_index != old_top_region) continue; + if (region_top == _page.blocks().size()) region_top = index; + if (seen++ == old_top_region_ordinal) { + mapped_block = index; + anchor_survived = true; + break; + } + } + if (mapped_block == _page.blocks().size()) mapped_block = region_top; + if (mapped_block < _page.blocks().size()) { + for (const auto& checkpoint : _layout_checkpoints) { + if (checkpoint.block_index != mapped_block) continue; + const int32_t mapped_intra = anchor_survived ? old_top_intra : 0; + if (!scroll_to_logical(checkpoint.y + mapped_intra, LV_ANIM_OFF)) { + rollback(); + lv_obj_invalidate(_content); + return NomadNet::PartialReplaceResult::ALLOCATION_FAILED; + } + break; + } + } + } + if (_field_editor && mapped_editing_field < 0) + finish_field_edit(false); + ++_form_generation; + lv_obj_refresh_self_size(_content); + lv_obj_invalidate(_content); + return NomadNet::PartialReplaceResult::APPLIED; +} + bool NomadNetScreen::layout_page(){ _table_layout=TableLayoutObservation{}; _page_layout.clear(); @@ -1034,18 +1334,23 @@ int32_t NomadNetScreen::logical_scroll_from_widget()const{ lv_obj_get_content_height(_content),_physical_extent); } -void NomadNetScreen::scroll_to_logical(int32_t logical,lv_anim_enable_t animation){ +bool NomadNetScreen::scroll_to_logical(int32_t logical,lv_anim_enable_t animation){ +#ifdef PYXIS_NOMADNET_TEST_HOOKS + if(_test_scroll_fail_countdown==0){_test_scroll_fail_countdown=-1;return false;} + if(_test_scroll_fail_countdown>0)--_test_scroll_fail_countdown; +#endif const int32_t viewport=std::max(1,lv_obj_get_content_height(_content)); const int32_t logical_max=std::max(0,_page_height-viewport); const int32_t target=std::max(0,std::min(logical,logical_max)); const int32_t physical=NomadNet::VirtualViewport::physical_from_logical( target,_page_height,viewport,_physical_extent); if(animation==LV_ANIM_OFF){ + if(!layout_window(target))return false; _logical_scroll=target; - layout_window(_logical_scroll); } lv_obj_scroll_to_y(_content,physical,animation); lv_obj_invalidate(_content); + return true; } bool NomadNetScreen::jump_to_anchor(const std::string& name){ @@ -1412,6 +1717,10 @@ void NomadNetScreen::page_event(lv_event_t* event){ if(code==LV_EVENT_DRAW_MAIN)self->draw_page(event); else if(code==LV_EVENT_GET_SELF_SIZE){auto* size=static_cast(lv_event_get_param(event));size->y=std::max(size->y,static_cast(self->_physical_extent));} else if(code==LV_EVENT_SCROLL){ + if(self->_transaction_scroll_restore){ + lv_obj_invalidate(self->_content); + return; + } self->_logical_scroll=self->logical_scroll_from_widget(); if(!self->layout_window(self->_logical_scroll))self->set_status("Page viewport could not be retained"); lv_obj_invalidate(self->_content); diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h index edd2b3e5..c28271f4 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h @@ -9,6 +9,7 @@ #include "NomadNetDocument.h" #include "NomadNetForm.h" #include "NomadNetLibrary.h" +#include "NomadNetPartialController.h" #include "NomadNetVirtualViewport.h" namespace UI::LXMF { @@ -38,6 +39,20 @@ public: std::string& target, NomadNet::ExternalVector& request_data, NomadNet::FormEncodeResult& result) const; + bool prepare_partial_request(const NomadNet::PartialRequest& request, + NomadNet::PartialController& controller, + NomadNet::FormEncodeResult& result) const; + bool partial_request_matches( + const NomadNet::PartialRequest& request, + const NomadNet::PartialController& controller) const { + return controller.matches(request, _page); + } + NomadNet::PartialReplaceResult apply_partial_fragment( + const NomadNet::PartialRequest& request, + const NomadNet::Document& fragment, + const NomadNet::PartialController& controller); + bool partial_id_matches(std::size_t partial_index, + const char* id, std::size_t id_size) const; bool jump_to_anchor(const std::string& name); void restore_logical_scroll(int32_t logical); int32_t logical_scroll() const { return _logical_scroll; } @@ -142,6 +157,10 @@ private: int32_t _logical_scroll = 0; int32_t _layout_window_top = 0; int32_t _layout_window_bottom = 0; + bool _transaction_scroll_restore = false; +#ifdef PYXIS_NOMADNET_TEST_HOOKS + int8_t _test_scroll_fail_countdown = -1; +#endif TableLayoutObservation _table_layout; int16_t _selected_link = -1; int16_t _selected_field = -1; @@ -190,7 +209,7 @@ private: int16_t available, uint8_t heading_level, int32_t window_top, int32_t window_bottom); int32_t logical_scroll_from_widget() const; - void scroll_to_logical(int32_t logical, lv_anim_enable_t animation); + bool scroll_to_logical(int32_t logical, lv_anim_enable_t animation); void draw_page(lv_event_t* event); void select_link(int direction); void activate_selected_link(); diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index 14838069..4b9d902c 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -54,6 +54,18 @@ constexpr const char* NOMAD_LIBRARY_STAGE = "/nomadnet/library.new"; constexpr const char* NOMAD_LIBRARY_BACKUP = "/nomadnet/library.bak"; constexpr const char* NOMAD_LIBRARY_OLD = "/nomadnet/library.old"; +bool bytes_equal_lower_hex(const Bytes& bytes, const std::string& hex) { + static constexpr char DIGITS[] = "0123456789abcdef"; + if (hex.size() != bytes.size() * 2U) return false; + for (std::size_t index = 0; index < bytes.size(); ++index) { + const uint8_t value = bytes[index]; + if (hex[index * 2U] != DIGITS[value >> 4U] || + hex[index * 2U + 1U] != DIGITS[value & 0x0fU]) + return false; + } + return true; +} + #if defined(MEMORY_INSTRUMENTATION_ENABLED) || defined(PYXIS_NOMAD_MEMORY_DIAGNOSTIC) void nomad_heap_checkpoint(const char* phase) { Serial.printf( @@ -2168,6 +2180,8 @@ bool UIManager::nomad_refresh_path_after_link_failure() { uint32_t UIManager::nomad_advance_navigation_generation() { _nomad_partial_scheduler.cancel(_nomad_navigation_generation); + _nomad_partial_controller.cancel(); + _nomad_partial_request = NomadNet::PartialRequest{}; ++_nomad_navigation_generation; if (_nomad_navigation_generation == 0) ++_nomad_navigation_generation; return _nomad_navigation_generation; @@ -2186,7 +2200,7 @@ bool UIManager::nomad_supersede_transport(const std::string& destination_hex) { const bool retain_link = _nomad_link && _nomad_link.status() == Type::Link::ACTIVE && _nomad_destination_hash && - _nomad_destination_hash.toHex() == destination_hex; + bytes_equal_lower_hex(_nomad_destination_hash, destination_hex); if (!retain_link && _nomad_link && _nomad_link.status() != Type::Link::CLOSED) _nomad_link.teardown(); nomad_release_request(); @@ -2203,6 +2217,10 @@ bool UIManager::nomad_supersede_transport(const std::string& destination_hex) { void UIManager::nomad_open(const std::string& address, bool add_history, int32_t restore_logical_scroll, bool preserve_submission, bool history_prepared) { + if (address.rfind("p:", 0) == 0) { + nomad_schedule_partial_ids(address, millis()); + return; + } if (!preserve_submission) { NomadNet::clear_encoded_form(_nomad_submission_data); _nomad_submission_ready = false; @@ -2344,6 +2362,183 @@ void UIManager::nomad_open(const std::string& address, bool add_history, nomad_begin_live_transport(); } +const NomadNet::Url& UIManager::nomad_transport_url() const { + return _nomad_partial_controller.active() ? _nomad_partial_url : _nomad_url; +} + +bool UIManager::nomad_schedule_partial_ids(const std::string& address, + uint32_t now_ms) { + bool matched = false; + std::size_t start = 2; + while (start <= address.size()) { + const std::size_t end = address.find(':', start); + const std::size_t size = (end == std::string::npos ? address.size() : end) - start; + if (size != 0 && size <= NomadNet::DocumentParser::MAX_PARTIAL_ID_BYTES) { + LVGL_LOCK(); + for (std::size_t index = 0; + index < _nomad_partial_scheduler.size(); ++index) { + if (_nomadnet_screen->partial_id_matches( + index, address.data() + start, size) && + _nomad_partial_scheduler.request_now( + index, _nomad_navigation_generation, now_ms)) + matched = true; + } + } + if (end == std::string::npos) break; + start = end + 1; + } + LVGL_LOCK(); + _nomadnet_screen->set_status(matched + ? "Dynamic content queued" : "No matching dynamic content"); + return matched; +} + +void UIManager::nomad_poll_partials(uint32_t now_ms) { + if (_nomad_state != NomadState::IDLE || + _nomad_partial_controller.active()) + return; + NomadNet::PartialRequest request; + if (!_nomad_partial_scheduler.poll( + now_ms, _navigation.current() == Route::NOMADNET, + true, request)) + return; + + NomadNet::FormEncodeResult encode_result; + bool prepared = false; + { + LVGL_LOCK(); + prepared = _nomadnet_screen->prepare_partial_request( + request, _nomad_partial_controller, encode_result); + } + if (!prepared) { + _nomad_partial_scheduler.defer(request); + _nomad_partial_controller.abandon_request(); + LVGL_LOCK(); + _nomadnet_screen->set_status( + encode_result == NomadNet::FormEncodeResult::ALLOCATION_FAILED + ? "Dynamic request exceeds available memory" + : "Dynamic request data exceeds device limit"); + return; + } + + NomadNet::Url target; + std::string error; + bool parsed = false; + bool allocation_failed = false; + try { + parsed = NomadNet::Url::parse( + std::string(_nomad_partial_controller.url_data(), + _nomad_partial_controller.url_size()), + target, error, _nomad_url.destination_hex, _nomad_url.path, {}); + } catch (const std::bad_alloc&) { + allocation_failed = true; + } + if (!parsed || target.has_fragment) { + if (allocation_failed) _nomad_partial_scheduler.defer(request); + else _nomad_partial_scheduler.complete(request, false, now_ms); + _nomad_partial_controller.abandon_request(); + LVGL_LOCK(); + _nomadnet_screen->set_status( + allocation_failed ? "Dynamic address exceeds available memory" : + parsed ? "Dynamic address cannot target a fragment" : error.c_str()); + return; + } + if (!nomad_supersede_transport(target.destination_hex)) { + _nomad_partial_scheduler.defer(request); + _nomad_partial_controller.abandon_request(); + LVGL_LOCK(); + _nomadnet_screen->set_status("Dynamic request owner is busy"); + return; + } + _nomad_partial_request = request; + _nomad_partial_url = std::move(target); + _nomad_state = NomadState::PARTIAL_PENDING; + nomad_begin_partial_transport(); +} + +void UIManager::nomad_begin_partial_transport() { + try { + if (!_nomad_partial_controller.active()) { + _nomad_state = NomadState::IDLE; + return; + } + RouterLock router_lock; + if (!router_lock.acquired()) { + _nomad_state = NomadState::PARTIAL_PENDING; + return; + } + const bool same_destination = _nomad_link && + _nomad_link.status() == Type::Link::ACTIVE && + _nomad_destination_hash && bytes_equal_lower_hex( + _nomad_destination_hash, _nomad_partial_url.destination_hex); + _nomad_mailbox.prepare( + _nomad_navigation_generation, + NomadNet::PartialController::MAX_RESPONSE_WIRE_BYTES); + if (same_destination) { + nomad_send_request(); + return; + } + if (_nomad_link && _nomad_link.status() != Type::Link::CLOSED) + _nomad_link.teardown(); + nomad_release_request(); + _nomad_link = Link(Type::NONE); + _nomad_link_identified = false; + _nomad_request = RequestReceipt(Type::NONE); + _nomad_response.clear(); + _nomad_request_policy.reset(); + _nomad_destination_hash = Bytes(); + _nomad_destination_hash.assignHex(_nomad_partial_url.destination_hex.c_str()); + { + LVGL_LOCK(); + _nomadnet_screen->set_status("Discovering dynamic-content path..."); + } + if (Transport::has_path(_nomad_destination_hash)) nomad_start_link(); + else { + Transport::request_path(_nomad_destination_hash); + _nomad_state = NomadState::PATH; + _nomad_deadline_ms = millis() + NomadNet::RequestPolicy::PATH_WAIT_MS; + } + } catch (const std::bad_alloc&) { + nomad_defer_partial( + "Dynamic transport exceeds available internal memory", false); + } +} + +void UIManager::nomad_finish_partial(bool success, const char* status) { + nomad_release_partial(success, false, status, true); +} + +void UIManager::nomad_defer_partial(const char* status, bool retain_link) { + nomad_release_partial(false, true, status, retain_link); +} + +void UIManager::nomad_release_partial(bool success, bool deferred, const char* status, + bool allow_retain_link) { + if (!_nomad_partial_controller.active()) return; + const std::string& destination = _nomad_partial_url.destination_hex; + if (deferred) _nomad_partial_scheduler.defer(_nomad_partial_request); + else _nomad_partial_scheduler.complete( + _nomad_partial_request, success, millis()); + _nomad_response.release(); + const bool retain_link = allow_retain_link && _nomad_link && + _nomad_link.status() == Type::Link::ACTIVE && + _nomad_destination_hash && + bytes_equal_lower_hex(_nomad_destination_hash, destination); + if (retain_link) { + if (success) nomad_finish_request_keep_link(); + else { + nomad_release_request(); + _nomad_state = NomadState::IDLE; + _nomad_deadline_ms = 0; + } + } else nomad_stop_transport(); + _nomad_partial_controller.abandon_request(); + _nomad_partial_request = NomadNet::PartialRequest{}; + _nomad_partial_url = NomadNet::Url{}; + LVGL_LOCK(); + _nomadnet_screen->set_status(status); +} + void UIManager::nomad_begin_live_transport() { RouterLock router_lock; if (!router_lock.acquired()) { @@ -2360,10 +2555,10 @@ void UIManager::nomad_begin_live_transport() { _nomadnet_screen->set_address(_nomad_url.str()); nomad_heap_checkpoint("action-after-navigation"); } - const bool same_destination = NomadNet::OwnerController::retain_active_link( - _nomad_url.destination_hex, - _nomad_destination_hash ? _nomad_destination_hash.toHex() : std::string(), - _nomad_link && _nomad_link.status() == Type::Link::ACTIVE); + const bool same_destination = _nomad_link && + _nomad_link.status() == Type::Link::ACTIVE && + _nomad_destination_hash && bytes_equal_lower_hex( + _nomad_destination_hash, _nomad_url.destination_hex); if (same_destination) { _nomad_response.clear(); _nomad_request_policy.reset(); @@ -2372,7 +2567,11 @@ void UIManager::nomad_begin_live_transport() { _nomadnet_screen->set_address(_nomad_url.str()); _nomadnet_screen->set_status("Requesting page..."); } - _nomad_mailbox.prepare(_nomad_navigation_generation); + _nomad_mailbox.prepare( + _nomad_navigation_generation, + _nomad_partial_controller.active() + ? NomadNet::PartialController::MAX_RESPONSE_WIRE_BYTES + : NomadNet::AsyncMailbox::MAX_WIRE_BYTES); nomad_send_request(); return; } @@ -2403,6 +2602,10 @@ void UIManager::nomad_start_link() { nomad_heap_checkpoint("link-enter"); Identity identity = Identity::recall(_nomad_destination_hash); if (!identity) { + if (_nomad_partial_controller.active()) { + nomad_finish_partial(false, "Dynamic-content identity is not known"); + return; + } _nomad_state = NomadState::IDLE; NomadNet::clear_encoded_form(_nomad_submission_data); _nomad_submission_ready = false; @@ -2413,6 +2616,10 @@ void UIManager::nomad_start_link() { Destination destination(identity, Type::Destination::OUT, Type::Destination::SINGLE, "nomadnetwork", "node"); if (destination.hash() != _nomad_destination_hash) { + if (_nomad_partial_controller.active()) { + nomad_finish_partial(false, "Dynamic-content identity does not match address"); + return; + } _nomad_state = NomadState::IDLE; NomadNet::clear_encoded_form(_nomad_submission_data); _nomad_submission_ready = false; @@ -2420,7 +2627,11 @@ void UIManager::nomad_start_link() { _nomadnet_screen->set_status("Identity does not match node address"); return; } - _nomad_mailbox.prepare(_nomad_navigation_generation); + _nomad_mailbox.prepare( + _nomad_navigation_generation, + _nomad_partial_controller.active() + ? NomadNet::PartialController::MAX_RESPONSE_WIRE_BYTES + : NomadNet::AsyncMailbox::MAX_WIRE_BYTES); nomad_heap_checkpoint("link-before-construct"); _nomad_link = Link(destination, on_nomad_link_established, on_nomad_link_closed); nomad_heap_checkpoint("link-after-construct"); @@ -2433,7 +2644,7 @@ void UIManager::nomad_start_link() { void UIManager::nomad_identify_link_if_configured() { if (_nomad_link_identified) return; - if (_nomad_library.node_identified(_nomad_url.destination_hex)) { + if (_nomad_library.node_identified(nomad_transport_url().destination_hex)) { _nomad_link.identify(_router.identity()); _nomad_link_identified = true; } @@ -2446,7 +2657,10 @@ void UIManager::nomad_send_request() { _nomad_link.set_resource_started_callback(on_nomad_resource_started); RNS::Bytes packed_request_data; try { - if (_nomad_submission_ready) { + if (_nomad_partial_controller.active()) { + packed_request_data = Bytes(_nomad_partial_controller.request_data(), + _nomad_partial_controller.request_size()); + } else if (_nomad_submission_ready) { packed_request_data = Bytes(_nomad_submission_data.data(), _nomad_submission_data.size()); } else { const auto request_data = NomadNet::request_data(_nomad_url.fields); @@ -2458,40 +2672,63 @@ void UIManager::nomad_send_request() { } packed_request_data = Bytes(request_data.data(), request_data.size()); } - _nomad_request = _nomad_link.request( - Bytes(reinterpret_cast(_nomad_url.path.data()), _nomad_url.path.size()), - packed_request_data, on_nomad_response, on_nomad_failed, - on_nomad_progress, 30.0, NomadNet::AsyncMailbox::MAX_WIRE_BYTES, true); + if (_nomad_partial_controller.active()) { + _nomad_request = _nomad_link.request( + Bytes(reinterpret_cast(_nomad_partial_url.path.data()), + _nomad_partial_url.path.size()), + packed_request_data, on_nomad_response, on_nomad_failed, + on_nomad_progress, 30.0, + NomadNet::PartialController::MAX_RESPONSE_WIRE_BYTES, true); + } else { + _nomad_request = _nomad_link.request( + Bytes(reinterpret_cast(_nomad_url.path.data()), + _nomad_url.path.size()), + packed_request_data, on_nomad_response, on_nomad_failed, + on_nomad_progress, 30.0, NomadNet::AsyncMailbox::MAX_WIRE_BYTES, true); + } } catch (const std::bad_alloc&) { if (packed_request_data) { volatile uint8_t* bytes = packed_request_data.writable(0); for (std::size_t i = 0; i < packed_request_data.size(); ++i) bytes[i] = 0; } - NomadNet::clear_encoded_form(_nomad_submission_data); - _nomad_submission_ready = false; - nomad_stop_transport(); - LVGL_LOCK(); - _nomadnet_screen->set_status("Request exceeds available internal memory"); + if (_nomad_partial_controller.active()) { + nomad_defer_partial( + "Dynamic request exceeds available internal memory", + false); + } else { + NomadNet::clear_encoded_form(_nomad_submission_data); + _nomad_submission_ready = false; + nomad_stop_transport(); + LVGL_LOCK(); + _nomadnet_screen->set_status("Request exceeds available internal memory"); + } return; } if (packed_request_data) { volatile uint8_t* bytes = packed_request_data.writable(0); for (std::size_t i = 0; i < packed_request_data.size(); ++i) bytes[i] = 0; } - NomadNet::clear_encoded_form(_nomad_submission_data); - _nomad_submission_ready = false; + if (!_nomad_partial_controller.active()) { + NomadNet::clear_encoded_form(_nomad_submission_data); + _nomad_submission_ready = false; + } nomad_heap_checkpoint("request-created"); if (!_nomad_request) { - nomad_stop_transport(); - LVGL_LOCK(); - _nomadnet_screen->set_status("Request could not be sent"); + if (_nomad_partial_controller.active()) + nomad_finish_partial(false, "Dynamic request could not be sent"); + else { + nomad_stop_transport(); + LVGL_LOCK(); + _nomadnet_screen->set_status("Request could not be sent"); + } return; } _nomad_mailbox.expect_request(token(_nomad_request.request_id())); _nomad_state = NomadState::REQUEST; _nomad_deadline_ms = millis() + 30000; LVGL_LOCK(); - _nomadnet_screen->set_status("Requesting page..."); + _nomadnet_screen->set_status(_nomad_partial_controller.active() + ? "Updating dynamic content..." : "Requesting page..."); } bool UIManager::nomad_apply_page_bytes(const uint8_t* data, std::size_t size, bool cached) { @@ -2556,6 +2793,7 @@ NomadNet::PageApplyResult UIManager::nomad_apply_page_document( } _nomad_partial_scheduler.configure( document, _nomad_navigation_generation, millis()); + _nomad_partial_controller.reset_page(document.source_bytes); if (library_changed) _nomad_library_dirty = true; _nomad_pending_scroll = -1; return result; @@ -2611,6 +2849,14 @@ void UIManager::nomad_update() { nomad_begin_live_transport(); return; } + if (_nomad_state == NomadState::PARTIAL_PENDING) { + nomad_begin_partial_transport(); + return; + } + if (_nomad_state == NomadState::IDLE) { + nomad_poll_partials(millis()); + if (_nomad_state != NomadState::IDLE) return; + } RouterLock router_lock; if (!router_lock.acquired()) return; const uint32_t now = millis(); @@ -2619,9 +2865,13 @@ void UIManager::nomad_update() { } else if (_nomad_state != NomadState::IDLE && _nomad_deadline_ms != 0 && static_cast(now - _nomad_deadline_ms) >= 0) { if (_nomad_state == NomadState::LINK && nomad_refresh_path_after_link_failure()) return; - nomad_stop_transport(); - LVGL_LOCK(); - _nomadnet_screen->set_status("NomadNet operation timed out"); + if (_nomad_partial_controller.active()) + nomad_finish_partial(false, "Dynamic-content request timed out"); + else { + nomad_stop_transport(); + LVGL_LOCK(); + _nomadnet_screen->set_status("NomadNet operation timed out"); + } return; } @@ -2638,36 +2888,108 @@ void UIManager::nomad_update() { break; case NomadNet::AsyncMailbox::Kind::LINK_CLOSED: if (_nomad_state == NomadState::LINK && nomad_refresh_path_after_link_failure()) break; - nomad_stop_transport(); - { LVGL_LOCK(); _nomadnet_screen->set_status("NomadNet link closed"); } + if (_nomad_partial_controller.active()) + nomad_finish_partial(false, "Dynamic-content link closed"); + else { + nomad_stop_transport(); + { LVGL_LOCK(); _nomadnet_screen->set_status("NomadNet link closed"); } + } break; case NomadNet::AsyncMailbox::Kind::FAILED: - nomad_stop_transport(); - { LVGL_LOCK(); _nomadnet_screen->set_status("Page request failed"); } + if (_nomad_partial_controller.active()) + nomad_finish_partial(false, "Dynamic-content request failed"); + else { + nomad_stop_transport(); + { LVGL_LOCK(); _nomadnet_screen->set_status("Page request failed"); } + } break; case NomadNet::AsyncMailbox::Kind::PROGRESS: if (_nomad_state == NomadState::REQUEST) { LVGL_LOCK(); - _nomadnet_screen->set_status("Receiving page..."); + if (_nomad_partial_controller.active()) + _nomadnet_screen->set_status("Receiving dynamic content..."); + else _nomadnet_screen->set_status("Receiving page..."); } break; case NomadNet::AsyncMailbox::Kind::OVERSIZED: _nomad_response.clear(); - nomad_stop_transport(); - { LVGL_LOCK(); _nomadnet_screen->set_status("Page exceeds 64 KiB limit"); } + if (_nomad_partial_controller.active()) + nomad_finish_partial(false, "Dynamic content exceeds 16 KiB limit"); + else { + nomad_stop_transport(); + { LVGL_LOCK(); _nomadnet_screen->set_status("Page exceeds 64 KiB limit"); } + } break; case NomadNet::AsyncMailbox::Kind::RESPONSE: { // The dependency has already removed this successful request from // Link::pending_requests(). Validate it before deciding whether the // Link is safe to retain for same-destination navigation. + if (_nomad_partial_controller.active()) { + bool lease_matches = false; + { + LVGL_LOCK(); + lease_matches = _nomadnet_screen->partial_request_matches( + _nomad_partial_request, _nomad_partial_controller); + } + if (!lease_matches) { + _nomad_partial_scheduler.cancel(_nomad_navigation_generation); + _nomad_partial_controller.cancel(); + nomad_stop_transport(); + LVGL_LOCK(); + _nomadnet_screen->set_status("Discarded stale dynamic content"); + break; + } + } if (!NomadNet::normalize_response(event.data.data(), event.data.size(), _nomad_response)) { - nomad_stop_transport(); - LVGL_LOCK(); - _nomadnet_screen->set_status("Malformed NomadNet response"); + if (_nomad_partial_controller.active()) + nomad_finish_partial(false, "Malformed dynamic-content response"); + else { + nomad_stop_transport(); + LVGL_LOCK(); + _nomadnet_screen->set_status("Malformed NomadNet response"); + } break; } nomad_heap_checkpoint("response-normalized"); const auto& bytes = _nomad_response.bytes(); + if (_nomad_partial_controller.active()) { + if (bytes.size() > NomadNet::PartialController::MAX_RESPONSE_BYTES || + !_nomad_partial_controller.can_accept_fragment( + _nomad_partial_request.partial_index, bytes.size())) { + nomad_finish_partial(false, + "Dynamic content exceeds expanded-page limit"); + break; + } + NomadNet::Document fragment; + const auto parse_status = _nomad_parser.parse_into( + reinterpret_cast(bytes.data()), bytes.size(), fragment); + if (parse_status != NomadNet::ParseStatus::OK || + fragment.malformed || fragment.truncated) { + nomad_finish_partial(false, + parse_status == NomadNet::ParseStatus::ALLOCATION_FAILED + ? "Dynamic content exceeds available memory" + : "Dynamic content is not valid UTF-8/Micron"); + break; + } + NomadNet::PartialReplaceResult applied; + { + LVGL_LOCK(); + applied = _nomadnet_screen->apply_partial_fragment( + _nomad_partial_request, fragment, + _nomad_partial_controller); + } + if (applied != NomadNet::PartialReplaceResult::APPLIED || + !_nomad_partial_controller.commit_fragment( + _nomad_partial_request.partial_index, bytes.size())) { + nomad_finish_partial(false, + applied == NomadNet::PartialReplaceResult::ALLOCATION_FAILED + ? "Dynamic content exceeds available memory" + : "Dynamic content exceeds page limits"); + break; + } + nomad_finish_partial(true, "Dynamic content updated"); + break; + } NomadNet::Document document; try { document = _nomad_parser.parse( @@ -2724,7 +3046,8 @@ void UIManager::nomad_update() { // active Link whose independent owner hash still matches this URL. if (_nomad_link && _nomad_link.status() == Type::Link::ACTIVE && _nomad_destination_hash && - _nomad_destination_hash.toHex() == _nomad_url.destination_hex) { + bytes_equal_lower_hex( + _nomad_destination_hash, _nomad_url.destination_hex)) { nomad_finish_request_keep_link(); } else { nomad_stop_transport(); @@ -2755,12 +3078,8 @@ void UIManager::on_nomad_response(const RequestReceipt& receipt) { void UIManager::on_nomad_failed(const RequestReceipt& receipt) { if (!s_nomad_instance) return; - if (receipt.response_size() > NomadNet::AsyncMailbox::MAX_WIRE_BYTES) { - s_nomad_instance->_nomad_mailbox.publish_oversized( - token(receipt.request_id()), receipt.response_size()); - return; - } - s_nomad_instance->_nomad_mailbox.publish_failed(token(receipt.request_id())); + s_nomad_instance->_nomad_mailbox.publish_failed( + token(receipt.request_id()), receipt.response_size()); } void UIManager::on_nomad_progress(const RequestReceipt& receipt) { diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.h b/lib/tdeck_ui/UI/LXMF/UIManager.h index dda3067a..f2eeabbc 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.h +++ b/lib/tdeck_ui/UI/LXMF/UIManager.h @@ -16,6 +16,7 @@ #include "NomadNetUrl.h" #include "NomadNetDocument.h" #include "NomadNetPartialScheduler.h" +#include "NomadNetPartialController.h" #include "NomadNetProtocol.h" #include "NomadNetHistory.h" #include "NomadNetMailbox.h" @@ -413,6 +414,9 @@ private: NomadNet::Url _nomad_url; NomadNet::DocumentParser _nomad_parser; NomadNet::PartialScheduler _nomad_partial_scheduler; + NomadNet::PartialController _nomad_partial_controller; + NomadNet::PartialRequest _nomad_partial_request; + NomadNet::Url _nomad_partial_url; NomadNet::ResponseBuffer _nomad_response; NomadNet::PageHistory _nomad_history; NomadNet::PageHistory::PendingOpen _nomad_pending_history; @@ -447,7 +451,9 @@ private: RNS::Link _nomad_link{RNS::Type::NONE}; bool _nomad_link_identified = false; RNS::RequestReceipt _nomad_request{RNS::Type::NONE}; - enum class NomadState { IDLE, CACHE, LIVE_PENDING, PATH, LINK, REQUEST }; + enum class NomadState { + IDLE, CACHE, LIVE_PENDING, PARTIAL_PENDING, PATH, LINK, REQUEST + }; std::atomic _nomad_state{NomadState::IDLE}; uint32_t _nomad_deadline_ms = 0; int32_t _nomad_pending_scroll = -1; @@ -465,6 +471,14 @@ private: uint32_t nomad_advance_navigation_generation(); bool nomad_supersede_transport(const std::string& destination_hex); void nomad_begin_live_transport(); + void nomad_begin_partial_transport(); + void nomad_poll_partials(uint32_t now_ms); + void nomad_finish_partial(bool success, const char* status); + void nomad_defer_partial(const char* status, bool retain_link = true); + void nomad_release_partial(bool success, bool deferred, const char* status, + bool allow_retain_link); + const NomadNet::Url& nomad_transport_url() const; + bool nomad_schedule_partial_ids(const std::string& address, uint32_t now_ms); bool nomad_apply_page_bytes(const uint8_t* data, std::size_t size, bool cached); NomadNet::PageApplyResult nomad_apply_page_document( const NomadNet::Document& document, bool cached); diff --git a/tests/native/nomadnet_lvgl_acceptance/CMakeLists.txt b/tests/native/nomadnet_lvgl_acceptance/CMakeLists.txt index 924d237f..f481ecae 100644 --- a/tests/native/nomadnet_lvgl_acceptance/CMakeLists.txt +++ b/tests/native/nomadnet_lvgl_acceptance/CMakeLists.txt @@ -15,6 +15,8 @@ add_executable(nomadnet_lvgl_acceptance "${NN}/NomadNetDocument.cpp" "${NN}/NomadNetCompactPage.cpp" "${NN}/NomadNetForm.cpp" + "${NN}/NomadNetPartialController.cpp" + "${NN}/NomadNetPartialScheduler.cpp" "${NN}/NomadNetGlyphs.cpp" "${NN}/NomadNetLibrary.cpp" "${NN}/NomadNetScreen.cpp" @@ -31,7 +33,8 @@ target_include_directories(nomadnet_lvgl_acceptance PRIVATE "${CMAKE_CURRENT_LIST_DIR}/stubs" "${NN}" "${PYXIS_ROOT}/lib/tdeck_ui/UI" "${PYXIS_ROOT}/lib/tdeck_ui/UI/Fonts" "${LVGL_SOURCE}" "${LVGL_SOURCE}/src") -target_compile_definitions(nomadnet_lvgl_acceptance PRIVATE ARDUINO=1) +target_compile_definitions(nomadnet_lvgl_acceptance PRIVATE + PYXIS_NOMADNET_TEST_HOOKS=1 ARDUINO=1) target_compile_options(nomadnet_lvgl_acceptance PRIVATE -Wall -Wextra -fsanitize=address,undefined -fno-omit-frame-pointer) target_link_options(nomadnet_lvgl_acceptance PRIVATE -fsanitize=address,undefined diff --git a/tests/native/nomadnet_lvgl_acceptance/acceptance.cpp b/tests/native/nomadnet_lvgl_acceptance/acceptance.cpp index ab58182f..7e81637a 100644 --- a/tests/native/nomadnet_lvgl_acceptance/acceptance.cpp +++ b/tests/native/nomadnet_lvgl_acceptance/acceptance.cpp @@ -200,6 +200,11 @@ int main() { bool table_pixels = false, form_pixels = false, focus_pixels = false, glyph_pixels = false; bool background_pixels = false, teardown = false, cached_status_transient = false; bool cached_status_oom_collapses = false; + bool partial_replace = false, partial_forms = false, partial_empty = false; + bool partial_link_focus = false, partial_focus_fallback = false; + bool partial_scroll_anchor = false; + bool partial_second_scroll_rollback = false; + bool partial_region_top_fallback = false; int delete_events = 0; UI::LXMF::NomadNet::DocumentParser parser; @@ -360,6 +365,171 @@ int main() { teardown = teardown && home_called && lv_group_get_focused(group) == nullptr && lv_indev_get_obj_act() == nullptr && !group_contains(screen._content); } + { + UI::LXMF::NomadNetScreen screen; + screen.show(); + const auto page = parser.parse( + "`<24|username`Initial>\n" + "`{:/partial.mu`10`pid=clock|username}\nfooter"); + assert(screen.set_page(page)); + assert(screen._form_state.set_value(0, "User edit")); + UI::LXMF::NomadNet::PartialScheduler scheduler; + scheduler.configure(page, 7, 0); + UI::LXMF::NomadNet::PartialRequest request; + assert(scheduler.poll(0, true, true, request)); + UI::LXMF::NomadNet::PartialController controller; + controller.reset_page(page.source_bytes); + UI::LXMF::NomadNet::FormEncodeResult encode_result; + assert(screen.prepare_partial_request(request, controller, encode_result)); + const auto fragment = parser.parse( + "Updated `<24|username`Server>\n" + "`[Open`:/next.mu]\n" + "`tc80\nA|B\n---|---\nOne|`[Two`:/two]\n`t\n" + "`{:nested.mu}"); + assert(screen.apply_partial_fragment(request, fragment, controller) == + UI::LXMF::NomadNet::PartialReplaceResult::APPLIED); + partial_replace = screen._page.partials().size() == 1 && + screen._page.blocks().size() >= 4 && + screen._page.blocks()[1].partial_region_index == 0 && + screen._page.tables().size() == 1 && screen._page.links().size() == 2 && + screen.partial_id_matches(0, "clock", 5); + partial_forms = screen._form_state.fields().size() == 2 && + std::string(screen._form_state.fields()[0].value.data(), + screen._form_state.fields()[0].value_length) == "User edit"; + screen._selected_link = 0; + for (std::size_t index = 0; index < screen._focus_order.size(); ++index) { + if (!screen._focus_order[index].field && + screen._focus_order[index].index == 0) { + screen._selected_focus = static_cast(index); + break; + } + } + assert(screen.apply_partial_fragment(request, fragment, controller) == + UI::LXMF::NomadNet::PartialReplaceResult::APPLIED); + partial_link_focus = screen._selected_link == 0 && screen._selected_focus >= 0; + const auto empty_fragment = parser.parse(""); + assert(screen.apply_partial_fragment(request, empty_fragment, controller) == + UI::LXMF::NomadNet::PartialReplaceResult::APPLIED); + partial_empty = screen._page.partials().size() == 1 && + std::any_of(screen._page.blocks().begin(), screen._page.blocks().end(), + [](const auto& block) { return block.partial_region_index == 0; }); + + const auto focus_page = parser.parse("`{:/focus.mu`10}\n"); + assert(screen.set_page(focus_page)); + scheduler.configure(focus_page, 8, 0); + assert(scheduler.poll(0, true, true, request)); + controller.cancel(); + controller.reset_page(focus_page.source_bytes); + assert(screen.prepare_partial_request(request, controller, encode_result)); + assert(screen.apply_partial_fragment(request, parser.parse( + "`[First`:/first]\n`[Gone`:/gone]\n`[Third`:/third]"), + controller) == UI::LXMF::NomadNet::PartialReplaceResult::APPLIED); + for (std::size_t index = 0; index < screen._page.links().size(); ++index) { + const auto target = screen._page.target(index); + if (std::string(target.data(), target.size()) != ":/gone") continue; + screen._selected_link = static_cast(index); + for (std::size_t focus = 0; focus < screen._focus_order.size(); ++focus) + if (!screen._focus_order[focus].field && + screen._focus_order[focus].index == index) + screen._selected_focus = static_cast(focus); + } + assert(screen.apply_partial_fragment(request, parser.parse( + "`[First`:/first]\n`[Third`:/third]"), controller) == + UI::LXMF::NomadNet::PartialReplaceResult::APPLIED); + partial_focus_fallback = screen._selected_link >= 0 && + [&] { + const auto target = screen._page.target( + static_cast(screen._selected_link)); + return std::string(target.data(), target.size()) == ":/third"; + }(); + + std::string scroll_source = "`{:/scroll.mu`10}\n"; + for (int index = 0; index < 30; ++index) + scroll_source += "Base " + std::to_string(index) + "\n\n"; + const auto scroll_page = parser.parse(scroll_source); + assert(screen.set_page(scroll_page)); + scheduler.configure(scroll_page, 9, 0); + assert(scheduler.poll(0, true, true, request)); + controller.cancel(); + controller.reset_page(scroll_page.source_bytes); + assert(screen.prepare_partial_request(request, controller, encode_result)); + assert(screen.apply_partial_fragment(request, parser.parse("short"), controller) == + UI::LXMF::NomadNet::PartialReplaceResult::APPLIED); + assert(screen.scroll_to_logical(120, LV_ANIM_OFF)); + auto top_identity = [&]() { + int16_t region = -2; + std::size_t ordinal = 0; + const UI::LXMF::NomadNetScreen::LayoutCheckpoint* top = nullptr; + for (const auto& checkpoint : screen._layout_checkpoints) + if (checkpoint.y <= screen._logical_scroll && + (!top || checkpoint.y >= top->y)) top = &checkpoint; + if (!top || top->block_index >= screen._page.blocks().size()) + return std::pair{region, ordinal}; + region = screen._page.blocks()[top->block_index].partial_region_index; + for (std::size_t index = 0; index < top->block_index; ++index) + if (screen._page.blocks()[index].partial_region_index == region) ++ordinal; + return std::pair{region, ordinal}; + }; + const auto before_top = top_identity(); + std::string expanded; + for (int index = 0; index < 18; ++index) + expanded += "Expanded " + std::to_string(index) + "\n\n"; + assert(screen.apply_partial_fragment(request, parser.parse(expanded), controller) == + UI::LXMF::NomadNet::PartialReplaceResult::APPLIED); + partial_scroll_anchor = before_top == top_identity(); + const auto rollback_top = top_identity(); + const int32_t rollback_logical = screen._logical_scroll; + const int32_t rollback_widget = lv_obj_get_scroll_y(screen._content); + const std::size_t rollback_blocks = screen._page.blocks().size(); + screen._test_scroll_fail_countdown = 1; + const auto rollback_result = screen.apply_partial_fragment( + request, parser.parse("replacement\n\nwith another block"), controller); + partial_second_scroll_rollback = + rollback_result == UI::LXMF::NomadNet::PartialReplaceResult::ALLOCATION_FAILED && + screen._logical_scroll == rollback_logical && + lv_obj_get_scroll_y(screen._content) == rollback_widget && + screen._page.blocks().size() == rollback_blocks && + top_identity() == rollback_top; + + std::string tall_fragment; + for (int line = 0; line < 18; ++line) + tall_fragment += "partial " + std::to_string(line) + "\n\n"; + assert(screen.apply_partial_fragment( + request, parser.parse(tall_fragment), controller) == + UI::LXMF::NomadNet::PartialReplaceResult::APPLIED); + std::size_t partial_seen = 0; + int32_t removed_anchor_y = -1; + for (const auto& checkpoint : screen._layout_checkpoints) { + if (screen._page.blocks()[checkpoint.block_index].partial_region_index != 0) + continue; + if (partial_seen++ == 8) { + removed_anchor_y = checkpoint.y; + break; + } + } + assert(removed_anchor_y >= 0); + assert(screen.scroll_to_logical(removed_anchor_y + 1, LV_ANIM_OFF)); + assert(screen.apply_partial_fragment( + request, parser.parse("short replacement"), controller) == + UI::LXMF::NomadNet::PartialReplaceResult::APPLIED); + const auto region_fallback = top_identity(); + int32_t expected_region_top = 0; + for (const auto& checkpoint : screen._layout_checkpoints) { + if (screen._page.blocks()[checkpoint.block_index].partial_region_index == 0) { + expected_region_top = checkpoint.y; + break; + } + } + const int32_t viewport = std::max( + 1, lv_obj_get_content_height(screen._content)); + expected_region_top = std::min( + expected_region_top, std::max(0, screen._page_height - viewport)); + partial_region_top_fallback = + std::get<0>(region_fallback) == 0 && + std::get<1>(region_fallback) == 0 && + screen._logical_scroll == expected_region_top; + screen.hide(); + } pump(); const uint32_t remaining = lv_obj_get_child_cnt(lv_scr_act()) - baseline; teardown = teardown && remaining == 0 && lv_group_get_focused(group) == nullptr; @@ -372,16 +542,23 @@ int main() { "eight_column_tier=%d eight_column_preserved=%d eight_column_pixels=%d table_link_focus=%d eight_column_objects=%d " "focus_events=%d edge_scroll=%d ready=%d cancel=%d enter=%d escape=%d focus_restore=%d " "teardown=%d cached_status_transient=%d cached_status_oom_collapses=%d stale_group=%d background_pixels=%d table_pixels=%d form_pixels=%d " - "focus_pixels=%d glyph_pixels=%d exact_fonts=1 objects=%u\n", + "focus_pixels=%d glyph_pixels=%d partial_replace=%d partial_forms=%d partial_link_focus=%d partial_focus_fallback=%d partial_scroll_anchor=%d partial_second_scroll_rollback=%d partial_region_top_fallback=%d partial_empty=%d exact_fonts=1 objects=%u\n", fit_tier, fit_columns, reflow_tier, reflow_cards, eight_column_tier, eight_column_preserved, eight_column_pixels, table_link_focus, eight_column_objects, focus_events, edge_scroll, ready, cancel, enter, escape, focus_restore, teardown, cached_status_transient, cached_status_oom_collapses, 0, background_pixels, - table_pixels, form_pixels, focus_pixels, glyph_pixels, remaining); + table_pixels, form_pixels, focus_pixels, glyph_pixels, + partial_replace, partial_forms, partial_link_focus, partial_focus_fallback, + partial_scroll_anchor, partial_second_scroll_rollback, + partial_region_top_fallback, partial_empty, remaining); return fit_tier && fit_columns && reflow_tier && reflow_cards && eight_column_tier && eight_column_preserved && eight_column_pixels && table_link_focus && eight_column_objects && focus_events && edge_scroll && ready && cancel && enter && escape && focus_restore && teardown && cached_status_transient && cached_status_oom_collapses && background_pixels && - table_pixels && form_pixels && focus_pixels && glyph_pixels && remaining == 0 ? 0 : 1; + table_pixels && form_pixels && focus_pixels && glyph_pixels && + partial_replace && partial_forms && partial_link_focus && partial_focus_fallback && + partial_scroll_anchor && partial_second_scroll_rollback && + partial_region_top_fallback && partial_empty && + remaining == 0 ? 0 : 1; } diff --git a/tests/native/nomadnet_x86_flow/CMakeLists.txt b/tests/native/nomadnet_x86_flow/CMakeLists.txt index c1ff5367..8ced4a70 100644 --- a/tests/native/nomadnet_x86_flow/CMakeLists.txt +++ b/tests/native/nomadnet_x86_flow/CMakeLists.txt @@ -52,6 +52,8 @@ set(PYXIS_MANIFEST_FILES "lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h" "lib/tdeck_ui/UI/LXMF/NomadNetPartialScheduler.cpp" "lib/tdeck_ui/UI/LXMF/NomadNetPartialScheduler.h" + "lib/tdeck_ui/UI/LXMF/NomadNetPartialController.cpp" + "lib/tdeck_ui/UI/LXMF/NomadNetPartialController.h" "lib/tdeck_ui/UI/LXMF/NomadNetPartialHash.h" "lib/tdeck_ui/UI/LXMF/NomadNetGlyphs.cpp" "lib/tdeck_ui/UI/LXMF/NomadNetGlyphs.h" @@ -77,6 +79,7 @@ add_executable(pyxis_nomadnet_x86_flow "${PYXIS_NOMADNET_DIR}/NomadNetDocument.cpp" "${PYXIS_ROOT}/lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.cpp" "${PYXIS_ROOT}/lib/tdeck_ui/UI/LXMF/NomadNetPartialScheduler.cpp" + "${PYXIS_ROOT}/lib/tdeck_ui/UI/LXMF/NomadNetPartialController.cpp" "${PYXIS_ROOT}/lib/tdeck_ui/UI/LXMF/NomadNetForm.cpp" "${PYXIS_ROOT}/lib/tdeck_ui/UI/LXMF/NomadNetGlyphs.cpp" "${PYXIS_NOMADNET_DIR}/NomadNetLibrary.cpp" diff --git a/tests/native/nomadnet_x86_flow/client.cpp b/tests/native/nomadnet_x86_flow/client.cpp index 0123b2d9..682c2cab 100644 --- a/tests/native/nomadnet_x86_flow/client.cpp +++ b/tests/native/nomadnet_x86_flow/client.cpp @@ -12,6 +12,8 @@ #include "NomadNetLibrary.h" #include "NomadNetMailbox.h" #include "NomadNetOwner.h" +#include "NomadNetPartialController.h" +#include "NomadNetPartialScheduler.h" #include "NomadNetProtocol.h" #include "NomadNetUrl.h" #include "BuildManifest.h" @@ -64,6 +66,11 @@ static bool owner_back_restored = false; static bool owner_reload_reused = false; static NN::ExternalVector owner_first_request; static NN::OwnerController owner; +static NN::PartialScheduler partial_scheduler; +static NN::PartialController partial_controller; +static NN::PartialRequest partial_request; +static NN::CompactPage partial_page; +static bool partial_live = false; class ScreenSubmissionSource final : public NN::OwnerSubmissionSource { public: @@ -300,7 +307,26 @@ static void on_link_established(RNS::Link& established_link) { established_link.set_resource_started_callback(on_resource_started); std::string path; double timeout = 8.0; - if (scenario == "immediate") path = "/page/immediate.mu"; + if (scenario == "partial") { + const std::string source = "Before\n`{" + destination_hex + + ":/page/partial.mu}\nAfter"; + const NN::Document document = NN::DocumentParser().parse(source); + const auto nil = NN::no_form_request_data(); + if (document.malformed || document.partials.size() != 1 || + !partial_page.assign(document)) { + fail("partial base parse"); + return; + } + partial_scheduler.configure(partial_page, 1U, 0U); + partial_controller.reset_page(document.source_bytes); + if (!partial_scheduler.poll(0U, true, true, partial_request) || + !partial_controller.prepare(partial_request, partial_page, + nil.data(), nil.size())) { + fail("partial lease preparation"); + return; + } + path = "/page/partial.mu"; + } else if (scenario == "immediate") path = "/page/immediate.mu"; else if (scenario == "reuse") path = "/page/reuse-first.mu"; else if (scenario == "lan") path = "/page/index.mu"; else if (scenario == "resource") path = "/page/resource.mu"; @@ -334,7 +360,9 @@ static void on_link_established(RNS::Link& established_link) { receipt = established_link.request(RNS::Bytes(path), RNS::Bytes(request_data.data(), request_data.size()), on_response, on_failed, on_progress, timeout, - NN::AsyncMailbox::MAX_WIRE_BYTES); + scenario == "partial" + ? NN::PartialController::MAX_RESPONSE_WIRE_BYTES + : NN::AsyncMailbox::MAX_WIRE_BYTES); NN::clear_encoded_form(request_data); if (!receipt) { fail("request creation"); @@ -364,7 +392,9 @@ public: destination = RNS::Destination(identity, RNS::Type::Destination::OUT, RNS::Type::Destination::SINGLE, "nomadnetwork", "node"); - mailbox.prepare(); + mailbox.prepare(0, scenario == "partial" + ? NN::PartialController::MAX_RESPONSE_WIRE_BYTES + : NN::AsyncMailbox::MAX_WIRE_BYTES); active_link = RNS::Link(destination, on_link_established, on_link_closed); // Deliberately no link.identify(): ordinary Pyxis page retrieval is anonymous. } @@ -385,6 +415,46 @@ static void consume_event() { return; } const RNS::Bytes response(event.data.data(), event.data.size()); + if (scenario == "partial") { + NN::ResponseBuffer normalized; + NN::CompactPage candidate; + if (!partial_controller.active() || + !partial_controller.matches(partial_request) || + !NN::normalize_response(response.data(), response.size(), normalized) || + normalized.size() > NN::PartialController::MAX_RESPONSE_BYTES) { + fail("partial response ownership"); + return; + } + const std::string source( + reinterpret_cast(normalized.bytes().data()), + normalized.size()); + const NN::Document fragment = NN::DocumentParser().parse(source); + if (fragment.malformed || fragment.truncated || + !partial_controller.can_accept_fragment( + partial_request.partial_index, normalized.size()) || + candidate.assign_replacing_partial( + partial_page, partial_request.partial_index, fragment, + NN::PartialController::MAX_EXPANDED_SOURCE_BYTES) != + NN::PartialReplaceResult::APPLIED || + !partial_controller.commit_fragment( + partial_request.partial_index, normalized.size())) { + fail("partial transactional replacement"); + return; + } + partial_page = std::move(candidate); + std::string rendered; + for (const auto& run : partial_page.runs()) { + const auto text = partial_page.text(run); + rendered.append(text.data(), text.size()); + } + partial_live = rendered.find("Peer-refreshed fragment") != std::string::npos && + partial_scheduler.complete(partial_request, true, 1U); + partial_controller.abandon_request(); + passed = partial_live; + completed = true; + if (!passed) fail("partial rendered content"); + break; + } const bool expected_resource = scenario == "resource" || scenario == "near-limit" || (scenario == "reuse" && reuse_requests == 2); if (expected_resource && progress_callbacks == 0) { @@ -550,7 +620,7 @@ int main(int argc, char** argv) { return 0; } if (argc < 2) { - std::fprintf(stderr, "usage: %s immediate|resource|near-limit|oversized|timeout|cancel|reuse|form-anonymous|form-identified|owner-form-history | lan host port destination\n", argv[0]); + std::fprintf(stderr, "usage: %s immediate|resource|near-limit|oversized|timeout|cancel|reuse|form-anonymous|form-identified|owner-form-history|partial | lan host port destination\n", argv[0]); return 2; } scenario = argv[1]; @@ -558,7 +628,7 @@ int main(int argc, char** argv) { scenario != "oversized" && scenario != "timeout" && scenario != "cancel" && scenario != "reuse" && scenario != "form-anonymous" && scenario != "form-identified" && - scenario != "owner-form-history" && scenario != "lan") return 2; + scenario != "owner-form-history" && scenario != "partial" && scenario != "lan") return 2; if ((scenario == "lan" && argc != 5) || (scenario != "lan" && argc != 2)) return 2; microStore::FileSystem filesystem{microStore::Adapters::UniversalFileSystem(".")}; @@ -626,7 +696,7 @@ int main(int argc, char** argv) { std::printf("RESULT scenario=%s announce=%d path=%d link=%d request=%d progress=%d callbacks=%d " "cancel=%d deadline=%d resource_started=%d resource_progress=%d receipt_failed=%d " "pending=%zu link_closed=%d stale_rejected=%d reuse_requests=%d link_callbacks=%d " - "owner_submit=%d history_bytes=%d retained_link=%d back_restored=%d reload_reused=%d passed=%d\n", + "owner_submit=%d history_bytes=%d retained_link=%d back_restored=%d reload_reused=%d partial_live=%d passed=%d\n", scenario.c_str(), announce_seen ? 1 : 0, destination_hex.empty() ? 0 : 1, link_established ? 1 : 0, request_started ? 1 : 0, progress_seen ? 1 : 0, progress_callbacks, @@ -637,7 +707,7 @@ int main(int argc, char** argv) { link_closed ? 1 : 0, stale_callback_rejections, reuse_requests, link_callbacks, owner_submit ? 1 : 0, owner_history_bytes ? 1 : 0, owner_retained_link ? 1 : 0, owner_back_restored ? 1 : 0, - owner_reload_reused ? 1 : 0, passed ? 1 : 0); + owner_reload_reused ? 1 : 0, partial_live ? 1 : 0, passed ? 1 : 0); if (scenario == "lan") { std::printf("LAN TRANSPORT rx=%zu rxbytes=%zu tx=%zu txbytes=%zu\n", network_interface.rx(), network_interface.rxbytes(), diff --git a/tests/native/nomadnet_x86_flow/run_flow.py b/tests/native/nomadnet_x86_flow/run_flow.py index ef87ca4c..7635dab2 100644 --- a/tests/native/nomadnet_x86_flow/run_flow.py +++ b/tests/native/nomadnet_x86_flow/run_flow.py @@ -36,7 +36,7 @@ REFERENCE_FILES = { "2461a592b731cb1469bebb5ccc5f523892127881cb7a7e8ed586ac62a8c0c23a", } SCENARIOS = ("immediate", "resource", "near-limit", "oversized", "timeout", "cancel", "reuse", - "form-anonymous", "form-identified", "owner-form-history") + "form-anonymous", "form-identified", "owner-form-history", "partial") if os.environ.get("PYXIS_FLOW_SCENARIOS"): requested = tuple(item.strip() for item in os.environ["PYXIS_FLOW_SCENARIOS"].split(",") if item.strip()) if not requested or any(item not in SCENARIOS for item in requested): @@ -62,6 +62,8 @@ MANIFEST_FILES = ( "lib/tdeck_ui/UI/LXMF/NomadNetCompactPage.h", "lib/tdeck_ui/UI/LXMF/NomadNetPartialScheduler.cpp", "lib/tdeck_ui/UI/LXMF/NomadNetPartialScheduler.h", + "lib/tdeck_ui/UI/LXMF/NomadNetPartialController.cpp", + "lib/tdeck_ui/UI/LXMF/NomadNetPartialController.h", "lib/tdeck_ui/UI/LXMF/NomadNetPartialHash.h", "lib/tdeck_ui/UI/LXMF/NomadNetGlyphs.cpp", "lib/tdeck_ui/UI/LXMF/NomadNetGlyphs.h", @@ -273,6 +275,9 @@ for scenario in SCENARIOS: "history_bytes=1", "retained_link=1", "back_restored=1", "reload_reused=1", )) ok &= server_text.count("SERVER request count=") == 3 + if scenario == "partial": + ok &= "partial_live=1" in client_text + ok &= "path=/page/partial.mu" in server_text print(f"SCENARIO {scenario}: {'PASS' if ok else 'FAIL'} server={server_rc} client={client_rc}") failed |= not ok diff --git a/tests/native/nomadnet_x86_flow/server.py b/tests/native/nomadnet_x86_flow/server.py index 90efdf3e..01530527 100644 --- a/tests/native/nomadnet_x86_flow/server.py +++ b/tests/native/nomadnet_x86_flow/server.py @@ -60,6 +60,7 @@ PAGES = { "/page/reuse-first.mu": micron_page("Immediate page", 220), "/page/reuse-second.mu": micron_page("Resource-backed page", 12_000), "/page/form.mu": micron_page("Form response", 220), + "/page/partial.mu": b">Peer-refreshed fragment\n\nReal partial response.\n", } EXPECTED_FORM_DATA = { @@ -117,7 +118,7 @@ def write_config(config_dir: str): def main(): parser = argparse.ArgumentParser() - parser.add_argument("scenario", choices=("immediate", "resource", "near-limit", "oversized", "timeout", "cancel", "reuse", "form-anonymous", "form-identified", "owner-form-history")) + parser.add_argument("scenario", choices=("immediate", "resource", "near-limit", "oversized", "timeout", "cancel", "reuse", "form-anonymous", "form-identified", "owner-form-history", "partial")) parser.add_argument("--timeout", type=float, default=20.0) args = parser.parse_args() @@ -185,7 +186,7 @@ def main(): continue print("SERVER FAIL owner form/history request sequence", flush=True) return 1 - if args.scenario in ("immediate", "resource", "near-limit", "oversized") and state["request_seen"]: + if args.scenario in ("immediate", "resource", "near-limit", "oversized", "partial") and state["request_seen"]: time.sleep(1.0) print("SERVER PASS", flush=True) return 0 diff --git a/tests/native/test_app_launcher_nomadnet.py b/tests/native/test_app_launcher_nomadnet.py index 411fdb61..268e8fb9 100644 --- a/tests/native/test_app_launcher_nomadnet.py +++ b/tests/native/test_app_launcher_nomadnet.py @@ -163,7 +163,9 @@ def test_ui_wiring_contract(): failed = manager_cpp[manager_cpp.index("void UIManager::on_nomad_failed"): manager_cpp.index("void UIManager::on_nomad_progress")] assert "receipt.response_size()" in failed - assert "publish_oversized" in failed + assert "publish_failed" in failed + mailbox_h = (INCLUDE / "NomadNetMailbox.h").read_text() + assert "response_size > _max_wire_bytes" in mailbox_h assert "response_transfer_size()" in manager_cpp assert "DEPENDENCY HARDENING GAP" not in manager_cpp assert "lv_obj_set_scroll_dir" in browser_cpp @@ -292,7 +294,10 @@ def test_nomadnet_forms_are_bounded_virtualized_and_owner_submitted(): assert "NomadNet::AsyncMailbox::MAX_WIRE_BYTES, true" in request identify_link = manager[manager.index("void UIManager::nomad_identify_link_if_configured()"): manager.index("void UIManager::nomad_send_request()")] - assert "if (_nomad_library.node_identified(_nomad_url.destination_hex))" in identify_link + assert "if (_nomad_library.node_identified(nomad_transport_url().destination_hex))" in identify_link + transport_url = manager[manager.index("const NomadNet::Url& UIManager::nomad_transport_url() const"): + manager.index("bool UIManager::nomad_schedule_partial_ids")] + assert "_nomad_partial_controller.active() ? _nomad_partial_url : _nomad_url" in transport_url assert "if (_nomad_link_identified) return;" in identify_link assert "_nomad_link.identify(_router.identity())" in identify_link link_event = manager[manager.index("case NomadNet::AsyncMailbox::Kind::LINK_ESTABLISHED"): @@ -1081,7 +1086,8 @@ def test_nomadnet_same_destination_navigation_reuses_active_link(): response.index("const bool ordinary_nil")] assert "nomad_stop_transport();" in application_failure retained = response[response.index("_nomad_link.status() == Type::Link::ACTIVE"):] - assert "_nomad_destination_hash.toHex() == _nomad_url.destination_hex" in retained + assert "bytes_equal_lower_hex(" in retained + assert "_nomad_destination_hash, _nomad_url.destination_hex" in retained assert "nomad_stop_transport();" in retained diff --git a/tests/native/test_nomadnet_lvgl_acceptance.py b/tests/native/test_nomadnet_lvgl_acceptance.py index cafdd32c..18c2215a 100644 --- a/tests/native/test_nomadnet_lvgl_acceptance.py +++ b/tests/native/test_nomadnet_lvgl_acceptance.py @@ -43,5 +43,8 @@ def test_actual_nomadnet_screen_320x240_acceptance(tmp_path): assert "ready=1 cancel=1 enter=1 escape=1 focus_restore=1" in output assert "teardown=1 cached_status_transient=1 cached_status_oom_collapses=1 stale_group=0" in output assert "background_pixels=1 table_pixels=1 form_pixels=1 focus_pixels=1 glyph_pixels=1" in output + assert "partial_focus_fallback=1 partial_scroll_anchor=1" in output + assert "partial_second_scroll_rollback=1" in output + assert "partial_region_top_fallback=1" in output assert "exact_fonts=1" in output assert output.endswith("objects=0") diff --git a/tests/native/test_nomadnet_mailbox_oom.cpp b/tests/native/test_nomadnet_mailbox_oom.cpp index bed8d99a..f155c3e1 100644 --- a/tests/native/test_nomadnet_mailbox_oom.cpp +++ b/tests/native/test_nomadnet_mailbox_oom.cpp @@ -21,6 +21,25 @@ void operator delete(void* value) noexcept { std::free(value); } void operator delete(void* value, std::size_t) noexcept { std::free(value); } int main() { + { + AsyncMailbox bounded; + const std::vector request{9}; + const std::vector response(17, 0x41); + bounded.prepare(88, 16); + bounded.expect_request(request); + if (!bounded.publish_response( + request, response.data(), response.size(), response.size())) + return 1; + AsyncMailbox::Event oversized; + if (!bounded.take(oversized) || + oversized.kind != AsyncMailbox::Kind::OVERSIZED || + oversized.transfer_size != response.size() || + oversized.generation != 88 || !oversized.data.empty()) { + std::cerr << "owner-specific response bound was not enforced\n"; + return 1; + } + } + AsyncMailbox mailbox; const std::vector link{1}; const std::vector request{2}; diff --git a/tests/native/test_nomadnet_partial_core.cpp b/tests/native/test_nomadnet_partial_core.cpp index 7c3ec4f1..0f44bb95 100644 --- a/tests/native/test_nomadnet_partial_core.cpp +++ b/tests/native/test_nomadnet_partial_core.cpp @@ -5,12 +5,19 @@ #include "NomadNetCompactPage.h" #include "NomadNetDocument.h" +#include "NomadNetForm.h" +#include "NomadNetPartialController.h" #include "NomadNetPartialScheduler.h" using UI::LXMF::NomadNet::BlockType; using UI::LXMF::NomadNet::CompactPage; using UI::LXMF::NomadNet::DocumentParser; +using UI::LXMF::NomadNet::ExternalVector; +using UI::LXMF::NomadNet::FormEncodeResult; +using UI::LXMF::NomadNet::FormState; +using UI::LXMF::NomadNet::PartialReplaceResult; using UI::LXMF::NomadNet::PartialRequest; +using UI::LXMF::NomadNet::PartialController; using UI::LXMF::NomadNet::PartialScheduler; using UI::LXMF::NomadNet::TruncationReason; @@ -220,6 +227,205 @@ int main() { compact.partial_field(partial, 1) == "user_name"); } + const auto replacement_source = parser.parse( + "before `\n" + "`{:fragment.mu`10`name}\n" + "after"); + CompactPage replacement_base; + check("partial replacement fixture compacts", replacement_base.assign(replacement_source)); + FormState edited_form; + check("partial replacement form fixture assigns", + edited_form.assign(replacement_base) && edited_form.set_value(0, "user value")); + const auto fragment = parser.parse("updated\n`{:nested.mu}\nmore"); + CompactPage replacement_candidate; + const auto replace_result = replacement_candidate.assign_replacing_partial( + replacement_base, 0, fragment, CompactPage::MAX_ARENA_BYTES); + check("partial fragment transaction replaces exactly one stable region", + replace_result == PartialReplaceResult::APPLIED && + replacement_candidate.blocks().size() == 5 && + replacement_candidate.blocks()[1].partial_region_index == 0 && + replacement_candidate.blocks()[2].partial_region_index == 0 && + replacement_candidate.blocks()[3].partial_region_index == 0); + check("nested fragment descriptors remain inert and root identity remains stable", + replacement_candidate.partials().size() == 1 && + replacement_candidate.blocks()[2].type == BlockType::PARTIAL && + replacement_candidate.blocks()[2].partial_index == -1 && + replacement_candidate.partials()[0].descriptor_hash == + replacement_base.partials()[0].descriptor_hash); + FormState preserved_form; + check("unrelated partial replacement preserves user-entered field state", + preserved_form.assign_preserving(replacement_candidate, edited_form) && + preserved_form.fields().size() == 1 && + std::string(preserved_form.fields()[0].value.data(), + preserved_form.fields()[0].value_length) == "user value"); + const auto region_source = parser.parse( + "base `\n" + "`{:region.mu}\n" + "after `"); + CompactPage region_base; + FormState region_form; + check("source-region identity fixture assigns", + region_base.assign(region_source) && region_form.assign(region_base) && + region_form.set_value(0, "base-edit") && + region_form.set_value(1, "after-edit")); + const auto region_fragment = parser.parse("peer `"); + CompactPage region_candidate; + FormState region_preserved; + check("partial fields cannot steal values from matching base fields", + region_candidate.assign_replacing_partial( + region_base, 0, region_fragment, CompactPage::MAX_ARENA_BYTES) == + PartialReplaceResult::APPLIED && + region_preserved.assign_preserving(region_candidate, region_form) && + region_preserved.fields().size() == 3 && + std::string(region_preserved.fields()[0].value.data(), + region_preserved.fields()[0].value_length) == "base-edit" && + std::string(region_preserved.fields()[1].value.data(), + region_preserved.fields()[1].value_length) == "peer-default" && + std::string(region_preserved.fields()[2].value.data(), + region_preserved.fields()[2].value_length) == "after-edit"); + const auto radio_base_document = parser.parse("`{:radio.mu}"); + CompactPage radio_base; + const auto radio_first_fragment = parser.parse( + "`<^|choice|a|*`A> `<^|choice|b`B>"); + CompactPage radio_first; + FormState radio_state; + check("radio replacement fixture selects second option", + radio_base.assign(radio_base_document) && + radio_first.assign_replacing_partial( + radio_base, 0, radio_first_fragment, + CompactPage::MAX_ARENA_BYTES) == PartialReplaceResult::APPLIED && + radio_state.assign(radio_first) && radio_state.set_checked(1, true)); + const auto radio_second_fragment = parser.parse("`<^|choice|b`B>"); + CompactPage radio_second; + FormState radio_preserved; + check("surviving radio option retains identity when a peer disappears", + radio_second.assign_replacing_partial( + radio_first, 0, radio_second_fragment, + CompactPage::MAX_ARENA_BYTES) == PartialReplaceResult::APPLIED && + radio_preserved.assign_preserving(radio_second, radio_state) && + radio_preserved.fields().size() == 1 && + radio_preserved.fields()[0].checked); + const auto radio_fallback_fragment = parser.parse("`<^|choice|b|*`B>"); + CompactPage radio_fallback_page; + FormState radio_default_state; + check("canonical radio fallback is selected when the old key disappears", + radio_default_state.assign(radio_first) && + radio_fallback_page.assign_replacing_partial( + radio_base, 0, radio_fallback_fragment, + CompactPage::MAX_ARENA_BYTES) == PartialReplaceResult::APPLIED && + radio_preserved.assign_preserving(radio_fallback_page, radio_default_state) && + radio_preserved.fields().size() == 1 && + radio_preserved.fields()[0].checked); + const auto radio_added_default_fragment = parser.parse( + "`<^|choice|b`B> `<^|choice|c|*`C>"); + CompactPage radio_added_default_page; + check("a surviving radio selection clears a newly canonical peer", + radio_added_default_page.assign_replacing_partial( + radio_base, 0, radio_added_default_fragment, + CompactPage::MAX_ARENA_BYTES) == PartialReplaceResult::APPLIED && + radio_preserved.assign_preserving(radio_added_default_page, radio_state) && + radio_preserved.fields().size() == 2 && + radio_preserved.fields()[0].checked && + !radio_preserved.fields()[1].checked && + std::string(radio_preserved.fields()[0].value.data(), + radio_preserved.fields()[0].value_length) == "b"); + const auto cross_region_radio_document = parser.parse( + "`<^|choice|base|*`Base>\n`{:radio.mu}"); + CompactPage cross_region_radio_base; + CompactPage cross_region_radio_page; + FormState cross_region_radio_state; + const auto cross_region_radio_fragment = parser.parse( + "`<^|choice|fragment|*`Fragment>"); + check("same-name radios across source regions remain one submission group", + cross_region_radio_base.assign(cross_region_radio_document) && + cross_region_radio_page.assign_replacing_partial( + cross_region_radio_base, 0, cross_region_radio_fragment, + CompactPage::MAX_ARENA_BYTES) == PartialReplaceResult::APPLIED && + cross_region_radio_state.assign(cross_region_radio_page) && + cross_region_radio_state.fields().size() == 2 && + !cross_region_radio_state.fields()[0].checked && + cross_region_radio_state.fields()[1].checked && + cross_region_radio_state.set_checked(0, true) && + cross_region_radio_state.fields()[0].checked && + !cross_region_radio_state.fields()[1].checked); + const auto global_fallback_old_fragment = parser.parse( + "`<^|choice|old`Old>"); + const auto global_fallback_new_fragment = parser.parse( + "`<^|choice|new|*`New>"); + CompactPage global_fallback_old_page; + CompactPage global_fallback_new_page; + FormState global_fallback_old_state; + FormState global_fallback_preserved; + ExternalVector global_fallback_encoded; + static constexpr uint8_t EXPECTED_GLOBAL_FALLBACK[] = { + 0x81, 0xac, 'f', 'i', 'e', 'l', 'd', '_', + 'c', 'h', 'o', 'i', 'c', 'e', 0xa3, 'n', 'e', 'w' + }; + check("removed cross-region selection retains one canonical fallback", + global_fallback_old_page.assign_replacing_partial( + cross_region_radio_base, 0, global_fallback_old_fragment, + CompactPage::MAX_ARENA_BYTES) == PartialReplaceResult::APPLIED && + global_fallback_old_state.assign(global_fallback_old_page) && + global_fallback_old_state.set_checked(1, true) && + global_fallback_new_page.assign_replacing_partial( + cross_region_radio_base, 0, global_fallback_new_fragment, + CompactPage::MAX_ARENA_BYTES) == PartialReplaceResult::APPLIED && + global_fallback_preserved.assign_preserving( + global_fallback_new_page, global_fallback_old_state) && + global_fallback_preserved.fields().size() == 2 && + !global_fallback_preserved.fields()[0].checked && + global_fallback_preserved.fields()[1].checked && + global_fallback_preserved.encode( + std::string{"*"}, global_fallback_encoded) == FormEncodeResult::OK && + global_fallback_encoded.size() == sizeof(EXPECTED_GLOBAL_FALLBACK) && + std::memcmp(global_fallback_encoded.data(), EXPECTED_GLOBAL_FALLBACK, + sizeof(EXPECTED_GLOBAL_FALLBACK)) == 0); + const auto duplicate_radio_document = parser.parse("`{:dup.mu}"); + const auto duplicate_radio_fragment = parser.parse( + "`<^|dup|same`First> `<^|dup|same`Second>"); + CompactPage duplicate_radio_base; + CompactPage duplicate_radio_page; + CompactPage duplicate_radio_replaced; + FormState duplicate_radio_state; + FormState duplicate_radio_preserved; + check("duplicate-value radio selection preserves its occurrence", + duplicate_radio_base.assign(duplicate_radio_document) && + duplicate_radio_page.assign_replacing_partial( + duplicate_radio_base, 0, duplicate_radio_fragment, + CompactPage::MAX_ARENA_BYTES) == PartialReplaceResult::APPLIED && + duplicate_radio_state.assign(duplicate_radio_page) && + duplicate_radio_state.set_checked(1, true) && + duplicate_radio_replaced.assign_replacing_partial( + duplicate_radio_base, 0, duplicate_radio_fragment, + CompactPage::MAX_ARENA_BYTES) == PartialReplaceResult::APPLIED && + duplicate_radio_preserved.assign_preserving( + duplicate_radio_replaced, duplicate_radio_state) && + duplicate_radio_preserved.fields().size() == 2 && + !duplicate_radio_preserved.fields()[0].checked && + duplicate_radio_preserved.fields()[1].checked); + const auto notice_secret_document = parser.parse( + "`<8!|password`PW_SECRET_7391>\n`[Link`:/target]"); + CompactPage notice_secret_page; + check("notice fixture with sensitive arena data compacts", + notice_secret_page.assign(notice_secret_document)); + const char* sensitive_arena_address = notice_secret_page.field_value(0).data(); + check("notice publication cannot reallocate an arena containing password bytes", + notice_secret_page.append_notice(std::string(96, 'N')) && + notice_secret_page.field_value(0).data() == sensitive_arena_address); + CompactPage repeated_candidate; + const auto shorter_fragment = parser.parse("short"); + check("repeated replacement removes the prior fragment without accumulation", + repeated_candidate.assign_replacing_partial( + replacement_candidate, 0, shorter_fragment, + CompactPage::MAX_ARENA_BYTES) == PartialReplaceResult::APPLIED && + repeated_candidate.blocks().size() == 3 && + repeated_candidate.blocks()[1].partial_region_index == 0); + CompactPage rejected_candidate; + check("unknown partial occurrence fails closed", + rejected_candidate.assign_replacing_partial( + replacement_base, 1, fragment, CompactPage::MAX_ARENA_BYTES) == + PartialReplaceResult::INVALID_PARTIAL && rejected_candidate.empty()); + const auto scheduled_document = parser.parse( "`{:first.mu`10}\n`{:second.mu`20}"); CompactPage scheduled_page; @@ -278,13 +484,19 @@ int main() { PartialRequest retry{}; check("one-shot partial starts immediately", scheduler.poll(0U, true, true, retry)); const uint32_t first_partial_generation = retry.partial_generation; + const uint32_t rejected_token = retry.request_token; + check("temporary owner admission rejection defers without terminal failure", + scheduler.defer(retry) && scheduler.poll(1U, true, true, retry) && + retry.request_token != rejected_token && + retry.partial_generation != first_partial_generation); + const uint32_t admitted_partial_generation = retry.partial_generation; check("failure is contained", scheduler.complete(retry, false, 1U)); check("one-shot transfer failure has no automatic retry", !scheduler.poll(0xffffffffU, true, true, retry)); check("manual p-link style retry rearms the exact occurrence", scheduler.request_now(0U, 9U, 5000U) && scheduler.poll(5000U, true, true, retry) && - retry.partial_generation != first_partial_generation); + retry.partial_generation != admitted_partial_generation); check("mismatched partial generation is rejected", [&] { PartialRequest stale = retry; --stale.partial_generation; @@ -297,6 +509,49 @@ int main() { }()); check("manual retry completion remains valid", scheduler.complete(retry, true, 5001U)); + PartialController controller; + controller.reset_page(retry_document.source_bytes); + const uint8_t empty_map = 0x80; + check("transfer lease retains exact bounded descriptor material", + controller.prepare(retry, retry_page, &empty_map, 1) && + controller.active() && controller.matches(retry, retry_page) && + std::string(controller.descriptor_data(), controller.descriptor_size()) == + ":once.mu" && + std::string(controller.url_data(), controller.url_size()) == ":once.mu" && + controller.request_size() == 1 && controller.request_data()[0] == empty_map); + const auto colliding_document = parser.parse("`{:other.mu}"); + CompactPage colliding_page; + check("digest collision fixture compacts", colliding_page.assign(colliding_document)); + if (!colliding_page.partials().empty()) { + auto& mutable_partial = const_cast( + colliding_page.partials()[0]); + mutable_partial.descriptor_hash = retry.descriptor_hash; + } + check("completion authority requires exact descriptor bytes as well as digest", + !controller.matches(retry, colliding_page)); + PartialRequest wrong_lease = retry; + wrong_lease.descriptor_hash[0] ^= 0x01U; + check("transfer lease rejects digest-only or stale completion", + !controller.matches(wrong_lease)); + check("aggregate fragment accounting permits bounded replacement", + controller.can_accept_fragment(retry.partial_index, 1024) && + controller.commit_fragment(retry.partial_index, 1024) && + controller.active() && + controller.expanded_source_bytes() == retry_document.source_bytes + 1024); + controller.abandon_request(); + controller.reset_page(DocumentParser::MAX_DOCUMENT_BYTES); + check("expanded page cap rejects fragment growth deterministically", [&] { + scheduler.configure(retry_page, 11U, 0U); + PartialRequest capped_request; + return scheduler.poll(0U, true, true, capped_request) && + controller.prepare(capped_request, retry_page, &empty_map, 1) && + !controller.can_accept_fragment(0, 1); + }()); + controller.cancel(); + check("lease cancellation wipes and revokes request material", + !controller.active() && controller.request_size() == 0 && + controller.descriptor_size() == 0); + const auto wrap_document = parser.parse("`{:wrap.mu`5}"); CompactPage wrap_page; check("wrap fixture compacts", wrap_page.assign(wrap_document)); diff --git a/tests/native/test_nomadnet_partial_core.py b/tests/native/test_nomadnet_partial_core.py index 5b113c05..73cc1a7f 100644 --- a/tests/native/test_nomadnet_partial_core.py +++ b/tests/native/test_nomadnet_partial_core.py @@ -25,6 +25,8 @@ def test_nomadnet_partial_core_native(tmp_path): str(INCLUDE / "NomadNetDocument.cpp"), str(INCLUDE / "NomadNetCompactPage.cpp"), str(INCLUDE / "NomadNetGlyphs.cpp"), + str(INCLUDE / "NomadNetForm.cpp"), + str(INCLUDE / "NomadNetPartialController.cpp"), str(INCLUDE / "NomadNetPartialScheduler.cpp"), "-o", str(binary), ] @@ -35,23 +37,63 @@ def test_nomadnet_partial_core_native(tmp_path): assert result.stdout.strip() == "partial core parser checks passed" -def test_partial_scheduler_is_generation_owned_but_transport_dispatch_is_deferred(): +def test_partial_transport_is_generation_owned_bounded_and_non_cacheable(): manager_h = (INCLUDE / "UIManager.h").read_text() manager_cpp = (INCLUDE / "UIManager.cpp").read_text() + controller_h = (INCLUDE / "NomadNetPartialController.h").read_text() assert "NomadNet::PartialScheduler _nomad_partial_scheduler;" in manager_h + assert "NomadNet::PartialController _nomad_partial_controller;" in manager_h advance = manager_cpp.split( "uint32_t UIManager::nomad_advance_navigation_generation() {", 1 )[1].split("\n}", 1)[0] assert "_nomad_partial_scheduler.cancel(_nomad_navigation_generation);" in advance + assert "_nomad_partial_controller.cancel();" in advance publication = manager_cpp.split( "NomadNet::PageApplyResult UIManager::nomad_apply_page_document(", 1 )[1].split("void UIManager::nomad_update()", 1)[0] assert publication.index("result != NomadNet::PageApplyResult::APPLIED") < publication.index( "_nomad_partial_scheduler.configure(" ) - # This increment owns only the bounded model and scheduler. Partial transport, - # response replacement, and form-map integration belong to the next increment. - assert "_nomad_partial_scheduler.poll(" not in manager_cpp + assert "_nomad_partial_controller.reset_page(document.source_bytes);" in publication + assert "_nomad_partial_scheduler.poll(" in manager_cpp + assert "prepare_partial_request(" in manager_cpp + assert "apply_partial_fragment(" in manager_cpp + assert "MAX_RESPONSE_BYTES = 16 * 1024" in controller_h + assert "MAX_EXPANDED_SOURCE_BYTES" in controller_h + start_link = manager_cpp.split("void UIManager::nomad_start_link() {", 1)[1].split( + "void UIManager::nomad_identify_link_if_configured()", 1 + )[0] + assert "_nomad_partial_controller.active()" in start_link + assert "PartialController::MAX_RESPONSE_WIRE_BYTES" in start_link + prepare_failure = manager_cpp.split("if (!prepared) {", 1)[1].split("return;", 1)[0] + assert "_nomad_partial_scheduler.defer(request);" in prepare_failure + assert "_nomad_partial_scheduler.complete(request, false" not in prepare_failure + url_parse = manager_cpp.split("NomadNet::Url target;", 1)[1].split( + "if (!nomad_supersede_transport", 1 + )[0] + assert "allocation_failed = true;" in url_parse + assert "_nomad_partial_scheduler.defer(request);" in url_parse + request_function = manager_cpp.split("void UIManager::nomad_send_request()", 1)[1] + request_oom = request_function.split("} catch (const std::bad_alloc&) {", 1)[1].split( + "return;", 1 + )[0] + assert "nomad_defer_partial(" in request_oom + begin_partial = manager_cpp.split( + "void UIManager::nomad_begin_partial_transport()", 1 + )[1].split("void UIManager::nomad_finish_partial", 1)[0] + assert "catch (const std::bad_alloc&)" in begin_partial + assert "nomad_defer_partial(" in begin_partial + assert "bytes_equal_lower_hex(" in begin_partial + release_partial = manager_cpp.split( + "void UIManager::nomad_release_partial", 1 + )[1].split("void UIManager::nomad_begin_live_transport", 1)[0] + assert "if (success) nomad_finish_request_keep_link();" in release_partial + assert "nomad_release_request();" in release_partial + response_branch = manager_cpp.split( + "if (_nomad_partial_controller.active()) {", 6 + )[-1].split("NomadNet::Document document;", 1)[0] + assert "commit_fragment(" in response_branch + assert "_nomad_cache_pending_body" not in response_branch local_jump = manager_cpp.split( "if (local_result == NomadNet::LocalNavigationResult::APPLIED)", 1 )[1].split("return;", 1)[0] diff --git a/tests/native/test_nomadnet_x86_flow.py b/tests/native/test_nomadnet_x86_flow.py index 4b4577aa..e5c08999 100644 --- a/tests/native/test_nomadnet_x86_flow.py +++ b/tests/native/test_nomadnet_x86_flow.py @@ -38,11 +38,11 @@ def test_nomadnet_x86_real_peer_flow(): scenario_records = re.findall( r"^SCENARIO ([a-z-]+): (PASS|FAIL) server=(-?\d+) client=(-?\d+)$", result.stdout, flags=re.MULTILINE) - assert len(scenario_records) == 10 - assert len({record[0] for record in scenario_records}) == 10 + assert len(scenario_records) == 11 + assert len({record[0] for record in scenario_records}) == 11 assert all(record[1:] == ("PASS", "0", "0") for record in scenario_records) result_records = re.findall(r"^RESULT ([^\n]+)$", result.stdout, flags=re.MULTILINE) - assert len(result_records) == 10 + assert len(result_records) == 11 parsed_results = {} for record in result_records: fields = record.split() @@ -63,6 +63,8 @@ def test_nomadnet_x86_real_peer_flow(): assert "SCENARIO form-anonymous: PASS server=0 client=0" in result.stdout assert "SCENARIO form-identified: PASS server=0 client=0" in result.stdout assert "SCENARIO owner-form-history: PASS server=0 client=0" in result.stdout + assert "SCENARIO partial: PASS server=0 client=0" in result.stdout + assert "partial_live=1" in result.stdout owner = parsed_results["owner-form-history"] assert owner["owner_submit"] == "1" assert owner["history_bytes"] == "1"