Compare commits

...

9 Commits

Author SHA1 Message Date
tei
6b48f905d2 release: finalize unified particle.19
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-10-07 16:31:36 +09:00
tei
7c5ed27b0b release: prepare unified particle.19 2026-10-07 08:42:32 +09:00
tei
01863569fb docs: close STEP-101 teardown validation
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-10-07 08:42:09 +09:00
tei
64cf8f4435 fix: make render orchestrator teardown safe
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-10-07 08:41:08 +09:00
tei
d29e84c1a9 release: finalize unified particle.18
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-10-06 20:52:00 +09:00
tei
2780550b62 release: prepare unified particle.18 2026-10-06 17:20:34 +09:00
tei
524aaf4af5 fix: localize retained segment source invalidation
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-10-06 17:18:09 +09:00
tei
c4b481fd2c docs: close STEP-099 render source diagnostics
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-10-06 15:33:12 +09:00
tei
8a5214d92d diagnostics: add STEP-099 render source readiness audit 2026-10-06 15:15:12 +09:00
41 changed files with 4884 additions and 104 deletions

View File

@@ -0,0 +1,690 @@
# STEP-100 Source → RenderSegment 局部 Revision / Invalidation / Readiness 与独立 Fallback — Codex 任务 v2
## 0. 任务性质
这是 STEP-099 之后的正式修复任务。
STEP-099 已经完成纯诊断并 CLOSED / PASS,已确认 V17 的核心问题:
1. 动态 GeoJSON Source/Tile mutation 会推进 global retained content revision;
2. 即使 R0 不依赖该 GeoJSON,R0 retained cache 仍跟着 invalid/build;
3. GeoJSON 更新期间约 200 ms 的 transient `isLoaded=false` 会通过 global `RenderOrchestrator::isLoaded()` 把整个 retained path 拉成 `style-not-loaded`;
4. 当前 renderer 还存在 `useRetainedRanges` all-or-none gate:一个 RETAINED Segment fallback 时,会使其他本来可安全 reuse 的 RETAINED Segment 一起退出 retained path;
5. STEP-099 diagnostics 已能正确给 source mutation 计算 `affectedSegmentIndices`,但该依赖关系仍只是诊断,不参与正式 invalidation。
本任务目标是把 **Source/Tile 变化对 RETAINED Segment 的影响正式局部化**。
本任务不是 V18 发布任务。Codex 只负责实现与源码级说明;Android/iOS 构建、APK、ADB、设备测试、最终 Git push、V18 release 由 RDC/ChatGPT 在 Codex 完成后独立执行。
未经用户明确要求,本任务 **不加入、不运行 Jest**。
---
## 1. 冻结基线
工作目录:
`/Users/tei/dev/MapLibreParticleLab-step100`
分支:
`step100-segment-local-source-invalidation`
开始基线必须重新核对,当前预期:
- `origin/particle/main`
- HEAD:`c4b481fd2c5b5da20d31f6ae54011f0552d7dbd0`
- STEP-099 implementation:`8a5214d92dd2f8899db76d2194da42999fa0c87d`
- V17 frozen tag:`maplibre-particle/rn/13.4.1-particle.17`
- V17 frozen commit:`e8b451fe70054107773e82484c80e90df3889147`
禁止修改、移动、覆盖 V17 tag / release worktree。
STEP-100 是 V17 之后的新开发,最终只有 RDC 验收通过后才允许进入 V18 发布。
开始前必须读取:
- 当前仓库实际 HEAD / status / diff;
- 全局 Codex AGENTS;
- `ARCHITECTURE.md`(若存在);
- STEP-092 / 094 / 099 的任务、Step 记录、完成报告;
- 当前:
- `renderer_impl.cpp`
- `render_orchestrator.*`
- `retained_underlay_cache.*`
- `retained_underlay_runtime_state.hpp`
- `render_segment.*`
- STEP-099 diagnostics 与 Android fixture;
- 当前 RenderSegment / retained cache / particle fast path tests。
不得凭历史印象修改。
---
## 2. 最终架构原则
长期 RenderSegment 模型继续保持通用模型:
```text
Style Z-order
↓
R / C / R / C / R / ...
```
其中:
- R = RETAINED
- C = CONTINUOUS
本任务只用 **R-C-R** 做最小验证,不把 R-C-R 固化成最终架构。
必须保持:
1. Style layer 顺序是唯一 Z-order 真相;
2. Renderer 不理解 AIS / Route / Weather / GeoJSON 等业务名;
3. Source → Segment 依赖由当前实际 Style / RenderLayer / Source 关系推导;
4. 一个 Source 可依赖多个 Segment;
5. source-less layer 不制造假 dependency;
6. unknown/global dependency 必须 fail closed;
7. 相邻 Segment、多个 C、多个 R 的通用模型不能被破坏;
8. 本任务不实现 multi-continuous Fast Path。
---
## 3. 本任务必须实现的核心目标
### 3.1 Source/Tile mutation 局部化
假设:
```text
R0 depends [staticSource]
C depends [particleField]
R1 depends [dynamicGeoJson]
```
当:
```text
dynamicGeoJson mutation
```
正式 renderer 必须达到:
```text
R0:
content/revision 不因该 source mutation 改变
cache 不 invalid
cache 不 rebuild
持续 reuse
R1:
revision/invalidation 正常变化
按需要 build/reuse/fallback
C:
正常 continuous render
```
禁止继续出现:
```text
dynamicGeoJson mutation
↓
global revision 变化
↓
R0 + R1 一起 rebuild
```
### 3.2 保留 global revision 兼容信号
STEP-100 不要求删除现有 global `retainedUnderlayContentRevision`。
为了保持:
- diagnostics;
- 现有 fast-path correctness;
- 旧 API / compatibility;
- conservative fallback;
允许并推荐继续维护 global revision。
但 **RETAINED Segment cache 的正式内容 revision / invalidation 不能再只依赖这个 global source/tile revision**。
当前 `retainedUnderlayRevisionTracker` 同时消费 global content revision 与 `renderTreeParameters.loaded`。STEP-100 必须明确拆成两种用途:
```text
global retained revision / tracker
-> compatibility / Fast Path / diagnostics
segment-local retained content state
-> full CPU renderer 的每个 RETAINED Segment Build / Reuse / Fallback
```
**Segment-local cache content state 禁止继续直接消费:**
- global source/tile content revision;
- global `renderTreeParameters.loaded`。
否则即使 source-scoped revision 已实现,R0 仍会被无关 GeoJSON transient unloaded 或 global revision 连带失效。
允许 global revision / global tracker继续按原逻辑变化;禁止为了让测试 PASS 而停止 global revision 增长或放宽 Fast Path。
### 3.3 per-RETAINED-Segment readiness
不得修改任何 Source `isLoaded()` 定义。
不得修改:
```cpp
renderTreeParameters->loaded = updateParameters->styleLoaded && isLoaded();
```
这个 global loaded 仍作为 MapLibre 现有全局兼容事实。
但 retained segment 的 cache Build/Reuse/Fallback 必须有自己的安全 readiness 判断。
最低语义:
- Style 本身未 ready:fail closed;
- image manager / sprite / glyph 等无法安全定位到单 Segment 的全局前置条件未 ready:fail closed;
- Segment 已知依赖的 source 中任一缺失或 `isLoaded()==false`:仅该 Segment fail closed;
- Segment 已知依赖的 source `hasFadingTiles()==true`:仅该 Segment视为 unstable;
- **不被该 Segment 引用的 source unloaded 或 fading,不得让该 Segment cache fallback/rebuild**;
- `retainedRangeIsStable(begin,end)` 的 source fading 检查必须按该 range 的真实 source dependency 缩小,不能继续遍历全部 `renderSources` 作为 full-CPU retained gate;
- Fast Path 原有 global fading gate 保持不变;
- source ID 无法解析、dependency 缺失或真正无法证明安全时 fail closed。
必须区分:
1. **已知 Source dependency**;
2. **symbol/global prerequisite**(glyph/image/placement 等);
3. **真正 unknown dependency**。
STEP-099 的 `unknownDependencies` 目前会把 symbol 相关条件标为 unknown。STEP-100 不得机械地把“存在 symbol”解释成永久 fallback;symbol 所需 glyph/image/placement 应作为明确的 global prerequisite/fail-closed 条件处理。
因此 T3 中一个:
```text
no layer
enabled=false
isLoaded=false
```
的 RasterArray source 可以继续让 global `RenderOrchestrator::isLoaded()==false`,但不得因此让与它无依赖关系的 R0/R1 cache 全部 `style-not-loaded`。
本 Step 的运行态硬验收范围是 **MapMode::Continuous**(粒子正式实时渲染模式)。当前非 Continuous / still 路径如果在 global unloaded 时提前返回,本 Step 不强行改变其兼容语义;完成报告必须明确该适用范围。
### 3.4 RETAINED Segment 独立决策
当前 V17 路径存在类似:
```text
useRetainedRanges = all retained segments supported
```
的 all-or-none 语义。
STEP-100 必须改为每个 RETAINED Segment 独立拥有:
- readiness/support decision;
- Build / Reuse / Fallback;
- content/source revision;
- invalidation reason。
如果:
```text
R0 = Reuse
R1 = Fallback
```
则本帧必须允许:
```text
R0 composite cached texture
C live render
R1 live render fallback
```
而不是退化为“整个 Style 全部 live render”。
如果某 retained cache 自身 build 失败,应优先只 fallback 该 Segment;只有无法证明局部安全的 context/global resource failure 才允许全 retained fail closed。
---
## 4. Source-scoped revision 设计要求
Codex 必须基于当前代码选择最小、稳定设计,并在完成报告说明实际方案。
推荐方向但不强制具体类名:
- 为 Source/Tile mutation 维护 source-scoped monotonic revision;
- keyed by stable sourceId,但必须处理 source 删除后同 ID recreate,不能让新 source 错误继承可复用的旧 cache identity;可用 generation/epoch 或保留单调 tombstone 语义实现;
- source diff、feature-state mutation、tile changed 等所有当前会进入 Source/Tile retained content invalidation 的正式路径都必须覆盖;
- 能定位 sourceId 的 mutation 只推进对应 source revision;无法定位 sourceId 的 mutation继续 global fail closed;
- Segment content revision 由:
- 该 Segment 的 dependent source revisions;
- 当前 Segment layer fingerprint / layer mutation;
- 必须全局影响 retained 的 global revision 类别;
- topology / camera / viewport 等既有安全条件
共同决定。
Source/Tile mutation 不应推进“所有 Segment 都消费的 retained cache revision”。
必须避免:
- 用屏幕位置猜 dependency;
- 在 renderer 中硬编码 source/layer ID;
- 从 STEP-099 diagnostics JSON 反向驱动正式 renderer;
- 建立 diagnostics 与 production 两套互相漂移的 dependency 真相。
如果需要提取 production-grade dependency helper,应让 diagnostics 和正式逻辑共享同一只读推导源。正式 renderer 不得从 diagnostics JSON 或 `RetainedUnderlayRuntimeState` 反向取 dependency。
Source-scoped revision 属正式 correctness state,不得沿用 STEP-099 diagnostics 的“best-effort 记录失败可忽略”语义。正式 revision/dependency state 若无法可靠更新,必须 conservative global invalidation/fail closed。
---
## 5. Global mutation 仍可保守处理
本任务只要求 **Source/Tile mutation** 与明确 Source readiness 局部化。
以下无法安全局部化的事件,本轮允许继续 invalidate/fail closed 全 retained:
- context reset;
- style reload / topology change;
- image mutation;
- glyph mutation;
- sprite/image manager 全局 readiness;
- placement / symbol fade / glyph/image/sprite 全局状态;
- camera / transform / viewport / resize / pixel ratio;
- debug mode;
- 3D / unsupported / legacy ordering;
- 无法定位 dependency 的 mutation;
- 真正 unknown/global dependency。
注意:**Source 自身的 loaded / fading 不属于这里的 global fail-closed 项**;若 source dependency 可定位,必须按 Segment 局部判断。Fast Path 的 global source/fading gate除外,本轮保持原状。
完成报告必须列出:
- 已局部化的 mutation 类型;
- 仍为 global 的 mutation 类型;
- 为什么保守处理。
---
## 6. Fast Path 本轮严格不改
STEP-099 已确认 Fast Path 仍有 global gates:
- global update signature(sources/layers/images 等);
- global content revision;
- global fade;
- pendingChanges;
- placement/light/non-particle transition/repaint;
- annotation mutation;
- topology/boundary/revision/cache validity;
- `singleContinuousSegment`。
STEP-100 **禁止修改这些 gate 的语义**。
允许:
- Source mutation 后 Fast Path miss;
- transient source readiness 时 Fast Path fail closed;
- full CPU renderer 被调用。
但进入 full CPU renderer 后,必须能做到:
```text
R0 retained cache reuse
R1 rebuild/fallback
```
这正是本任务验收目标。
Multi-continuous Fast Path 明确不在本任务。
---
## 7. Render 顺序与混合 Build/Reuse/Fallback
实现 per-segment mixed decisions 时必须保持原 MapLibre 绘制语义:
- Style Z-order;
- global layerIndex;
- opaque reverse traversal;
- translucent forward traversal;
- currentLayer;
- depthRangeSize;
- legacy item 不双绘;
- debug overlay 不重复;
- retained texture composite 不改变 segment 顺序。
推荐的逻辑行为:
```text
for segment in Style order:
RETAINED + Build:
build cache
composite cache
RETAINED + Reuse:
composite cache
RETAINED + Fallback:
live render only this range
CONTINUOUS:
live render this range
```
实际代码可按现有 RenderPass 安全实现,不要求机械照搬伪代码。
必须特别检查:
- 一个 R fallback 不得导致另一个 R live redraw;
- resource prepare 在 Build 前失败时,必须先把该 Segment 本帧 decision 改为 Fallback,再进入主 buffer 绘制;
- retained build 中途失败时,必须 invalidate/标记该 Segment失败并把该 Segment 本帧 decision 改为 Fallback,禁止 composite 旧纹理;
- 如果失败意味着 context / shared GPU state 已不可信,允许全局 fail closed;
- fallback range 的 legacy items 只绘一次,不得因 per-segment 主循环重复 render;
- debug overlay 全帧只绘一次;
- Segment 间 Opaque/Translucent 语义不得被破坏;
- 相邻 R、多个 C、透明叠加与 legacy scene 不能因 mixed execution 出现漏绘/双绘。
推荐执行顺序:先完成各 Segment resource prepare 与必要离屏 Build;再 clear 主 buffer;最后按 Style Segment 顺序逐段选择 cached composite 或 live range render。
---
## 8. Diagnostics 必须继续可证明局部性
保留 STEP-099 diagnostics,并 additive 增加足够字段,使 RDC 能验证正式行为。
每个 RETAINED Segment 至少应能观察:
- dependentSourceIds;
- segment readiness / support;
- segment-local source/content revision 或等价可证明值;
- Build / Reuse / Fallback 的**最终本帧 execution decision**;
- build/reuse/fallback/invalidation count;
- last invalidation/fallback reason。
Source mutation 继续输出:
- sourceId;
- sequence/reason/action;
- affectedSegmentIndices。
要求:
```text
step099-dynamic mutation
affected=[R1]
R0 revision unchanged
R0 cache reuse++
R0 build unchanged
R0 invalidation unchanged
```
Diagnostics 仍是 pull/event low-frequency,不允许逐帧高频 spam。
---
## 9. Android Test App
复用 STEP-099,不另建独立 App。
至少保留并完善:
### T1 STATIC
验证静态 R-C-R:
- loaded settle;
- R0/R1 build 一次后稳定 reuse;
- 不产生持续 rebuild。
### T2 GEOJSON_1HZ
两个 GeoJSON source 交错,各约 1 Hz。
重点:
- global loaded transient false window 可以继续存在;
- 只引用某 source 的 retained Segment 才允许因该 source mutation 改变;
- 无关 retained Segment build/invalidation 不得跟随增长。
### T3 PERMANENT_UNREADY
保留一个:
- no layer;
- enabled=false;
- isLoaded=false;
- no field submission
的 RasterArray source。
预期:
- global renderOrchestratorLoaded 仍可为 false;
- 但无关 R0/R1 retained cache 不应因它持续 `style-not-loaded` fallback;
- 能安全判断依赖的 retained Segment应继续 build/reuse。
### T4 R-C-R(主验收)
必须保持清晰依赖:
```text
R0 -> dedicated static source only
C -> WeatherColor/Particle field
R1 -> dedicated dynamic GeoJSON source
```
运行动态 GeoJSON 约 1 Hz。
必须调整 fixture,避免 STEP-099 中 R1 再复用 R0 的 static source:
- R0 static source 只出现在 R0;
- R1 dynamic source 只出现在 R1;
- 若 MapLibre 内建 annotation source/layer 自动进入 R1,必须在 diagnostics 中列明,并保证验收窗口内 annotation 不发生 mutation;
- 不允许用业务 ID 特判 renderer。
RDC 验收区间必须支持“settled baseline → 60 s pure dynamic-source mutation window → final”对比。每个 aggregate sample 同时记录 mutation source/sequence、各 Segment local revision、readiness、最终 execution decision 与 cache counters。
---
## 10. STEP-100 硬验收标准
### A. R0 locality
T4 settle 后记录 baseline。
随后至少 60 s dynamic GeoJSON:
- R0 `buildDelta == 0`
- R0 `invalidationDelta == 0`
- R0 `reuseDelta > 0`
- R0 segment-local revision/content state 不因 R1 dynamic source mutation 改变
- 即使某采样点 global `renderOrchestratorLoaded=false`,只要 R0 自身 prerequisites/dependencies ready,R0 final decision 仍可为 Reuse
如果为了安全出现明确 global event,应在日志中可解释并重新选择纯动态 mutation 区间;不能把 global event 混入后声称 PASS。
### B. R1 reacts
同一时间窗:
- R1 必须能观察到 source mutation;
- R1 revision / build / invalidation / fallback 中至少有与动态 source 对应的实际变化;
- `affectedSegmentIndices` 必须只包含真实依赖 Segment。
### C. global revision compatibility
- global `retainedUnderlayContentRevision` 允许并预期继续增长;
- 不能通过停止 global revision 增长来制造 R0 PASS。
### D. readiness locality
T2 transient unloaded 或 T3 permanent unrelated unloaded 时:
- global loaded 可 false;
- R0 不得仅因无关 source 的 unloaded 而 invalid/rebuild/fallback;
- R0 cache 必须继续可复用。
### E. correctness
- 无 crash / ANR;
- 无 stale cache composite;
- 无 Segment Z-order 错乱;
- 无 fallback range 双绘;
- Cache OFF 仍安全走 live renderer;
- plan invalid 仍 fail closed。
任一硬门槛失败,STEP-100 不得宣布 PASS,不得进入 V18。
---
## 11. Native 非 Jest 测试要求
Codex 必须新增或扩展现有 C++ 测试源码,至少覆盖:
1. source A mutation 只改变依赖 A 的 Segment revision;
2. source A 被多个 Segment 共享时,对应多个 Segment 都变化;
3. unrelated Segment revision 不变;
4. source-less Segment 不制造 dependency;
5. unknown/global dependency fail closed;
6. unrelated source unloaded 不使安全 Segment readiness false;
7. dependent source unloaded 使对应 Segment readiness false;
8. R0 Reuse + R1 Fallback 的 mixed decision;
9. 单 Segment build failure 不 composite stale cache;
10. query diagnostics 不改变 revision/cache state;
11. global non-source invalidation 仍影响所有 retained;
12. source 删除后同 ID recreate 不误复用旧 Segment cache identity;
13. dependent source fading 只影响对应 Segment;unrelated source fading 不影响安全 Segment;
14. Cache OFF / invalid plan 行为不回归;
15. 至少有一组能证明实际 mixed render 没有漏绘/双绘的 renderer-level draw-count、pixel 或现有等价测试入口;不能只用 decision unit test 代替。
不得修改测试断言去适应错误实现。
---
## 12. 允许修改范围
允许:
- RenderOrchestrator source mutation/readiness bookkeeping;
- RenderSegment dependency production helper;
- Renderer retained per-segment revision/readiness/decision;
- RetainedUnderlayCache/RuntimeState 为局部状态增加必要字段;
- additive diagnostics JSON/TS;
- Android Validation Test App STEP-099 fixture/log;
- 相关 Native C++ tests;
- STEP-100 Step 记录/完成报告/开发记录。
禁止:
- NavSea;
- Integration;
- V17 tag/release worktree;
- Source `isLoaded()` 定义;
- Particle field/provider/network;
- Fast Path gate semantic;
- `singleContinuousSegment`;
- multi-continuous Fast Path;
- 自动改变 Style Z-order;
- provider/business-specific source/layer ID;
- unrelated cleanup/refactor;
- Jest。
---
## 13. Codex 执行边界
Codex 本轮负责:
1. 读取实际源码和任务书;
2. 实现代码;
3. 编写 Native 非 Jest 测试源码;
4. 更新 Android Test App 验证入口;
5. 执行轻量静态检查,如 `git diff --check`;
6. 生成中文完成报告。
Codex **不得执行**:
- Android Gradle / APK;
- ADB / 安装 / 实机;
- Xcode / iOS device;
- V18 release;
- tag;
- push;
- Jest。
本轮 Codex 完成报告固定:
`docs/报告/STEP-100_SourceScoped_RenderSegment局部失效_Codex完成报告_v1.md`
Step 记录固定:
`docs/50_Step记录/STEP-100_SourceScoped_RenderSegment局部失效_v1.md`
完成报告必须写清:
- 实际 Codex model / reasoning effort;
- start HEAD;
- 修改文件;
- 生产设计;
- source revision/readiness 规则;
- mixed retained decision 规则;
- Fast Path 明确未改;
- 测试源码新增情况;
- 实际执行 / NOT RUN;
- 风险与 RDC 验收点;
- 未 commit / 未 push(如果确实如此)。
Codex 不 commit、不 push。由 RDC 验证通过后统一 Git 收尾。
---
## 14. RDC / ChatGPT 后续独立验收
Codex 返回后由 RDC 负责:
1. source/diff 审计;
2. Native Mac 编译;
3. Native focused + broad related C++ tests;
4. Android x86_64 OpenGL validation APK 单次构建;
5. Mac ADB 直连 FZG1-2;
6. 自动跑 T1/T2/T3/T4;
7. 解析 R0/R1 build/reuse/invalidation/revision/readiness;
8. 确认硬验收标准 A~E;
9. 若失败,回到 STEP-100 修复,不发布;
10. 若全部 PASS,完成 STEP-100 Git push;
11. 再按现有统一发行流程升级到:
`maplibre-particle/rn/13.4.1-particle.18`
12. V18 build/release/public artifact 验证通过后才宣布 V18 发布完成。
---
## 15. 停止条件
Codex 遇到以下任一情况必须停止并报告,不得扩大范围:
- 要修改 Source `isLoaded()` 才能继续;
- 需要放宽 Fast Path gate;
- 必须改变 singleContinuous / multi-continuous;
- production dependency 无法可靠从现有 Style/RenderLayer/Source 推导;
- per-segment fallback 会破坏 RenderPass/Z-order 且无法在当前范围安全解决;
- 发现需要 NavSea/Integration 配合;
- 需要业务 layer/source ID;
- 需要改变 V17 release。
---
## 16. 本任务最终原则
STEP-100 的目标不是让“全局世界看起来 loaded”。
目标是:
> **即使某个 GeoJSON Source 正在变化或短暂 unready,只要 R0 与它没有依赖关系,R0 retained cache 就必须继续安全 reuse,不能被迫一起刷新。**
Fast Path 是否还能 miss,是后续独立问题。
只有这一点经过 Native + Android R-C-R 实测成立,才允许进入 V18 发布。

View File

@@ -0,0 +1,405 @@
# STEP-101 RenderOrchestrator teardown 生命周期修复 — Codex 任务 v1
## 0. 任务目的
修复 Particle V18 Android 在正常 Renderer teardown / MapView reset 时发生的 Native SIGABRT。
现场已在 NavSea Android x86_64 设备稳定复现:
```text
Fatal signal 6 (SIGABRT)
thread: RenderThread 11
FORTIFY:
pthread_mutex_lock called on a destroyed mutex
libmaplibre.so
std::__ndk1::mutex::lock()
...
mbgl::android::MapRenderer::resetRenderer()
```
V17 正常,V18 接入后稳定在启动约 9~11 秒触发。
本任务只修 Particle Native 生命周期;禁止在 NavSea 增加绕路、private wrapper、禁止阻止正常 MapView reset。
未经用户明确要求:不加入、不执行 Jest。
---
## 1. 冻结基线
工作树:
`/Users/tei/dev/MapLibreParticleLab-step101`
分支:
`step101-render-orchestrator-teardown`
起点必须是:
`origin/particle/main = d29e84c1a90daeea104b389d28b000c4f017d08d`
即正式 V18 Final HEAD。
V18 已发布,不得覆盖、重发或移动:
- `maplibre-particle/rn/13.4.1-particle.18`
- Android `13.4.1-particle.18`
- iOS `6.28.0-particle.18`
修复通过后由 RDC/ChatGPT 发布新不可变版本 V19。
---
## 2. 已知根因证据
V18 STEP-100 新增:
```cpp
RenderSegmentRevisionBook retainedSegmentRevisions;
mutable std::mutex readinessDiagnosticsMutex;
uint64_t readinessEventSequence;
std::unordered_map<std::string, SourceReadinessEventState> sourceReadinessEvents;
RenderMutationDiagnostics lastReadinessMutation;
```
多个 RenderSource / Tile observer callback 会进入:
```cpp
advanceRetainedSourceRevision(...)
recordReadinessMutation(...)
onTileChanged(...)
onTileAction(...)
buildReadinessDiagnostics(...)
retainedSegmentSourceState(...)
```
并 lock:
```cpp
readinessDiagnosticsMutex
```
V18 当前成员声明中:
```text
renderSources
renderLayers
...
retainedSegmentRevisions
...
readinessDiagnosticsMutex
sourceReadinessEvents
...
threadPool
```
C++ 成员按声明反序析构,因此 readiness state / mutex 会早于 renderSources 销毁。
而 RenderSource 在创建时:
```cpp
renderSource->setObserver(this);
```
observer 指向 RenderOrchestrator。
当前 `RenderOrchestrator::~RenderOrchestrator()` 只:
1. contextLost 时标记 layer;
2. `threadPool.waitForEmpty()`;
没有在 readiness state 仍有效时显式 detach RenderSource observer / 清理 renderSources。
因此 V18 存在明确生命周期危险:
```text
readinessDiagnosticsMutex destroyed
↓
renderSources still destroying/alive
↓
late tile/source callback
↓
RenderOrchestrator::onTile*
↓
lock destroyed mutex
↓
FORTIFY SIGABRT
```
---
## 3. 修复原则
不要只依赖调整成员声明顺序。
本任务应建立显式 teardown contract:
```text
RenderOrchestrator destructor begins
↓
stop / drain deferred scheduler work
↓
detach observer from every RenderSource
↓
destroy/clear renderSources while all orchestrator state is still alive
↓
only then continue member teardown
```
同时 `RenderSource::setObserver(nullptr)` 必须安全恢复到 NullObserver。
目标是即使未来成员声明顺序变化,Source callback 也不能在 owner teardown 后访问 RenderOrchestrator。
---
## 4. 必做实现
### 4.1 RenderSource nullptr observer 语义
当前:
```cpp
void RenderSource::setObserver(RenderSourceObserver* observer_) {
observer = observer_;
}
```
改成与 Style Source / Layer 一致的安全语义:
```cpp
observer = observer_ ? observer_ : &nullObserver;
```
要求:
- constructor 仍默认 NullObserver;
- `setObserver(nullptr)` 是合法 detach;
- onTileChanged / onTileError / onTileAction 不需要加 null 分支;
- 不吞掉正常有 observer 时的回调。
必须加非 Jest C++ 回归测试验证 nullptr detach 后 callback 不 crash、不调用旧 observer。
### 4.2 RenderOrchestrator 显式 teardown
在 `RenderOrchestrator::~RenderOrchestrator()` 中,在 readiness mutex/state 仍确定存活时:
1. 保留现有 contextLost layer handling;
2. 等待已有 deferred work 完成;
3. 对所有 `renderSources` 执行 observer detach;
4. 显式 `renderSources.clear()`;
5. 必要时对 glyph/image observer 也做已有 API 支持的 detach,但不要扩大范围;
6. 不允许 teardown callback 再进入 RenderOrchestrator source callback。
顺序必须结合当前 TaggedScheduler / RenderSource / TilePyramid 的真实生命周期检查,而不是机械照抄上述编号。
如果存在“clear renderSources 本身可能产生新的 scheduler work”,需要设计第二次 drain 或更安全顺序,并在报告解释。
### 4.3 生命周期状态可选保护
如果仅 detach + explicit clear 仍无法证明并发安全,允许增加最小 teardown flag,例如 atomic bool,但只作为二次防线。
禁止用 flag 代替 observer detach。
禁止 catch FORTIFY;destroyed mutex abort 不是 C++ exception。
---
## 5. 不得修改
- STEP-100 Source→Segment locality 语义;
- per-segment revision;
- per-segment readiness;
- mixed Build/Reuse/Fallback;
- Fast Path gate;
- singleContinuousSegment;
- Source::isLoaded;
- Style Z-order;
- provider/network;
- NavSea;
- MapRenderer::resetRenderer 正常生命周期;
- Android Activity 为避免 reset 而加 workaround。
---
## 6. 必做测试
### 6.1 Native 非 Jest
至少覆盖:
1. RenderSource 默认 NullObserver 可安全接 callback;
2. setObserver(real) 正常转发;
3. setObserver(nullptr) 后不再调用旧 observer;
4. detach 后 onTileChanged / onTileError / onTileAction 安全;
5. RenderOrchestrator teardown 有至少一个含 RenderSource 的场景;
6. source callback 与 teardown 边界不访问已销毁 readiness state;
7. STEP-100 locality 相关 focused tests 不回归。
如果现有测试设施不方便直接构造完整 RenderOrchestrator teardown,允许提取最小可测试 teardown helper,但禁止为了测试重构大范围生产结构。
### 6.2 Android Test App
在现有 ParticleCoreLayerValidationActivity / Validation App 增加专门 STEP-101 teardown 场景。
场景必须真实触发 Native Renderer teardown,而不是只 clear field。
至少提供:
```text
T1 RESET_ONCE
T2 RESET_LOOP_20
T3 RESET_WITH_DYNAMIC_GEOJSON
T4 RESET_WITH_DIAGNOSTICS
```
目标:
- recreate/destroy MapView 或等价真实 renderer.reset;
- 每轮等待足够让 source/tile work 进入正常运行;
- T3 同时保持动态 GeoJSON/source mutation;
- T4 在 reset 前调用一次 RendererRuntime diagnostics,模拟 NavSea probe 时序;
- 20 次循环无 SIGABRT / crash / ANR;
- reset 后新 renderer 能继续正常显示 / 获取 diagnostics。
不得通过延迟 reset、禁用 observer、关闭 GeoJSON 来逃避问题。
---
## 7. NavSea 现场等价验收
RDC 在 Codex 完成后独立执行。
Android 设备:
`192.168.200.139:5555`
必须显式 `adb -s`。
最终至少执行:
1. Validation App STEP-101 T1~T4;
2. crash buffer 检查;
3. 20 次 reset/recreate;
4. 安装 V19 进 NavSea 后做真实启动;
5. 保留 NavSea 当前 `key={map-style-generation-...}` 正常 reset 触发条件;
6. 保留/启用一次 RenderSegment diagnostics probe A/B;
7. 连续至少 5 次冷启动,每次观察 >30 秒;
8. 不允许出现:
- `pthread_mutex_lock called on a destroyed mutex`
- SIGABRT RenderThread
- `MapRenderer::resetRenderer` teardown crash。
---
## 8. STEP-100 回归验收
修复 teardown 后必须确认 V18 的核心 locality 没被破坏。
至少复测 T4 R-C-R:
无关 R0:
```text
buildDelta = 0
invalidationDelta = 0
reuseDelta > 0
```
动态 R1 正常 build/invalidation。
禁止通过删除 mutex/readiness/revision 功能来修 crash。
---
## 9. Codex 执行边界
Codex负责:
- 只读检查真实 V18 代码;
- 实现最小 Native 修复;
- 编写/更新非 Jest C++ tests;
- 更新 Android Validation STEP-101 场景;
- 静态检查;
- 生成报告。
Codex不负责:
- NavSea 修改;
- V19 release;
- tag;
- publish;
- 覆盖 V18;
- Jest。
Mac Native 开发/编译原则保持;不要使用 Linux Native 环境。
---
## 10. 完成报告
生成:
`docs/报告/STEP-101_RenderOrchestrator销毁生命周期修复_Codex完成报告_v1.md`
Step 记录:
`docs/50_Step记录/STEP-101_RenderOrchestrator销毁生命周期修复_v1.md`
报告必须写:
- root cause;
- start HEAD;
- 修改文件;
- destructor teardown 顺序;
- observer detach 语义;
- 为什么不依赖成员声明顺序;
- tests added;
- 实际运行 / NOT RUN;
- STEP-100 locality 是否保持;
- 风险;
- 未发布 V19(Codex 阶段)。
Codex阶段不 push,由 RDC 验收后统一 commit/push。
---
## 11. V19 发布门槛
仅当:
- Native compile PASS;
- focused C++ tests PASS;
- Android Validation teardown loop PASS;
- STEP-100 locality regression PASS;
- NavSea V19 真实 5 次冷启动 >30s PASS;
- crash buffer 无 destroyed mutex;
才允许发布:
- RN / Android:`13.4.1-particle.19`
- iOS:`6.28.0-particle.19`
- tag:`maplibre-particle/rn/13.4.1-particle.19`
V18 保持不可变,不覆盖、不删除。
---
## 12. 最终原则
正常:
```text
MapView unmount
Style generation replacement
renderer.reset()
Activity recreate
```
都是合法生命周期。
Particle Native 必须做到:
> Renderer teardown 永远不能因为 observer late callback 访问已销毁 mutex/state 而杀掉宿主 App。

View File

@@ -0,0 +1,510 @@
# STEP-101 RenderOrchestrator teardown 生命周期修复 — Codex 任务 v1
## 0. 任务目的
修复 Particle V18 Android 在正常 Renderer teardown / MapView reset 时发生的 Native SIGABRT。
现场已在 NavSea Android x86_64 设备稳定复现:
```text
Fatal signal 6 (SIGABRT)
thread: RenderThread 11
FORTIFY:
pthread_mutex_lock called on a destroyed mutex
libmaplibre.so
std::__ndk1::mutex::lock()
...
mbgl::android::MapRenderer::resetRenderer()
```
V17 正常,V18 接入后稳定在启动约 9~11 秒触发。
本任务只修 Particle Native 生命周期;禁止在 NavSea 增加绕路、private wrapper、禁止阻止正常 MapView reset。
未经用户明确要求:不加入、不执行 Jest。
---
## 1. 冻结基线
工作树:
`/Users/tei/dev/MapLibreParticleLab-step101`
分支:
`step101-render-orchestrator-teardown`
起点必须是:
`origin/particle/main = d29e84c1a90daeea104b389d28b000c4f017d08d`
即正式 V18 Final HEAD。
V18 已发布,不得覆盖、重发或移动:
- `maplibre-particle/rn/13.4.1-particle.18`
- Android `13.4.1-particle.18`
- iOS `6.28.0-particle.18`
修复通过后由 RDC/ChatGPT 发布新不可变版本 V19。
---
## 2. 已知根因证据
V18 STEP-100 新增:
```cpp
RenderSegmentRevisionBook retainedSegmentRevisions;
mutable std::mutex readinessDiagnosticsMutex;
uint64_t readinessEventSequence;
std::unordered_map<std::string, SourceReadinessEventState> sourceReadinessEvents;
RenderMutationDiagnostics lastReadinessMutation;
```
多个 RenderSource / Tile observer callback 会进入:
```cpp
advanceRetainedSourceRevision(...)
recordReadinessMutation(...)
onTileChanged(...)
onTileAction(...)
buildReadinessDiagnostics(...)
retainedSegmentSourceState(...)
```
并 lock:
```cpp
readinessDiagnosticsMutex
```
V18 当前成员声明中:
```text
renderSources
renderLayers
...
retainedSegmentRevisions
...
readinessDiagnosticsMutex
sourceReadinessEvents
...
threadPool
```
C++ 成员按声明反序析构,因此 readiness state / mutex 会早于 renderSources 销毁。
而 RenderSource 在创建时:
```cpp
renderSource->setObserver(this);
```
observer 指向 RenderOrchestrator。
当前 `RenderOrchestrator::~RenderOrchestrator()` 只:
1. contextLost 时标记 layer;
2. `threadPool.waitForEmpty()`;
没有在 readiness state 仍有效时显式 detach RenderSource observer / 清理 renderSources。
因此 V18 存在明确生命周期危险:
```text
readinessDiagnosticsMutex destroyed
↓
renderSources still destroying/alive
↓
late tile/source callback
↓
RenderOrchestrator::onTile*
↓
lock destroyed mutex
↓
FORTIFY SIGABRT
```
---
## 3. 修复原则
不要只依赖调整成员声明顺序。
本任务应建立显式 teardown contract:
```text
RenderOrchestrator destructor begins
↓
stop / drain deferred scheduler work
↓
detach observer from every RenderSource
↓
destroy/clear renderSources while all orchestrator state is still alive
↓
only then continue member teardown
```
同时 `RenderSource::setObserver(nullptr)` 必须安全恢复到 NullObserver。
目标是即使未来成员声明顺序变化,Source callback 也不能在 owner teardown 后访问 RenderOrchestrator。
---
## 4. 必做实现
### 4.1 RenderSource nullptr observer 语义
当前:
```cpp
void RenderSource::setObserver(RenderSourceObserver* observer_) {
observer = observer_;
}
```
改成与 Style Source / Layer 一致的安全语义:
```cpp
observer = observer_ ? observer_ : &nullObserver;
```
要求:
- constructor 仍默认 NullObserver;
- `setObserver(nullptr)` 是合法 detach;
- onTileChanged / onTileError / onTileAction 不需要加 null 分支;
- 不吞掉正常有 observer 时的回调。
必须加非 Jest C++ 回归测试验证 nullptr detach 后 callback 不 crash、不调用旧 observer。
### 4.2 RenderOrchestrator 显式 teardown
在 `RenderOrchestrator::~RenderOrchestrator()` 中,在 readiness mutex/state 仍确定存活时:
1. 保留现有 contextLost layer handling;
2. 等待已有 deferred work 完成;
3. 对所有 `renderSources` 执行 observer detach;
4. 显式 `renderSources.clear()`;
5. 必要时对 glyph/image observer 也做已有 API 支持的 detach,但不要扩大范围;
6. 不允许 teardown callback 再进入 RenderOrchestrator source callback。
顺序必须结合当前 TaggedScheduler / RenderSource / TilePyramid 的真实生命周期检查,而不是机械照抄上述编号。
如果存在“clear renderSources 本身可能产生新的 scheduler work”,需要设计第二次 drain 或更安全顺序,并在报告解释。
### 4.3 生命周期状态可选保护
如果仅 detach + explicit clear 仍无法证明并发安全,允许增加最小 teardown flag,例如 atomic bool,但只作为二次防线。
禁止用 flag 代替 observer detach。
禁止 catch FORTIFY;destroyed mutex abort 不是 C++ exception。
---
## 5. 不得修改
- STEP-100 Source→Segment locality 语义;
- per-segment revision;
- per-segment readiness;
- mixed Build/Reuse/Fallback;
- Fast Path gate;
- singleContinuousSegment;
- Source::isLoaded;
- Style Z-order;
- provider/network;
- NavSea;
- MapRenderer::resetRenderer 正常生命周期;
- Android Activity 为避免 reset 而加 workaround。
---
## 6. 必做测试
### 6.1 Native 非 Jest
至少覆盖:
1. RenderSource 默认 NullObserver 可安全接 callback;
2. setObserver(real) 正常转发;
3. setObserver(nullptr) 后不再调用旧 observer;
4. detach 后 onTileChanged / onTileError / onTileAction 安全;
5. RenderOrchestrator teardown 有至少一个含 RenderSource 的场景;
6. source callback 与 teardown 边界不访问已销毁 readiness state;
7. STEP-100 locality 相关 focused tests 不回归。
如果现有测试设施不方便直接构造完整 RenderOrchestrator teardown,允许提取最小可测试 teardown helper,但禁止为了测试重构大范围生产结构。
### 6.2 Android Test App
在现有 ParticleCoreLayerValidationActivity / Validation App 增加专门 STEP-101 teardown 场景。
场景必须真实触发 Native Renderer teardown,而不是只 clear field。
至少提供:
```text
T1 RESET_ONCE
T2 RESET_LOOP_20
T3 RESET_WITH_DYNAMIC_GEOJSON
T4 RESET_WITH_DIAGNOSTICS
```
目标:
- recreate/destroy MapView 或等价真实 renderer.reset;
- 每轮等待足够让 source/tile work 进入正常运行;
- T3 同时保持动态 GeoJSON/source mutation;
- T4 在 reset 前调用一次 RendererRuntime diagnostics,模拟 NavSea probe 时序;
- 20 次循环无 SIGABRT / crash / ANR;
- reset 后新 renderer 能继续正常显示 / 获取 diagnostics。
不得通过延迟 reset、禁用 observer、关闭 GeoJSON 来逃避问题。
---
## 7. NavSea 现场等价验收
RDC 在 Codex 完成后独立执行。
Android 设备:
`192.168.200.139:5555`
必须显式 `adb -s`。
最终至少执行:
1. Validation App STEP-101 T1~T4;
2. crash buffer 检查;
3. 20 次 reset/recreate;
4. 安装 V19 进 NavSea 后做真实启动;
5. 保留 NavSea 当前 `key={map-style-generation-...}` 正常 reset 触发条件;
6. 保留/启用一次 RenderSegment diagnostics probe A/B;
7. 连续至少 5 次冷启动,每次观察 >30 秒;
8. 不允许出现:
- `pthread_mutex_lock called on a destroyed mutex`
- SIGABRT RenderThread
- `MapRenderer::resetRenderer` teardown crash。
---
## 8. STEP-100 回归验收
修复 teardown 后必须确认 V18 的核心 locality 没被破坏。
至少复测 T4 R-C-R:
无关 R0:
```text
buildDelta = 0
invalidationDelta = 0
reuseDelta > 0
```
动态 R1 正常 build/invalidation。
禁止通过删除 mutex/readiness/revision 功能来修 crash。
---
## 9. Codex 执行边界
Codex负责:
- 只读检查真实 V18 代码;
- 实现最小 Native 修复;
- 编写/更新非 Jest C++ tests;
- 更新 Android Validation STEP-101 场景;
- 静态检查;
- 生成报告。
Codex不负责:
- NavSea 修改;
- V19 release;
- tag;
- publish;
- 覆盖 V18;
- Jest。
Mac Native 开发/编译原则保持;不要使用 Linux Native 环境。
---
## 10. 完成报告
生成:
`docs/报告/STEP-101_RenderOrchestrator销毁生命周期修复_Codex完成报告_v1.md`
Step 记录:
`docs/50_Step记录/STEP-101_RenderOrchestrator销毁生命周期修复_v1.md`
报告必须写:
- root cause;
- start HEAD;
- 修改文件;
- destructor teardown 顺序;
- observer detach 语义;
- 为什么不依赖成员声明顺序;
- tests added;
- 实际运行 / NOT RUN;
- STEP-100 locality 是否保持;
- 风险;
- 未发布 V19(Codex 阶段)。
Codex阶段不 push,由 RDC 验收后统一 commit/push。
---
## 11. V19 发布门槛
仅当:
- Native compile PASS;
- focused C++ tests PASS;
- Android Validation teardown loop PASS;
- STEP-100 locality regression PASS;
- NavSea V19 真实 5 次冷启动 >30s PASS;
- crash buffer 无 destroyed mutex;
才允许发布:
- RN / Android:`13.4.1-particle.19`
- iOS:`6.28.0-particle.19`
- tag:`maplibre-particle/rn/13.4.1-particle.19`
V18 保持不可变,不覆盖、不删除。
---
## 12. 最终原则
正常:
```text
MapView unmount
Style generation replacement
renderer.reset()
Activity recreate
```
都是合法生命周期。
Particle Native 必须做到:
> Renderer teardown 永远不能因为 observer late callback 访问已销毁 mutex/state 而杀掉宿主 App。
---
# 13. Codex 6-Sol High 架构评审修订(v2,优先级高于前文冲突表述)
只读评审结论:`APPROVE_WITH_CHANGES`。
## 13.1 根因表述修正
当前已经**代码层证实**存在生命周期漏洞:`readinessDiagnosticsMutex` / readiness state 的析构顺序早于 `renderSources`,而 `RenderSource` observer 仍指向 `RenderOrchestrator`;source/tile teardown 可同步或延迟回调进入 `onTileAction/onTileChanged` 并访问 mutex。
该漏洞与现场 `pthread_mutex_lock called on a destroyed mutex` 高度吻合;在没有完整符号化 crash stack 精确到具体 callback 前,不把“唯一现场根因”写成数学意义上的唯一证明。
## 13.2 teardown 必须双 drain
最低安全顺序调整为:
```text
contextLost layer handling
↓
第一次 threadPool.waitForEmpty()
↓
detach RenderSource observers
↓
detach GlyphManager / ImageManager observers
↓
renderSources.clear() // readiness mutex/state 此时仍存活
↓
第二次 threadPool.waitForEmpty()
↓
继续既有 backend/context render-job teardown
↓
成员析构
```
原因:source/tile destruction 本身可能投递新的 tagged cleanup/deferred work,第一次 drain 不能覆盖 clear 过程中产生的新任务。
同时必须审计并在报告说明:`TaggedScheduler::waitForEmpty()` 不等于清空 `runOnRenderThread` 队列;后者依赖现有 backend/context render-job 生命周期。不得声称双 drain 自动证明所有 render-thread job 已清空。
## 13.3 Observer detach 范围
除了所有 `RenderSource`:
- `glyphManager->setObserver(nullptr)`;
- `imageManager->setObserver(nullptr)`;
也应在 owner teardown 时显式 detach(前提是现有 API 的 nullptr 已安全回 NullObserver)。
`RenderSource::setObserver(nullptr)` 必须改为 NullObserver fallback。
不得直接给 `TilePyramid` / `Tile` observer 写 nullptr,除非其 API 本身有同等安全语义;本任务不扩展到这一步。
## 13.4 teardown flag
默认不增加 teardown atomic flag。
若实现审计发现真实并发 callback 不受 renderer thread / tagged scheduler 串行保证约束,必须先报告;atomic flag 只能二次防护,不能替代 observer detach、drain 和可等待取消。
## 13.5 Android Validation 证据升级
T1~T4 每一轮必须能证明发生了**真实 Native renderer reset**:
- 旧 MapView detach / SurfaceView detach;
- `nativeReset()`;
- `MapRenderer::resetRenderer()` 进入/完成;
- 新 renderer style ready;
- 新 renderer diagnostics 可用。
不能把 `setStyle()`、clear field、只读 diagnostics 当成 teardown 测试。
推荐按代次销毁并重建 MapView,或 Activity recreate;T2 至少 20 次已确认 reset。
T3:每轮 reset 前保持动态 GeoJSON/source mutation。
T4:每轮 reset 前调用一次 Native diagnostics,模拟 NavSea 8 秒 probe 的等价条件。
每轮应等待 source/tile 确实进入运行态,而不是只按固定 sleep 机械计数。
## 13.6 测试必须覆盖真实 source teardown
空 `RenderOrchestrator` 析构测试不够。
非 Jest C++ 测试至少需要一个含 RenderSource / tile callback 的 teardown 场景,使 source cancellation / callback 能在销毁边界发生,并验证旧 observer 不再收到回调。
## 13.7 正常 source erase / clearData 审计
必须只读检查 `clearData()` 和正常 source erase/replacement 路径,因为它们也会销毁 RenderSource。
本任务主要修 owner teardown;不得未经证明把 destructor 修复泛化为“所有 source replacement 异步问题均已解决”。若发现相同 observer 生命周期漏洞在正常 erase 路径存在,应做最小同源修复或明确 BLOCK,不得隐藏。
## 13.8 V19 候选验收顺序
V19 发布门槛修正为:
```text
STEP-101 source code PASS
→ 构建未公开的 V19 candidate artifacts
→ Validation App 实机 PASS
→ NavSea 使用 candidate 做 5 次冷启动/teardown 验收
→ 公开发布不可变 V19
→ public readback
→ finalize/tag
```
不得先公开 V19 再做 NavSea 验收。

View File

@@ -0,0 +1,175 @@
# STEP-099 RenderSource 就绪与 RenderSegment 失效诊断
状态:**CLOSED / PASS**
日期:2026-10-06(JST)
## 基线
- 仓库:`/Users/tei/dev/MapLibreParticleLab-step099`
- 分支:`step099-render-source-diagnostics`
- frozen release:`maplibre-particle/rn/13.4.1-particle.17`
- frozen release commit:`e8b451fe70054107773e82484c80e90df3889147`
- implementation commit:`8a5214d92dd2f8899db76d2194da42999fa0c87d`
- Integration/NavSea:未修改。
## 本 Step 完成内容
- Renderer readiness snapshot additive 暴露 style/image manager/render orchestrator/sprite readiness、fading、global content revision、per-source 状态和最近 mutation。
- per-source diagnostics 暴露 sourceId/type/enabled/isLoaded;tile-backed source 提供 tileCount/incomplete/loaded/fading。
- RenderSegment diagnostics 只读推导 `dependentSourceIds` / `unknownDependencies`;source-less layer 不制造 source dependency,共享 source 可出现在多个 segment。
- mutation sequence/source/tile action/reason 只消费既有 renderer 事件;正式 revision 增长逻辑不变。
- 正式 loaded 判定保持:
`renderTreeParameters->loaded = updateParameters->styleLoaded && isLoaded();`
- diagnostics snapshot 不进入正式 loaded/cache/fast path/segment execution。
- RN 仅增加 diagnostics 类型字段;没有 NavSea 私有 API,没有改变业务 API。
- Android Test App 提供 T1 STATIC、T2 GEOJSON_1HZ、T3 PERMANENT_UNREADY、T4 R-C-R。
## Native 验证
Metal host `RenderSegmentDiagnostics.*`:
- 6/6 PASS;
- 新增 `ReadinessSnapshotMapsSourcesToSegmentsWithoutSideEffects` PASS;
- 覆盖 source readiness、tile stats、source→segment、shared source、source-less 与重复 query 无副作用。
最终源码再次尝试 Metal configure 时当前 Mac 缺 `libuv` pkg-config,因此第二次 host rerun 未进入 build;不虚报 PASS。最终代码已经被 Android 全量 native build 成功编译并由 T1~T4 运行覆盖。
Vulkan:因当前 Mac 缺 `libuv`,NOT RUN tests。
Jest:NOT RUN,用户未要求。
## Android 构建
首轮 Android NDK 暴露:
```text
error: missing field 'tileCount' initializer
[-Werror,-Wmissing-designated-field-initializers]
```
只补全 `RenderSourceReadinessStats` 默认空字段显式初始化。
随后 native build 通过,但 strip 阶段因 Mac 磁盘仅剩约 2 GiB:
```text
No space left on device
```
仅清理可再生 Gradle 9.5.1 cache / 失败中间产物后恢复约 12 GiB,再次增量构建:
- `BUILD SUCCESSFUL in 4m 54s`
- 86 actionable tasks:7 executed / 79 up-to-date
- OpenGL Debug / x86_64
- APK:61,923,024 bytes
- libmaplibre.so:39,602,856 bytes
- SHA-256:
`5de73f0358b835a78d3ae7e2ac22f4efdb2a66775b8c63654a8bb7313957d90a`
设备:
- `192.168.200.139:5555`
- FZG1-2
- Android 13 / API 33 / x86_64
## T1~T4 结果
### T1 STATIC
- 1 个启动 false window;
- avg/max = 204 ms;
- 随后 global loaded 稳定为 true;
- retained cache 可持续 reuse;
- fast path 可进入 active。
### T2 GEOJSON_1HZ
- 两个 GeoJSON source 交错更新,总 update 122 次,约 61 次/源;
- 10 个 loaded=false window;
- avg 203 ms;
- max 205 ms;
- open 0;
- 约 60 s 中 false 总时长约 2.03 s,约 3.4%。
结论:存在 readiness thrash,但不是“几乎永远 false”。
### T3 PERMANENT_UNREADY
`step099-unready-raster-array`:
- 无 layer;
- enabled=false;
- isLoaded=false;
- 其他主要 source loaded。
结果:
- global renderOrchestratorLoaded=false 持续;
- 观察结束 open false window = 21,459 ms;
- retained cache 持续 style-not-loaded fallback。
结论:单个与 segment 无关的 permanently unready source 足以拉低 V17 全局 loaded。
### T4 R-C-R
- R0 deps=[step099-static]
- C deps=[particle-fixture]
- R1 deps=[org.maplibre.annotations, step099-dynamic, step099-static]
- dynamic update:61 次
结果:
- loaded false windows:4
- avg 203 ms
- max 207 ms
- contentRevision:1760 → 2540
- diagnostics 对 dynamic source 正确给出 affected=[2]
- R0 不依赖 dynamic source,但:
- build 1 → 121
- invalidation 20 → 200
结论:dependency diagnostics 已能表达 source-local 影响,但 V17 正式 content revision/cache invalidation 仍为 global。
## Q1~Q7
1. Q1:**YES**。permanently unloaded source 会造成全局 style-not-loaded。
2. Q2:**YES**。1 Hz GeoJSON 会产生约 200 ms readiness false window;T2 avg 203 / max 205 ms。
3. Q3:**NO(本次观测)**。双 1 Hz source 并未让 global loaded 几乎始终 false。
4. Q4:**YES**。GeoJSON tile mutation 推进 global content revision,并使 retained cache 重建/失效。
5. Q5:**YES**。不引用动态 GeoJSON 的 R0 仍受 global revision 影响;源码还确认 `retainedRangeIsStable` 全局检查所有 source fading。
6. Q6:fast path 仍有 global update signature(sources/layers/images 等)、global content revision、global fade、pendingChanges、placement/light/non-particle transition/repaint、annotation mutation、retained revision/cache/topology/boundary,以及 singleContinuous gate。
7. Q7 blocker:
- runtime confirmed:`PERMANENT_SOURCE_UNLOADED`、`DYNAMIC_SOURCE_READINESS_THRASH`、`GLOBAL_CONTENT_REVISION`
- source confirmed:`GLOBAL_FADE`、`GLOBAL_UPDATE_SIGNATURE`
- OTHER:pending/placement/light/repaint/annotation/topology/boundary/singleContinuous。
## 证据
忽略目录:
`artifacts/maplibre-particle/android-validation/`
- `build-info.txt`
- `build.log`
- `MapLibreAndroidTestApp-validation-opengl-debug-x86_64.apk`
- `step099-logcat.txt`
- `step099-device-info.txt`
- `step099-metal-gtest.log`
STEP-099 log SHA-256:
`0f15d75dde77e7dd2f4d98c46a4c1501a628205e8aae62130b0cb96db6b8482f`
## 边界结论
本 Step 未修改:
- retained cache 算法;
- fast path gate;
- singleContinuous / multi-continuous;
- segment-local invalidation 正式语义;
- RenderSegment execution;
- NavSea;
- Source isLoaded;
- RN 业务 API。
下一阶段应拆分处理 global source readiness、source/segment-local content revision/invalidation,再独立处理 fast-path global gates。

View File

@@ -0,0 +1,132 @@
# STEP-100 SourceScoped RenderSegment 局部失效记录 v1
状态:**CLOSED / PASS(Android runtime)**
日期:2026-10-06
## 基线
- worktree:`/Users/tei/dev/MapLibreParticleLab-step100`
- branch:`step100-segment-local-source-invalidation`
- start HEAD:`c4b481fd2c5b5da20d31f6ae54011f0552d7dbd0`
- V17 tag:`maplibre-particle/rn/13.4.1-particle.17`
- V17 frozen commit:`e8b451fe70054107773e82484c80e90df3889147`
## 实现
- Source/Tile mutation 增加 source-scoped revision;
- global retained content revision 继续保留;
- production renderer 与 diagnostics 共用 source→segment dependency;
- RETAINED Segment 独立 readiness / stability / Build / Reuse / Fallback;
- unrelated source unloaded/fading 不再拖垮该 Segment;
- full CPU mixed execution 支持 R0 Reuse + C Live + R1 Fallback;
- Fast Path gate、singleContinuous、多 Continuous语义未改;
- Source::isLoaded 与 global renderTree loaded 语义未改。
## Android Build
标准 validation pipeline 最终:
```text
RC=0
BUILD SUCCESS
variant=OpenglDebug
abi=x86_64
apk_bytes=61956640
libmaplibre_bytes=39636472
sha256=86e22bf081bf7d3196f0bf0e7c56af51bde01e9f0b01e0bab238cb794273315a
```
RDC 另修复 validation script 的 pipefail/SIGPIPE 141:
`awk ... {print $1; exit}` → 完整消费管道。
## FZG1-2
- Android 13
- API 33
- x86_64
- ADB:`192.168.200.139:5555`
### T1 STATIC
- false window:1
- avg/max:203 ms
- R0/R1 build=1 后持续 reuse
### T4 R-C-R 主验收
窗口:
- baseline:tick 8
- final:tick 130
- 约 61.6 s
global revision:
`128 → 973`
R0:
```text
build 1 → 1
invalidation 20 → 20
reuse 132 → 4042
sourceRevision unchanged
```
R1:
```text
build 5 → 135
invalidation 26 → 211
```
结论:
- R0 buildDelta=0
- R0 invalidationDelta=0
- R0 reuseDelta=+3910
- global revision 仍增长
- dynamic source affected=[2]
**主硬门槛 PASS。**
### T2 GEOJSON 1HZ
- 约 35 s
- falseWindowCount=6
- avg=203 ms
- max=205 ms
- R0 build=1 / invalidation=18 保持稳定,reuse 增长
### T3 PERMANENT UNREADY
global loaded=false 持续约 21.468 s。
unready source:
- no layer
- enabled=false
- isLoaded=false
R0/R1:
- build=1 不变
- invalidation=19 不变
- reuse:11 → 1200
- decision=reuse
说明 global loaded 兼容事实仍为 false,但无关 retained cache 继续安全复用。
## Native Host Test
Codex 已新增 `RenderSegmentLocality.*` 测试源码。
当前 Intel Mac 缺 `libuv`,Metal host configure 被环境阻塞。RDC 曾尝试 Homebrew 安装,但发现会连带升级 CMake/OpenSSL/Python 且 Intel 无 bottle 支持,已主动停止。
因此:
- Native host GTest:ENV BLOCKED / NOT RUN
- Android full native build:PASS
- Android runtime:PASS
Jest:NOT RUN。
## 结论
STEP-100 功能目标成立:
> GeoJSON Source 更新只刷新依赖它的 retained segment;无关 retained segment 不再一起刷新。
允许进入 V18 unified release。

View File

@@ -0,0 +1,54 @@
# STEP-101 RenderOrchestrator 销毁生命周期修复记录 v1
状态:**CLOSED / PASS(Native + Android runtime)**
日期:2026-10-07
## 目标与基线
- 工作区:`/Users/tei/dev/MapLibreParticleLab-step101`
- 分支:`step101-render-orchestrator-teardown`
- start HEAD:`d29e84c1a90daeea104b389d28b000c4f017d08d`
- implementation commit:`64cf8f44350eb8ec4b45360db4d3f9505ac4ac0b`(`fix: make render orchestrator teardown safe`),已 push 到 `origin/particle/main` 并回读一致。
- 工作合同:`docs/40_任务/STEP-101_RenderOrchestrator销毁生命周期修复_Codex任务_v2.md`,第 13 节优先于前文冲突项。
- V18 为不可变已发布版本;本阶段未创建、发布或标记 V19。
- 目标:修复 RenderSource/Tile 销毁时 observer 可能回调已析构的 RenderOrchestrator readiness mutex/state 的生命周期漏洞。
V18 成员声明使 readiness mutex/state 早于 `renderSources` 自动析构。Tile 销毁时同步发出 `Cancelled` 回调的路径可达,且与现场 Android destroyed mutex SIGABRT 高度吻合;没有完整符号化栈与修复后等价实机结果,暂不把它写成唯一确认的现场根因。
## 必做项进度
- [x] `RenderSource::setObserver(nullptr)` 恢复 NullObserver,保留正常 observer 转发。
- [x] Orchestrator 析构:保留 contextLost layer handling;第一次 tagged drain;detach RenderSource、GlyphManager、ImageManager observer;在 readiness state 存活时清理 sources;第二次 tagged drain。
- [x] 静态审计独立 render-job 队列:`waitForEmpty()` 不排空该队列;OpenGL context 析构的 `runRenderJobs(true)` 属于后续 backend 清理,测试中也单独调用 `runRenderJobs()`。不能以双 drain 代替 render-job 验收。
- [x] 新增非 Jest C++ 测试源码:默认/真实/detach observer、三种 tile callback、Cancelled、source 清理后第二次 drain、render-job 单独处理和重复 teardown。helper 像生产代码一样注册 Orchestrator observer,并断言 source 销毁前旧 observer 已被替换。
- [x] Android Validation 增加 T1 RESET_ONCE、T2 RESET_LOOP_20、T3 RESET_WITH_DYNAMIC_GEOJSON、T4 RESET_WITH_DIAGNOSTICS;通过 Activity recreate 触发 MapView 销毁/新建,并输出 SurfaceView detach、nativeReset、resetRenderer、style ready 与 diagnostics 日志供逐次配对。
- [x] 只读审计正常 `renderSources.erase()` / `clearData()`:两者在 Orchestrator 存活期间执行;TileCache 析构等待其延迟释放任务结束。本阶段没有扩大其行为修改,运行态异步边界仍待 RDC 验证。
- [x] 静态核对 STEP-100 locality、Fast Path、`singleContinuousSegment`、`Source::isLoaded()` 等代码未被本次 diff 修改。
- [x] Native:Android full native build PASS;focused Android C++ TestRunner 6/6 PASS。Mac host 因 libuv 环境阻塞,未虚报。
- [x] Android Validation APK build PASS;T1~T4 实机 PASS;T2 完整 20 次真实 reset/recreate,crash buffer 为空。
- [x] STEP-100 T4 R-C-R locality 回归 PASS:R0 buildDelta=0、invalidationDelta=0、reuseDelta=+3878。
- [ ] V19 candidate、NavSea 五次冷启动与正式 V19 发布:属于后续发行 Gate,尚未执行。
## 允许与禁止范围
实际变更限于 Orchestrator/RenderSource 生命周期、对应 C++ 测试、Android Validation 场景及 Java/JNI reset 观测日志。未修改 NavSea、provider/network、STEP-100 locality 实现、发布坐标或 V18 产物。未运行 Jest。
## 检查与当前结果
本次验证仅包含源码 diff 静态审计与 `git diff --check`。构建、链接、GTest、Gradle/APK、ADB、设备、NavSea、发布均 **NOT RUN**;源码交付不能记为运行态 PASS。完成报告:`docs/报告/STEP-101_RenderOrchestrator销毁生命周期修复_Codex完成报告_v1.md`。
## 阻塞与下一步
当前没有已观测编译失败;实际编译与场景行为仍未知。由 RDC 按完成报告的命令独立编译、运行 Native 测试、Android T1~T4、STEP-100 locality,并核对每次 `RESET_REQUEST` 对应 SurfaceView detach、`nativeReset`、`resetRenderer` 和新 renderer ready。随后才进入 V19 candidate 与 NavSea 等价验收;全部门槛满足后才可考虑正式发布。
## RDC 最终证据
- Android Validation APK:61,957,678 bytes;`libmaplibre.so` 39,637,512 bytes;SHA-256 `14cbd35ed0bc1366389ddea69925901013e3b209ed3575462421a7920c73c721`。
- T1~T4 teardown:全部 PASS;T2 20 次 reset;动态 GeoJSON / diagnostics 条件均无 crash。
- STEP-100 locality:global revision 62→894;R0 build 1→1、invalidation 19→19、reuse 12→3890;PASS。
- Android C++ focused TestRunner:4 个 `RenderSegmentLocality` + 2 个 `RenderOrchestratorTeardown` 全部 PASS;status=0。
- Mac host GTest:ENV BLOCKED(本机缺可用 libuv pkg-config),不影响 Android full native/device 闭环结论。
- Jest:NOT RUN。
**STEP-101 CLOSED / PASS。** 下一阶段进入 V19 candidate → NavSea 等价冷启动 Gate → 不可变公开发行。

View File

@@ -0,0 +1,536 @@
# STEP-099 RenderSource 就绪与 RenderSegment 失效诊断 — Codex 完成报告 v1
## 1. 执行结论
**CLOSED / PASS(诊断阶段完成)**。
STEP-099 按任务书保持“纯诊断、fail-closed、不优化正式语义”的范围完成。Renderer/Source/RenderSegment diagnostics、Android Test App T1~T4 场景、定向 native 测试和 Android 实机运行证据均已取得,Q1~Q7 可以据实回答。
本 Step **没有**修改 retained cache 算法、fast path gate、`singleContinuousSegment`、multi-continuous 行为、segment-local invalidation、RenderSegment 执行语义、NavSea、Source `isLoaded()` 定义或 RN 业务 API 语义。
## 2. 基线与 Git
- 仓库:`/Users/tei/dev/MapLibreParticleLab-step099`
- branch:`step099-render-source-diagnostics`
- frozen release tag:`maplibre-particle/rn/13.4.1-particle.17`
- frozen release commit / start base:`e8b451fe70054107773e82484c80e90df3889147`
- implementation commit:`8a5214d92dd2f8899db76d2194da42999fa0c87d`
- implementation message:`diagnostics: add STEP-099 render source readiness audit`
- Integration/NavSea:未修改。
- 文档 closeout commit、push result、`origin/particle/main` Final HEAD:Git 提交无法在自身内容中自指其最终 SHA,按项目既有规则由最终交付回读记录。
## 3. 实际修改范围
生产源码 / diagnostics:
- `include/mbgl/renderer/render_segment.hpp`
- `src/mbgl/renderer/render_orchestrator.cpp`
- `src/mbgl/renderer/render_orchestrator.hpp`
- `src/mbgl/renderer/render_segment_json.hpp`
- `src/mbgl/renderer/render_source.hpp`
- `src/mbgl/renderer/retained_underlay_runtime_state.hpp`
- `src/mbgl/renderer/sources/render_tile_source.cpp`
- `src/mbgl/renderer/sources/render_tile_source.hpp`
- `packages/maplibre-particle-rn/src/index.ts`
- `packages/maplibre-particle-rn/src/types.ts`
测试 / Test App:
- `test/renderer/retained_underlay_cache.test.cpp`
- `platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt`
- `platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/Step099ReadinessFixture.kt`
项目记录:
- `docs/50_Step记录/STEP-099_RenderSource就绪与RenderSegment失效诊断_v1.md`
- `docs/报告/STEP-099_RenderSource就绪与RenderSegment失效诊断_Codex完成报告_v1.md`
- `粒子开发记录.md`
## 4. 诊断能力
### 4.1 全局 readiness
新增只读 snapshot:
- `styleLoaded`
- `imageManagerLoaded`
- `renderOrchestratorLoaded`
- `spriteLoaded`
- `hasFadingTiles`
- global retained `contentRevision`
- RenderSegment plan/config/topology 信息
- 最近 mutation sequence / reason / source / affected segment diagnostics
正式 loaded 路径保持原式:
```cpp
renderTreeParameters->loaded = updateParameters->styleLoaded && isLoaded();
```
diagnostics snapshot 在旁路采样,失败被 best-effort 隔离,不决定正式 loaded。
### 4.2 per-source readiness
每个 RenderSource additive 暴露:
- sourceId / sourceType
- enabled / isLoaded
- tileBacked
- tileCount / incompleteTileCount / loadedTileCount
- hasFadingTiles
- last source/tile mutation sequence
- last tile action / reason
默认非 tile source 返回完整显式初始化的空统计,Android NDK `-Werror` 下已验证通过;未改变任何 Source `isLoaded()` 实现。
### 4.3 RenderSegment 只读依赖
每个 segment additive 暴露:
- segmentIndex / strategy / beginIndex / endExclusive
- `dependentSourceIds`
- `unknownDependencies`
规则:
- 从实际 ordered layers 的 sourceId 推导;
- shared source 可出现在多个 segment;
- source-less background 不制造假依赖;
- symbol/custom 等无法仅靠 sourceId 完整表达的依赖标记为 unknown/global;
- 依赖图只用于诊断,不参与 cache/loaded/fast path/execution。
### 4.4 mutation 定位
Source/tile mutation 复用已有回调记录 sequence/source/reason/action。原 `retainedUnderlayContentRevision` 的增长次数与正式调用路径不变;diagnostics 不增加额外 revision。
query 路径不清理或修改 event history,重复 snapshot/JSON 查询保持无副作用。
## 5. Native 定向测试
### 5.1 Metal host 定向 GTest
实际执行:
```bash
cmake --preset macos-metal
cmake --build build-macos-metal --target mbgl-test-runner --parallel 3
build-macos-metal/mbgl-test-runner --gtest_filter='RenderSegmentDiagnostics.*'
```
结果:
- `RenderSegmentDiagnostics.*`:**6/6 PASS**
- 新增 `ReadinessSnapshotMapsSourcesToSegmentsWithoutSideEffects`:PASS
- 覆盖 source loaded/unloaded、tile stats、source→segment、shared source、source-less、重复 query、topology/cache/content revision 无副作用。
证据:
`artifacts/maplibre-particle/android-validation/step099-metal-gtest.log`
SHA-256:
`f0618750ead03eae1b251126dd49488a9269a120373a66e3453fa9aaa1dc0f10`
说明:该 6/6 发生在 Android NDK 首轮暴露“默认 readiness struct designated initializer 未显式列完 optional 字段”之前;随后仅补全默认空字段初始化。最终源码由 Android 全量 native build 成功覆盖。为了在最终源码上重跑 Metal,重新 configure 时当前 Mac 缺 `libuv` pkg-config,configure fail;没有安装系统依赖来制造 PASS。
### 5.2 Vulkan
`macos-vulkan` configure:**BLOCKED / NOT RUN TESTS**。
原因:
```text
The following required packages were not found:
- libuv
```
这是当前 Mac host 环境依赖,不是 STEP-099 源码编译错误。
### 5.3 Jest
**NOT RUN**。用户未要求 Jest,任务未加入 Jest。
## 6. Android 构建与设备
### 6.1 首轮 Android 编译问题
首轮 x86_64 OpenGL build 在 Android NDK `-Werror` 下发现:
```text
render_source.hpp:84:51:
error: missing field 'tileCount' initializer
[-Werror,-Wmissing-designated-field-initializers]
```
修复仅补全 `RenderSourceReadinessStats` 默认空字段初始化,不改变运行语义。
随后 native C++ 已越过原失败点并完整编译。
### 6.2 磁盘阻塞与恢复
第一次完整 native build 后,在:
`MapLibreAndroidTestApp:stripOpenglDebugDebugSymbols`
因 Mac 仅剩约 2 GiB 出现:
```text
No space left on device
```
RDC 只清理可再生 Gradle 9.5.1 cache / 本轮失败中间产物,保留 Android native object;可用空间恢复到约 12 GiB 后增量重跑。
### 6.3 最终 APK
命令:
```bash
./tools/particle_android_validation/build_validation_apk.sh
```
最终结果:
- Gradle:**BUILD SUCCESSFUL in 4m 54s**
- actionable tasks:86(7 executed / 79 up-to-date)
- variant:OpenGL Debug
- ABI:x86_64
- APK:61,923,024 bytes
- `libmaplibre.so`:39,602,856 bytes
- APK SHA-256:
`5de73f0358b835a78d3ae7e2ac22f4efdb2a66775b8c63654a8bb7313957d90a`
构建时 metadata:
- base commit:`e8b451fe70054107773e82484c80e90df3889147`
- `git_dirty=true`
- 构建内容与随后形成的 implementation commit `8a5214d92` 源码一致;当前 source tree 对 implementation commit clean,剩余仅 docs closeout。
### 6.4 Android 设备
Mac ADB 直接连接:
- serial:`192.168.200.139:5555`
- model:`FZG1-2`
- Android:13
- API:33
- ABI:x86_64
APK 覆盖安装成功,`ParticleCoreLayerValidationActivity` 冷启动成功。
运行 log:
`artifacts/maplibre-particle/android-validation/step099-logcat.txt`
log SHA-256:
`0f15d75dde77e7dd2f4d98c46a4c1501a628205e8aae62130b0cb96db6b8482f`
## 7. T1~T4 实测
### T1 STATIC
场景:
- R0:静态 GeoJSON retained
- C:本地静态 RasterArray field 驱动 WeatherColor + Particle
- R1:静态 retained
- camera fixed
结果:
- 启动阶段 1 个 false window:204 ms;
- 随后 `renderOrchestratorLoaded=true` 稳定;
- `particle-fixture` 与 `step099-static` 均 loaded;
- retained cache 建立后持续 reuse;
- fast path 进入 `active=true / eligible=true / reason=none`;
- 没有持续 style-not-loaded。
aggregate:
```text
falseWindowCount=1
falseWindowAvgMs=204
falseWindowMaxMs=204
openFalseWindowMs=0
```
结论:静态基线可稳定进入 loaded/cache reuse/fast path。
### T2 GEOJSON_1HZ
两个 GeoJSON source 每 500 ms 交错更新,因此每个约 1 Hz。
实际:
- 总 source update:122 次,约 61 次/源;
- 60 秒量级观察中,出现 10 个 loaded=false window;
- 平均 203 ms;
- 最大 205 ms;
- 最终 open window = 0;
- 总 false 时间约 2.03 s,约占 60 s 的 3.4%,不是“几乎始终 false”。
aggregate:
```text
falseWindowCount=10
falseWindowAvgMs=203
falseWindowMaxMs=205
openFalseWindowMs=0
```
同时 final sample:
- global contentRevision 已到 1679;
- R0 仅依赖 `step099-static`,但 cache build=241、invalidation=362;
- R1 依赖动态 GeoJSON,也同样 build=241;
- mutation diagnostics 能正确指出动态 source 只 `affected=[2]`,但正式 cache invalidation 仍非局部。
### T3 PERMANENT_UNREADY
额外加入:
`step099-unready-raster-array`
它:
- 没有 layer;
- 因此 `enabled=false`;
- host-submission 无 field;
- `isLoaded=false`。
其他主要 source 均 loaded。
实测:
- `renderOrchestratorLoaded=false` 持续;
- retained segment cache 持续 fallback;
- `lastInvalidationReason=style-not-loaded`;
- 观察结束仍没有恢复。
aggregate:
```text
falseWindowCount=0
openFalseWindowMs=21459
```
结论:即使 source 与当前 layer/segment 无关且 disabled,只要仍在 `renderSources` 且 `isLoaded=false`,V17 全局 `RenderOrchestrator::isLoaded()` 就保持 false。
### T4 R-C-R
结构:
- R0:`deps=[step099-static]`
- C:`deps=[particle-fixture]`,WeatherColor + Particle
- R1:`deps=[org.maplibre.annotations, step099-dynamic, step099-static]`
- `step099-dynamic` 约 1 Hz 更新
实际:
- dynamic update:61 次;
- loaded false window:4;
- 平均 203 ms;
- 最大 207 ms;
- global contentRevision:1760 → 2540;
- mutation diagnostics:`step099-dynamic` 对应 `affected=[2]`;
- R0 并不依赖 `step099-dynamic`,但:
- build:1 → 121
- invalidation:20 → 200
- R1 同步得到相同级别 build/invalidation 增长;
- fast path 在稳定窗口可恢复 active,但 global revision / transient readiness 会反复制造 full CPU work。
aggregate:
```text
falseWindowCount=4
falseWindowAvgMs=203
falseWindowMaxMs=207
openFalseWindowMs=0
```
结论:依赖诊断已经能准确表达“R1 source-local”,但 V17 正式 content revision/cache invalidation 仍是 global。
## 8. Q1~Q7
### Q1:单个 permanently unloaded source 会造成 V17 style-not-loaded 吗?
**YES / CONFIRMED。**
T3 中 `step099-unready-raster-array` 无 layer、`enabled=false`、`isLoaded=false`;其他主要 source loaded,但整体 `renderOrchestratorLoaded=false` 持续 21.459 s 未恢复,retained cache 持续 `style-not-loaded` fallback。
源码事实与运行事实一致:`RenderOrchestrator::isLoaded()` 遍历全部 `renderSources`,不按 enabled/segment dependency 排除 source。
### Q2:1 Hz GeoJSON 会周期性让 isLoaded=false 吗?平均/最大窗口?
**YES / CONFIRMED。**
T2 双 source 交错各约 1 Hz:
- 10 次 false window;
- avg 203 ms;
- max 205 ms。
T4 单动态 source 约 1 Hz:
- 4 次 false window;
- avg 203 ms;
- max 207 ms。
### Q3:错开的多个 1 Hz GeoJSON 会导致 global loaded 几乎无法稳定为 true 吗?
**NO / 本次观测不支持。**
T2 约 60 s 中总 false 时间约 2.03 s,约 3.4%;绝大多数时间 global loaded=true。它确实产生 readiness thrash,但不是“几乎永远 false”。
### Q4:GeoJSON tile mutation 会推进 global retainedUnderlayContentRevision 并影响 retained cache 吗?
**YES / CONFIRMED。**
T4 global contentRevision 1760 → 2540。仅依赖静态 source 的 R0 也从 build 1 → 121、invalidation 20 → 200。
### Q5:不引用 GeoJSON 的 retained segment 仍会受它的 loaded/fading/tile mutation 影响吗?
**YES。**
运行证据:
- T4 diagnostics 正确报告动态 source `affected=[2]`;
- 但 R0 `deps=[step099-static]` 仍随动态 source mutation 重建/失效。
源码证据:
- `retainedRangeIsStable(begin,end)` 虽为 range API,但最后遍历 **全部 `renderSources`**,任一 source `hasFadingTiles()` 都让任意 retained range unstable。
本次 T4 稳态 `fade=false`,因此 R0 的实际反复失效主要直接证明 global content revision/tile mutation;GLOBAL_FADE 属源码确认、未在本轮场景主动触发的全局 gate。
### Q6:`tryRenderParticleFastFrame` 仍有哪些 global gate 会阻挡未来 localized cache CPU fast path?
**确认仍存在:**
1. **GLOBAL_UPDATE_SIGNATURE**
- `particleFastPathNonCameraInputsMatch` 全局比较:
- styleLoaded / spriteLoaded
- light
- images
- **sources**
- **layers**
- fontFaces / fileSource / glyphURL
- transition / placement / tile LOD 等全局输入。
2. **GLOBAL_CONTENT_REVISION**
- `particlePreparedContentRevision == orchestrator.getRetainedUnderlayContentRevision()`。
3. **GLOBAL_FADE**
- `particleFastPathDynamicState` 遍历全部 renderSources 汇总 fading;
- `retainedRangeIsStable` 也遍历全部 renderSources。
4. **GLOBAL_PENDING / TRANSITION / REPAINT**
- pendingChanges
- renderLight transition
- 任意 layer transition
- 任意非粒子 layer repaint
- placement transition。
5. **GLOBAL_ANNOTATION_MUTATION**
- AnnotationManager `hasPendingChanges()`。
6. **RETAINED REVISION / CACHE / TOPOLOGY / BOUNDARY**
- retained cache validity/static revision
- global retained revision tracker
- topology revision
- boundary/config revision。
7. **singleContinuousSegment**
- 仍要求 plan 恰好能由一个 Continuous segment 表达;
- multi-continuous 继续 fail closed,本 Step 未修改。
因此即使未来 cache invalidation 变成 source/segment-local,fast path 仍有多组 global gate 需要后续 Step 单独设计,不能在 STEP-099 顺手放宽。
### Q7:blocker 分类
**运行态 CONFIRMED:**
- `PERMANENT_SOURCE_UNLOADED`
- `DYNAMIC_SOURCE_READINESS_THRASH`
- `GLOBAL_CONTENT_REVISION`
**源码 CONFIRMED,本轮未主动触发独立运行场景:**
- `GLOBAL_FADE`
- `GLOBAL_UPDATE_SIGNATURE`
**OTHER:**
- global pendingChanges
- placement/light/non-particle transition/repaint
- annotation mutation
- topology/boundary/revision/cache validity
- singleContinuous fail-closed
## 9. 任务边界审计
确认未改:
- retained cache 核心算法;
- fast path gate 逻辑;
- `singleContinuousSegment`;
- multi-continuous 策略;
- segment-local invalidation 正式语义;
- RenderSegment execution;
- NavSea;
- private NavSea Native API;
- RN 业务 API;
- Source `isLoaded()` 定义。
新增 dependency / affectedSegmentIndices 只是观察信息;它没有参与正式 invalidation。
## 10. 证据文件
忽略目录:
`artifacts/maplibre-particle/android-validation/`
主要文件:
- `build-info.txt`
- `build.log`
- `MapLibreAndroidTestApp-validation-opengl-debug-x86_64.apk`
- `step099-logcat.txt`
- `step099-device-info.txt`
- `step099-metal-gtest.log`
- `step099-cmake-metal.log`
APK SHA-256:
`5de73f0358b835a78d3ae7e2ac22f4efdb2a66775b8c63654a8bb7313957d90a`
STEP-099 log SHA-256:
`0f15d75dde77e7dd2f4d98c46a4c1501a628205e8aae62130b0cb96db6b8482f`
## 11. 下一阶段建议
STEP-099 只负责定位,不在本 Step 修复。
下一阶段应基于本轮证据拆开处理:
1. 先决定 global source readiness 是否要引入“影响当前 style/render segment 的 readiness 范围”,解决 permanently unready / transient unrelated source 对全局 loaded 的影响;
2. 再设计 source/segment-local content revision 与 invalidation,避免 R1 GeoJSON mutation 重建 R0;
3. 最后单独审查 fast-path global signature/fade/pending/placement 等 gate,不能把 cache locality 与 fast-path locality 混成一个修改;
4. 保持 Style Z-order 唯一事实来源和当前通用 Render Segment 模型。
## 12. 最终状态
**STEP-099 CLOSED / PASS。**
Pass 含义仅为:
- diagnostics 能回答任务问题;
- Native 定向测试已有 PASS 证据;
- 最终 Android x86_64 OpenGL APK BUILD SUCCESS;
- FZG1-2 T1~T4 实跑完成;
- Q1~Q7 有源码/运行证据;
- 未改变正式渲染语义。
不代表:
- Vulkan host 已通过;
- iOS runtime 已测试;
- 下一阶段 localized readiness/invalidation 已实现;
- fast path global gates 已优化。

View File

@@ -0,0 +1,454 @@
# STEP-100 SourceScoped RenderSegment 局部失效 — 完成报告 v1
## 1. 最终结论
**CLOSED / PASS(功能与 Android 运行态目标确认)**。
Codex 实现使用:
- model:`gpt-6-sol`
- reasoning effort:`high`
RDC 独立完成:
- 源码 diff 审计;
- Android x86_64 OpenGL 全量 native build;
- Validation APK;
- FZG1-2 自动安装与 T1/T2/T3/T4;
- STEP-100 60 秒 locality window;
- validation build script 小型工程修复;
- Git diff/status 审计。
本 Step 核心目标已确认:
> 动态 GeoJSON Source 更新只影响依赖它的 RETAINED Segment;无关 R0 cache 不再跟着 invalid/rebuild,并且在 global loaded=false 时仍可安全 reuse。
Native host GTest 因当前 Intel Mac 缺 `libuv` 未执行;曾尝试 Homebrew 安装,但 Homebrew 要连带升级 CMake/OpenSSL/Python 且 Intel 已无 bottle 支持,因此 RDC 主动终止,未把环境变化作为任务依赖。Android 最终 C++ native build 与设备运行态用于本轮功能验收。
Jest:**NOT RUN**,用户未要求。
## 2. Git / 基线
- worktree:`/Users/tei/dev/MapLibreParticleLab-step100`
- branch:`step100-segment-local-source-invalidation`
- start HEAD:`c4b481fd2c5b5da20d31f6ae54011f0552d7dbd0`
- start remote:`origin/particle/main = c4b481fd2c5b5da20d31f6ae54011f0552d7dbd0`
- frozen V17 tag:`maplibre-particle/rn/13.4.1-particle.17`
- frozen V17 commit:`e8b451fe70054107773e82484c80e90df3889147`
- V17 tag/release worktree:未修改。
最终 STEP-100 commit / push / remote HEAD 在 Git 收尾后由最终交付回读记录,避免文档自引用。
## 3. 实际修改
生产源码:
- `include/mbgl/renderer/render_segment.hpp`
- `src/mbgl/renderer/render_orchestrator.hpp`
- `src/mbgl/renderer/render_orchestrator.cpp`
- `src/mbgl/renderer/renderer_impl.cpp`
- `src/mbgl/renderer/retained_underlay_cache.cpp`
- `src/mbgl/renderer/retained_underlay_runtime_state.hpp`
- `src/mbgl/renderer/render_segment_json.hpp`
- `packages/maplibre-particle-rn/src/types.ts`
Test App / tests:
- `platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/Step099ReadinessFixture.kt`
- `platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt`
- `test/renderer/retained_underlay_cache.test.cpp`
工具:
- `tools/particle_android_validation/build_validation_apk.sh`
- RDC 修复 `unzip | awk ... exit` 在 `pipefail` 下偶发 SIGPIPE/141;
- 仅移除 awk 的提前 exit,不改变 APK 验证语义。
文档:
- `docs/40_任务/STEP-100_SourceScoped_RenderSegment局部失效_Codex任务_v2.md`
- `docs/50_Step记录/STEP-100_SourceScoped_RenderSegment局部失效_v1.md`
- 本报告;
- `粒子开发记录.md`
## 4. 生产设计
### 4.1 Source-scoped revision
新增正式 source-scoped revision bookkeeping。
明确的 Source/Tile mutation:
- source diff;
- feature state;
- tile changed;
推进对应 sourceId revision。
现有 global `retainedUnderlayContentRevision` 继续增长,供:
- compatibility;
- diagnostics;
- Fast Path fail-closed。
RETAINED Segment cache 不再直接以 global Source/Tile revision 作为内容 revision。
### 4.2 Segment dependency
正式 renderer 与 diagnostics 共用 source→segment 依赖推导。
规则:
- layer sourceId 决定 source dependency;
- source-less background 不制造假 dependency;
- shared source 可影响多个 segment;
- symbol 的 glyph/image/placement 作为明确 global prerequisite;
- 真正无法证明的 dependency fail closed。
### 4.3 Per-segment readiness
未修改:
- `Source::isLoaded()`;
- `renderTreeParameters->loaded = styleLoaded && isLoaded()`。
full CPU retained path 增加 per-segment source readiness/stability:
- dependent source unloaded → 仅该 Segment fallback;
- dependent source fading → 仅该 Segment unstable;
- unrelated source unloaded/fading → 不影响该 Segment;
- style/image/sprite/glyph/placement/context 等 global prerequisite 仍 fail closed。
### 4.4 Mixed Build / Reuse / Fallback
移除 retained full-CPU path 的 all-or-none `useRetainedRanges` 依赖。
每个 RETAINED Segment独立决定:
- Build
- Reuse
- Fallback
主 buffer 按 Style Segment 顺序执行:
- RETAINED Build/Reuse → composite cache
- RETAINED Fallback → live render only that range
- CONTINUOUS → live render
因此允许:
```text
R0 = Reuse
C = Live
R1 = Fallback
```
不再因为 R1 fallback 把 R0 一起变成 live redraw。
Fast Path gate / `singleContinuousSegment` / multi-continuous Fast Path:**未修改**。
## 5. Android build
首次标准 validation script 的 Gradle 本体实际结果:
```text
BUILD SUCCESSFUL in 12s
86 actionable tasks: 6 executed, 80 up-to-date
```
但脚本在 Gradle 成功后以 141 退出。RDC 用 `bash -x` 精确定位到:
```bash
LIB_BYTES="$(unzip ... | awk '/.../ {print $1; exit}')"
```
在 `set -o pipefail` 下触发 SIGPIPE。
RDC 仅去掉 awk 的 `exit`,重跑标准脚本:
```text
RC=0
[validation-apk] BUILD SUCCESS
```
最终 APK:
- variant:OpenglDebug
- ABI:x86_64
- apk bytes:61,956,640
- libmaplibre.so bytes:39,636,472
- SHA-256:
`86e22bf081bf7d3196f0bf0e7c56af51bde01e9f0b01e0bab238cb794273315a`
证据:
`artifacts/maplibre-particle/android-validation/`
## 6. 设备
- serial:`192.168.200.139:5555`
- model:FZG1-2
- Android:13
- API:33
- ABI:x86_64
APK 覆盖安装成功,`ParticleCoreLayerValidationActivity` 冷启动成功。
STEP-100 log:
`artifacts/maplibre-particle/android-validation/step100-logcat.txt`
log SHA-256:
`e9d18ea987d12cb30e321d0d9c669b2588972aac89e5277920bf1fa9fee8146b`
## 7. T1 STATIC
运行约 20 秒。
aggregate:
```text
falseWindowCount=1
falseWindowAvgMs=203
falseWindowMaxMs=203
openFalseWindowMs=0
```
最终:
- R0 build=1
- R0 reuse=1217
- R0 decision=reuse
- R1 build=1
- R1 reuse=1187
- R1 decision=reuse
静态 R-C-R 正常。
## 8. T4 R-C-R 主验收
结构:
```text
R0 -> step099-static
C -> particle-fixture
R1 -> step099-dynamic (+ framework annotation source if present)
```
window marker:
```text
baseline tick=8 17:05:21.642
final tick=130 17:06:23.240
```
窗口约 61.6 秒。
dynamic update 总计:68 次。
global revision:
```text
128 -> 973
```
说明 global compatibility revision **仍正常增长**,没有通过停掉 global revision“作弊”。
### R0
baseline:
```text
build=1
reuse=132
invalidation=20
srcRev=5127273714767523329
decision=reuse
```
final:
```text
build=1
reuse=4042
invalidation=20
srcRev=5127273714767523329
decision=reuse
```
Delta:
```text
buildDelta = 0
invalidationDelta = 0
reuseDelta = +3910
sourceRevision = unchanged
```
**硬门槛 PASS。**
### R1
baseline:
```text
build=5
invalidation=26
```
final:
```text
build=135
invalidation=211
```
动态 Source mutation 正常驱动 R1。
mutation diagnostics 持续:
```text
sourceId=step099-dynamic
affected=[2]
scope=source-local-diagnostic
```
### loaded false windows
T4:
```text
falseWindowCount=5
falseWindowAvgMs=202
falseWindowMaxMs=203
openFalseWindowMs=0
```
即 transient global loaded=false 仍存在,但不再让 R0 build/invalidation 增长。
## 9. T2 双 GeoJSON readiness 回归
约 35 秒。
aggregate:
```text
falseWindowCount=6
falseWindowAvgMs=203
falseWindowMaxMs=205
openFalseWindowMs=0
```
R0 在动态窗口保持:
- build=1
- invalidation=18
- revision unchanged
- reuse 持续增长。
R1 随两个动态 GeoJSON 持续 build/invalidate。
结论:
**DYNAMIC_SOURCE_READINESS_THRASH 仍是 global loaded 事实,但不再误伤无关 retained cache。**
## 10. T3 permanently unready unrelated source
`step099-unready-raster-array`:
- no layer;
- enabled=false;
- isLoaded=false。
global:
```text
renderOrchestratorLoaded=false
openFalseWindowMs=21468
```
约 21.468 秒持续 false。
与此同时:
R0:
- build=1
- invalidation=19
- reuse:11 → 1200
- decision=reuse
R1:
- build=1
- invalidation=19
- reuse:11 → 1200
- decision=reuse
因此:
> global loaded 仍保持旧兼容语义,但 unrelated permanently-unready source 已不能把无关 RETAINED Segment 拖成 style-not-loaded fallback。
**硬门槛 PASS。**
## 11. Mixed fallback
T4 中 R1 `fallbackCount` 从约 21 增长到 76,而 R0 fallback 计数保持不变,同时 R0 reuse 持续增长。
这证明运行期间真实发生了:
```text
R0 = Reuse
R1 = transient Fallback / rebuild
```
而不是只有“两个段都 ready”的理想场景。
未观察到 crash / ANR。
## 12. Fast Path 边界
RDC diff 审计确认未修改:
- `tryRenderParticleFastFrame`
- `particleFastPathNonCameraInputsMatch`
- `singleContinuousSegment`
- multi-continuous fail-closed
- global Fast Path signature/fade/contentRevision/pending/placement 等 gate。
STEP-100 只修 full CPU retained locality。
## 13. Native host tests
Codex 已新增 `RenderSegmentLocality.*` C++ 测试源码。
RDC 尝试恢复 `macos-metal` test runner,但 host configure 需要 `libuv`,当前未安装。
尝试 `brew install libuv` 时发现 Intel Homebrew 会连带升级:
- CMake
- OpenSSL
- Python
- ca-certificates 等
且当前 Intel macOS 已无官方 bottle 支持。为避免污染开发环境,RDC 主动停止。
因此:
- Native host GTest:**ENV BLOCKED / NOT RUN**
- Android C++ full native build:**PASS**
- Android device runtime:**PASS**
## 14. Jest / iOS
- Jest:NOT RUN。
- iOS runtime:本 Step 未运行。
- V18 unified release:在 STEP-100 Git 收尾后单独执行。
## 15. 最终判定
STEP-100 的核心实现目标全部成立:
1. global revision 继续增长;
2. R1 GeoJSON mutation 不再 invalid/rebuild R0;
3. R0 buildDelta=0;
4. R0 invalidationDelta=0;
5. R0 reuseDelta>0;
6. transient unrelated unloaded 不影响 R0 reuse;
7. permanent unrelated unloaded 不影响 R0 reuse;
8. mixed Reuse/Fallback 实际发生;
9. Fast Path 语义未放宽。
因此本 Step 功能与 Android runtime 验收:
**PASS / CLOSED**。
下一步允许进入 V18 unified release。

View File

@@ -0,0 +1,184 @@
# STEP-101 RenderOrchestrator 销毁生命周期修复 Codex 完成报告 v1
## 1. 执行结论
**PASS(RDC Native / Android runtime):V18 teardown destroyed-mutex 回归已修复并通过设备闭环。** Codex 阶段仅交付源码;随后 RDC 独立完成 Android full Native build、focused C++ TestRunner、STEP-101 T1~T4、20 次真实 renderer reset/recreate、STEP-100 locality 回归与 crash buffer 检查。V19 尚未在本报告阶段公开发布。
## 2. Git 基线
- 工作区:`/Users/tei/dev/MapLibreParticleLab-step101`
- 分支:`step101-render-orchestrator-teardown`
- start HEAD:`d29e84c1a90daeea104b389d28b000c4f017d08d`
- implementation commit:`64cf8f44350eb8ec4b45360db4d3f9505ac4ac0b`
- implementation message:`fix: make render orchestrator teardown safe`
- push result:**PASS**,`origin/particle/main` 独立回读与 implementation commit 一致。
- 文档 closeout commit / 最终远端 HEAD:Git 提交无法在自身内容中自指其最终 SHA,按项目既有规则由最终交付回读记录。
- 子模块:`git submodule status` 列出的子模块均以 `-` 开头,表示当前工作区未初始化;本轮未修改或初始化子模块。
- V18 Android/RN `13.4.1-particle.18`、iOS `6.28.0-particle.18` 保持不变;V19 **未发布**。
## 3. 根因与实现
V18 `readinessDiagnosticsMutex` 等成员声明在 `renderSources` 之后,按 C++ 反序析构会先于 sources 消失。RenderSource/Tile 销毁存在同步 `Cancelled` callback 到 Orchestrator 的可达路径,随后可能锁已销毁 mutex。这是已证实的生命周期漏洞,与 Android FORTIFY 报错高度吻合。虽未取得精确到最终 callback 的完整符号化现场栈,但同一设备上 V18 条件稳定复现,而 STEP-101 修复后 T1~T4、20 次 reset、动态 GeoJSON 与 diagnostics 条件均不再崩溃,因此将其作为本次 Android teardown 回归的已确认工程根因。
当前源码的析构顺序为:处理 contextLost layers → 第一次 `threadPool.waitForEmpty()` → detach 各 RenderSource、GlyphManager、ImageManager observer → readiness state 仍活着时 `renderSources.clear()` → 第二次 `threadPool.waitForEmpty()` → 常规成员析构。`RenderSource::setObserver(nullptr)` 恢复 NullObserver,tile 回调无需新增空指针分支。显式 detach 与清理建立 owner teardown 合同,不依赖今后成员顺序碰巧安全;没有增加 teardown flag。
render-job 队列独立于 tagged scheduler work。静态检查显示 tile/worker 销毁可投递捕获 render data/bucket 的 render jobs;`waitForEmpty()` 不处理它们。OpenGL 同步 Android reset 路径先 `renderer.reset()`、再 `backend.reset()`,其 Context 析构调用 `runRenderJobs(true)`;若启用 async renderer cleanup,backend 的清理时点另由后续生命周期决定。Metal Context 与 Vulkan backend 也有各自的 `runRenderJobs(true)` 清理入口,均未在本轮运行验证。测试显式 `runRenderJobs()`,但没有构建或设备证据证明所有 backend 模式均安全。这是 RDC 必须核对的剩余风险。
正常 source remove 使用 `renderSources.erase()`,`clearData()` 使用 `renderSources.clear()`;两者均在 Orchestrator 实体及 readiness state 存活时执行。`TileCache::~TileCache()` 会清理 pending releases 并等待先前 deferred deletion 结束。这个审计没有证成任意外部并发调用都安全,本轮不修改正常 erase/clearData 行为,也不声称所有 source replacement 异步问题已解决。
## 4. 实际修改与审计结论
源码/测试差异共七个文件:
1. `src/mbgl/renderer/render_orchestrator.cpp`:显式 detach、source clear 与双 drain。
2. `src/mbgl/renderer/render_orchestrator.hpp`:仅为 focused test 开放 friend helper。
3. `src/mbgl/renderer/render_source.cpp`:nullptr observer 回退 NullObserver。
4. `test/renderer/render_orchestrator_error.test.cpp`:新增两个非 Jest teardown tests;helper 注册 Orchestrator observer,测试断言 source 销毁前 observer 已 detach、Cancelled 不再到旧 observer、第二次 drain 和独立 render-job 队列。
5. `platform/android/MapLibreAndroid/src/cpp/map_renderer.cpp`:`nativeReset` 与 `resetRenderer` 入口/完成日志,仅用于配对观测,未改变 reset 调用顺序。
6. `platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/renderer/surfaceview/SurfaceViewMapRenderer.java`:SurfaceView detach 与 Java `nativeReset()` 进入/返回日志。
7. `platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/particle/ParticleCoreLayerValidationActivity.kt`:T1~T4,tile ready/diagnostics、动态 GeoJSON、Activity recreate、重建与完成日志;保存状态里的空场景不会在配置变更后重新从 Intent 启动。
本轮新增/更新文档:本报告、`docs/50_Step记录/STEP-101_RenderOrchestrator销毁生命周期修复_v1.md`、`粒子开发记录.md`。既有未跟踪的 STEP-101 任务书 v1/v2 没有修改。
与 v2 合同静态核对:observer、双 drain、render-job 独立审计、四个 Android 场景均有对应代码;STEP-100 locality/fast path/source loaded 语义无本次差异。T2 代码计的是 reset **请求**,20 次 Native reset 必须由 log 中每轮 Java `nativeReset`、JNI `nativeReset`、`MapRenderer::resetRenderer entered/completed` 和新 renderer ready 独立确认,不能凭 UI DONE 计数认定。T3 使用已有 T4 R-C-R fixture 并持续更新动态 GeoJSON;T4 在 reset 前额外取 diagnostics。现有 C++ test 使用最小 tile-backed Source,并非实际 Android source;设备等价验收仍不可省略。
## 5. 实际检查与未执行项目
- `git diff --check`:**PASS**(已跟踪 diff 的 whitespace/error 静态检查;未跟踪的新文档不在此命令检查范围)。
- 源码 diff/合同静态审计:完成;补了 observer detach 断言、Java/JNI reset 边界日志、T3 mutation 证据及场景恢复边界。
- CMake/Ninja/Native compile、C++ GTest:**NOT RUN**。
- Android Gradle、Validation APK、AAR/RN candidate:**NOT RUN**。
- ADB、T1~T4、crash buffer、STEP-100 locality 运行态、NavSea 冷启动:**NOT RUN**。
- Jest、iOS/Metal build 与设备、publish/tag/commit/push:**NOT RUN**。
- 产物路径、大小、SHA-256、运行日志:本轮没有新产物,均无结果。
## 6. RDC 精确验证命令与验收证据
在本工作区执行 Mac Native 定向验证(若既有 `libuv` 环境问题仍在,保留首个错误并标为环境阻塞):
```bash
cmake --preset macos-metal
cmake --build build-macos-metal --target mbgl-test-runner --parallel 3
build-macos-metal/mbgl-test-runner --gtest_filter='RenderOrchestratorTeardown.*:RenderSegmentLocality.*'
```
Android Validation APK 使用固定入口,不手拼 Gradle:
```bash
./tools/particle_android_validation/build_validation_apk.sh
```
RDC 使用显式设备 serial。每个场景先清日志、停止旧 Activity,再单独启动;`T2` 等待 20 轮结束。安装前核对 APK 文件、SHA-256、ABI 与运行设备:
```bash
adb -s 192.168.200.139:5555 install -r artifacts/maplibre-particle/android-validation/MapLibreAndroidTestApp-validation-opengl-debug-x86_64.apk
adb -s 192.168.200.139:5555 logcat -c
adb -s 192.168.200.139:5555 shell am force-stop org.maplibre.android.testapp
adb -s 192.168.200.139:5555 shell am start -n org.maplibre.android.testapp/.activity.particle.ParticleCoreLayerValidationActivity --es step101_scenario T2_RESET_LOOP_20
adb -s 192.168.200.139:5555 logcat -v threadtime -d > /tmp/step101-t2-logcat.txt
rg -n 'STEP101_|SurfaceView detached; nativeReset|nativeReset returned|MapRenderer::nativeReset|MapRenderer::resetRenderer|pthread_mutex_lock called on a destroyed mutex|Fatal signal|ANR' /tmp/step101-t2-logcat.txt
```
对 T1/T3/T4 分别重复相同 `logcat -c`、`force-stop`、`am start`、日志保存步骤,将 `step101_scenario` 依次替换为 `T1_RESET_ONCE`、`T3_RESET_WITH_DYNAMIC_GEOJSON`、`T4_RESET_WITH_DIAGNOSTICS`。逐轮配对 `RESET_REQUEST`、`MAPVIEW_DESTROY_ENTER/RETURN`、`SurfaceView detached; nativeReset entered`、JNI `MapRenderer::nativeReset entered/completed`、Native `resetRenderer entered/completed`、新 `STYLE_READY` 与 `NEW_RENDERER_READY`;T2 需至少 20 个完整 reset/recreate 配对,且无 SIGABRT/ANR。T3 核对 `STEP101_DYNAMIC_SOURCE_MUTATION` 在每轮 reset 前持续出现,T4 核对 `PRE_RESET_DIAGNOSTICS` 与 reset 后 diagnostics。STEP-100 T4 R-C-R 另按既有 Validation 菜单复跑,核对无关 R0 `buildDelta=0`、`invalidationDelta=0`、`reuseDelta>0`,动态 R1 正常 build/invalidation。
上述只是交接命令,**本轮均未执行**。V19 candidate 必须先于 NavSea 临时锁包和五次超过 30 秒的冷启动;crash buffer 与 locality 均通过后,才可进入不可变发布。NavSea 外部工作区/候选制品命令由 RDC 按其实际环境另行确定,本轮不伪造路径或结果。
## 7. 已知问题、未完成事项与下一步
静态审计无法证明 Android 每次 Activity recreate 都实际完成 native reset,也无法证明异步 backend 清理和真实 Source 并发场景无 late callback。Native 编译与定向测试、四个 Validation 场景、STEP-100 locality、NavSea 等价五次冷启动、crash buffer 均待 RDC;任何失败均应保留现场并按首个真实错误回传。无理由在本阶段修改 NavSea、发布 V19 或重写 STEP-100 语义。
## 8. RDC 独立验收(2026-10-07)
### 8.1 Android full Native / Validation APK
标准入口:
```text
./tools/particle_android_validation/build_validation_apk.sh
```
结果:**PASS**。
```text
variant=OpenglDebug
abi=x86_64
apk_bytes=61957678
libmaplibre_bytes=39637512
sha256=14cbd35ed0bc1366389ddea69925901013e3b209ed3575462421a7920c73c721
```
该构建实际编译了本 Step 修改后的 C++ Core、`libmaplibre.so`、JNI/Java reset 日志和 Android Validation T1~T4。
### 8.2 STEP-101 Android teardown runtime
设备:FZG1-2 / Android 13 / API 33 / x86_64 / `192.168.200.139:5555`。
- T1 `RESET_ONCE`:真实 `MapRenderer::resetRenderer entered/completed`、`nativeReset entered/completed`、新 renderer `STYLE_READY / NEW_RENDERER_READY`;PASS。
- T2 `RESET_LOOP_20`:完整运行至 iteration 20,最终 `STEP101_SCENARIO_DONE ... resetsRequested=20`;逐轮出现 destroy/reset/new renderer ready;PASS。
- T3 `RESET_WITH_DYNAMIC_GEOJSON`:reset 前动态 GeoJSON mutation 持续产生,reset 完成后新 renderer 继续 ready;PASS。
- T4 `RESET_WITH_DIAGNOSTICS`:reset 前 `getRenderSegmentDiagnostics()` 可读,随后真实 reset 完成并重建;PASS。
- 四组验收 crash buffer 均为空;未出现 `pthread_mutex_lock called on a destroyed mutex`、SIGABRT 或 ANR。
### 8.3 STEP-100 locality 回归
R-C-R T4 重新运行 60+ 秒,稳定窗口:
```text
global revision: 62 -> 894
R0 retained:
build 1 -> 1 delta 0
invalidation 19 -> 19 delta 0
reuse 12 -> 3890 delta +3878
R1 retained:
build / invalidation 持续增长
```
动态 source mutation 仍只影响对应 segment;R0 source revision 保持不变。STEP-101 teardown 修复没有破坏 STEP-100 Source→Segment locality。
### 8.4 Focused Android C++ TestRunner
Mac host `mbgl-test-runner` 仍因本机缺失可用 `libuv` pkg-config 包而 ENV BLOCKED;没有安装或升级系统依赖来掩盖该环境事实。
改用仓库已有 Android Native C++ TestRunner,x86_64 build **PASS**。RDC 为 TestRunner 临时打包真实 `test/fixtures/` 到 `data.zip`,仅满足 Android runner 既有解压契约;该临时 asset 已删除,不进入 Git。
设备过滤执行:
```text
RenderSegmentLocality.SourceRevisionsAreScopedAndRecreationUsesNewIdentity PASS
RenderSegmentLocality.ReadinessAndFadeFollowOnlyDependencies PASS
RenderSegmentLocality.MixedExecutionDrawsEachLiveRangeOnceInStyleOrder PASS
RenderSegmentLocality.FailedBuildCannotReuseOldTexture PASS
RenderOrchestratorTeardown.NullObserverAndDetachCoverEveryTileCallback PASS
RenderOrchestratorTeardown.PendingTileCancellationAndSecondDrainRepeatSafely PASS
TestRunner finished with status: '0'
```
最终 TestRunner APK(含临时 fixtures,仅作验收证据)SHA-256:
```text
e1b39b3eb035175b79dd60dbe39496ea982c2dd6e2f1593fd60dd9630f393712
```
crash buffer 为空。
首次 TestRunner 编译暴露 6 个测试源码级 C++20 designated-initializer / protected-base conversion 问题;RDC 仅修正测试源码字段补齐和显式转换,未改变生产语义。修复后 Android C++ TestRunner build 与上述 filtered tests 均 PASS。
### 8.5 结论
STEP-101 的 Native 生命周期修复达到关闭条件:
1. RenderSource observer 在 owner teardown 前显式 detach;
2. readiness state 仍存活时显式销毁 renderSources;
3. source clear 前后 tagged work 双 drain;
4. glyph/image observer 同步 detach;
5. 20 次真实 Android renderer reset/recreate 无 destroyed-mutex;
6. 动态 GeoJSON、diagnostics 条件下仍安全;
7. STEP-100 locality 保持 PASS;
8. focused C++ tests PASS。
Jest:**NOT RUN**。
V19 candidate、NavSea 五次冷启动与正式 publish/finalize 属于后续发行 Gate,不改变本 Step Native 修复的 PASS 结论。

View File

@@ -2,7 +2,9 @@
#include <cstdint>
#include <optional>
#include <set>
#include <string>
#include <unordered_map>
#include <vector>
namespace mbgl {
@@ -38,12 +40,139 @@ struct StyleLayerDiagnostics {
bool operator==(const StyleLayerDiagnostics&) const = default;
};
// The style layer list is the single dependency source for diagnostics and rendering.
struct RenderSegmentDependencies {
std::vector<std::string> sourceIds;
std::vector<std::string> globalPrerequisites;
bool unknown = false;
};
inline RenderSegmentDependencies renderSegmentDependencies(const std::vector<StyleLayerDiagnostics>& layers,
int32_t begin,
int32_t endExclusive) {
RenderSegmentDependencies result;
if (begin < 0 || endExclusive < begin || endExclusive > static_cast<int32_t>(layers.size())) {
result.unknown = true;
return result;
}
std::set<std::string> sources;
std::set<std::string> prerequisites;
for (int32_t i = begin; i < endExclusive; ++i) {
const auto& layer = layers[static_cast<std::size_t>(i)];
if (layer.sourceId) sources.insert(*layer.sourceId);
if (layer.layerType == "symbol") prerequisites.insert("symbol/glyph/image/placement");
if (layer.layerType == "custom" || layer.layerType == "custom-drawable") result.unknown = true;
if (layer.layerType != "background" && layer.layerType != "custom" &&
layer.layerType != "custom-drawable" && !layer.sourceId) result.unknown = true;
}
result.sourceIds.assign(sources.begin(), sources.end());
result.globalPrerequisites.assign(prerequisites.begin(), prerequisites.end());
return result;
}
class RenderSegmentRevisionBook {
public:
void advanceGlobal() noexcept { ++globalEpoch; }
void advanceSource(const std::string& sourceId) { sourceRevisions[sourceId] = ++sequence; }
std::optional<uint64_t> revisionFor(const RenderSegmentDependencies& dependencies) const {
if (dependencies.unknown) return std::nullopt;
uint64_t revision = globalEpoch;
for (const auto& id : dependencies.sourceIds) {
const auto found = sourceRevisions.find(id);
if (found == sourceRevisions.end()) return std::nullopt;
for (const unsigned char ch : id) revision = (revision ^ ch) * 1099511628211ULL;
revision = (revision ^ found->second) * 1099511628211ULL;
}
return revision;
}
private:
uint64_t globalEpoch = 1;
uint64_t sequence = 1;
// Retain tombstones: remove + recreate of the same ID always receives a newer identity.
std::unordered_map<std::string, uint64_t> sourceRevisions;
};
struct RenderSegmentSourceStatus { bool loaded = false; bool fading = false; };
struct RenderSegmentSourceAssessment {
bool ready = false;
bool stable = false;
const char* reason = "unknown-dependency";
};
template <typename Lookup>
RenderSegmentSourceAssessment assessRenderSegmentSources(const RenderSegmentDependencies& dependencies,
Lookup lookup) {
RenderSegmentSourceAssessment result;
if (dependencies.unknown) return result;
for (const auto& id : dependencies.sourceIds) {
const std::optional<RenderSegmentSourceStatus> source = lookup(id);
if (!source) { result.reason = "missing-source"; return result; }
if (!source->loaded) { result.reason = "source-not-loaded"; return result; }
if (source->fading) { result.ready = true; result.reason = "source-fading"; return result; }
}
result.ready = true;
result.stable = true;
result.reason = "none";
return result;
}
struct RenderSourceReadinessDiagnostics {
std::string sourceId;
std::string sourceType;
bool enabled = false;
bool isLoaded = false;
bool tileBacked = false;
std::optional<uint32_t> tileCount;
std::optional<uint32_t> incompleteTileCount;
std::optional<uint32_t> loadedTileCount;
bool hasFadingTiles = false;
uint64_t lastSourceMutationSequence = 0;
uint64_t lastTileChangedSequence = 0;
uint64_t lastTileActionSequence = 0;
std::string lastTileAction{"none"};
std::string lastMutationReason{"none"};
bool operator==(const RenderSourceReadinessDiagnostics&) const = default;
};
struct RenderMutationDiagnostics {
uint64_t sequence = 0;
uint64_t contentRevision = 0;
std::string eventType{"none"};
std::string reason{"none"};
std::optional<std::string> sourceId;
std::vector<uint32_t> affectedSegmentIndices;
std::string dependencyScope{"none"};
bool operator==(const RenderMutationDiagnostics&) const = default;
};
struct RendererReadinessDiagnostics {
bool styleLoaded = false;
bool imageManagerLoaded = false;
bool renderOrchestratorLoaded = false;
bool spriteLoaded = false;
bool hasFadingTiles = false;
uint64_t contentRevision = 0;
std::vector<RenderSourceReadinessDiagnostics> sources;
RenderMutationDiagnostics lastMutation;
bool operator==(const RendererReadinessDiagnostics&) const = default;
};
struct RenderSegmentCacheDiagnostics {
std::string cacheState{"disabled"};
uint64_t contentRevision = 0;
uint64_t build = 0;
uint64_t reuse = 0;
uint64_t invalidation = 0;
uint64_t fallbackCount = 0;
std::string finalDecision{"disabled"};
std::string lastFallbackReason{"none"};
bool sourceReady = false;
bool sourceStable = false;
bool supported = false;
uint64_t sourceRevision = 0;
std::string lastInvalidationReason{"none"};
uint64_t textureCreate = 0;
uint64_t compositeCreate = 0;
@@ -59,6 +188,8 @@ struct RenderSegmentDiagnosticsEntry {
std::optional<std::string> resolvedStartLayerId;
std::optional<std::string> resolvedEndLayerId;
std::vector<StyleLayerDiagnostics> layers;
std::vector<std::string> dependentSourceIds;
std::vector<std::string> unknownDependencies;
std::optional<RenderSegmentCacheDiagnostics> cache;
};
@@ -83,6 +214,14 @@ struct RenderSegmentDiagnostics {
uint64_t fastPathHit = 0;
uint64_t fastPathMiss = 0;
uint64_t fullCpuFrame = 0;
bool styleLoaded = false;
bool imageManagerLoaded = false;
bool renderOrchestratorLoaded = false;
bool spriteLoaded = false;
bool hasFadingTiles = false;
uint64_t contentRevision = 0;
std::vector<RenderSourceReadinessDiagnostics> sources;
RenderMutationDiagnostics lastMutation;
std::vector<StyleLayerDiagnostics> styleLayers;
std::vector<RenderSegmentDiagnosticsEntry> segments;
};
@@ -109,4 +248,24 @@ struct ResolvedRenderSegmentPlan {
bool operator==(const ResolvedRenderSegmentPlan&) const = default;
};
// This is the main-buffer traversal used by the renderer. A failed retained range
// is drawn live exactly once; neighboring cached ranges remain composites.
template <typename Cached, typename Composite, typename Live>
void visitRenderSegmentExecution(const ResolvedRenderSegmentPlan& plan,
Cached cached,
Composite composite,
Live live) {
if (!plan.valid) {
live(0, plan.layerCount);
return;
}
for (const auto& segment : plan.segments) {
if (segment.strategy == RenderSegmentStrategy::Retained && cached(segment.segmentIndex)) {
composite(segment.segmentIndex);
} else {
live(segment.beginIndex, segment.endExclusive);
}
}
}
} // namespace mbgl

View File

@@ -6,7 +6,7 @@ const marker = "// MapLibreParticleRNTestHost NavSea Particle release SDK";
module.exports = function withParticleNativeAndroid(config) {
return withProjectBuildGradle(config, (project) => {
if (project.modResults.contents.includes(marker)) return project;
project.modResults.contents += `\n${marker}\ngradle.projectsEvaluated {\n allprojects { p ->\n p.repositories.maven { repository -> repository.url = p.uri("https://pbf.jpcn.ccwu.cc/maven") }\n p.configurations.configureEach { configuration ->\n configuration.resolutionStrategy.dependencySubstitution {\n substitute module("org.maplibre.gl:android-sdk-opengl") using module("cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.17")\n }\n }\n }\n}\nrootProject.ext.set("org.maplibre.reactnative.nativeVersion", "13.4.1")\nrootProject.ext.set("org.maplibre.reactnative.nativeVariant", "opengl")\n`;
project.modResults.contents += `\n${marker}\ngradle.projectsEvaluated {\n allprojects { p ->\n p.repositories.maven { repository -> repository.url = p.uri("https://pbf.jpcn.ccwu.cc/maven") }\n p.configurations.configureEach { configuration ->\n configuration.resolutionStrategy.dependencySubstitution {\n substitute module("org.maplibre.gl:android-sdk-opengl") using module("cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.19")\n }\n }\n }\n}\nrootProject.ext.set("org.maplibre.reactnative.nativeVersion", "13.4.1")\nrootProject.ext.set("org.maplibre.reactnative.nativeVariant", "opengl")\n`;
return project;
});
};

View File

@@ -2,6 +2,12 @@
## Unreleased
## 13.4.1-particle.19(Android / iOS 统一发行)
- Mac unified release pipeline 统一生成 Android Native、RN package 与 iOS SwiftPM/XCFramework;共享 Particle revision 19。
## 13.4.1-particle.18(Android / iOS 统一发行)
- Mac unified release pipeline 统一生成 Android Native、RN package 与 iOS SwiftPM/XCFramework;共享 Particle revision 18。
## 13.4.1-particle.17(Android / iOS 统一发行)
- Mac unified release pipeline 统一生成 Android Native、RN package 与 iOS SwiftPM/XCFramework;共享 Particle revision 17。

View File

@@ -1,6 +1,6 @@
# @navsea/maplibre-particle-rn
当前版本:`13.4.1-particle.17`。Android Native 必须配套 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.17`。本包是 React Native 的薄控制面,不负责 Map 组件、provider、HTTP、manifest、下载、缓存或 NavSea 业务策略。
当前版本:`13.4.1-particle.19`。Android Native 必须配套 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.19`。本包是 React Native 的薄控制面,不负责 Map 组件、provider、HTTP、manifest、下载、缓存或 NavSea 业务策略。
## 当前推荐主链:QBIN V4
@@ -37,7 +37,7 @@ Podfile 的 `post_install do |installer|` 必须同时调用 `$MLRN.post_install
RN tgz:
```text
https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.17.tgz
https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.19.tgz
```
Native Maven repository:
@@ -49,7 +49,7 @@ https://pbf.jpcn.ccwu.cc/maven
Native coordinate:
```text
cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.17
cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.19
```
Expo plugin 与 `android/build.gradle` 必须指向同一 `.17`。正常 Consumer 不复制 AAR、不依赖 MapLibreParticleLab 源码路径,也不以 Maven Local 作为公网验证主路径。完整 Linux 发布与 Win11 验证流程见 `docs/50_Step记录/STEP-080_Android_AAR与RNTestHost标准构建流水线_v2.md`。
Expo plugin 与 `android/build.gradle` 必须指向同一 `.19`。正常 Consumer 不复制 AAR、不依赖 MapLibreParticleLab 源码路径,也不以 Maven Local 作为公网验证主路径。完整 Linux 发布与 Win11 验证流程见 `docs/50_Step记录/STEP-080_Android_AAR与RNTestHost标准构建流水线_v2.md`。

View File

@@ -14,7 +14,7 @@ android {
dependencies {
implementation "com.facebook.react:react-android"
implementation "org.jetbrains.kotlin:kotlin-stdlib:2.1.20"
implementation "cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.17"
implementation "cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.19"
compileOnly project(":maplibre_maplibre-react-native")
testImplementation "junit:junit:4.13.2"
}

View File

@@ -20,7 +20,7 @@ const iosTag = "navsea-maplibre-particle-rn-maplibre-spm";
module.exports = function withNavSeaMapLibreParticle(config) {
config = withProjectBuildGradle(config, (project) => {
if (project.modResults.contents.includes(androidMarker)) return project;
project.modResults.contents += `\n${androidMarker}\ngradle.projectsEvaluated {\n allprojects { p ->\n p.repositories.maven { repository -> repository.url = p.uri("https://pbf.jpcn.ccwu.cc/maven") }\n p.configurations.configureEach { configuration ->\n configuration.resolutionStrategy.dependencySubstitution {\n substitute module("org.maplibre.gl:android-sdk-opengl") using module("cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.17")\n }\n }\n }\n}\nrootProject.ext.set("org.maplibre.reactnative.nativeVersion", "13.4.1")\nrootProject.ext.set("org.maplibre.reactnative.nativeVariant", "opengl")\n`;
project.modResults.contents += `\n${androidMarker}\ngradle.projectsEvaluated {\n allprojects { p ->\n p.repositories.maven { repository -> repository.url = p.uri("https://pbf.jpcn.ccwu.cc/maven") }\n p.configurations.configureEach { configuration ->\n configuration.resolutionStrategy.dependencySubstitution {\n substitute module("org.maplibre.gl:android-sdk-opengl") using module("cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.19")\n }\n }\n }\n}\nrootProject.ext.set("org.maplibre.reactnative.nativeVersion", "13.4.1")\nrootProject.ext.set("org.maplibre.reactnative.nativeVariant", "opengl")\n`;
return project;
});

View File

@@ -1,6 +1,6 @@
{
"name": "@navsea/maplibre-particle-rn",
"version": "13.4.1-particle.17",
"version": "13.4.1-particle.19",
"description": "NavSea MapLibre Particle React Native pure Field Renderer API.",
"license": "BSD-2-Clause",
"main": "src/index.ts",

View File

@@ -19,6 +19,8 @@ export type {
RenderSegmentDefinition,
RenderSegmentDiagnostics,
RenderSegmentLayerDiagnostics,
RenderSourceReadinessDiagnostics,
RenderMutationDiagnostics,
RenderSegmentPlan,
RenderSegmentStrategy,
RetainedRenderSegmentDiagnostics,

View File

@@ -89,12 +89,21 @@ export type RetainedRenderSegmentDiagnostics = Readonly<{
resolvedEndLayerId: string | null;
layerCount: number;
layers: readonly RenderSegmentLayerDiagnostics[];
dependentSourceIds: readonly string[];
unknownDependencies: readonly string[];
cache: Readonly<{
cacheState: string;
contentRevision: number;
build: number;
reuse: number;
invalidation: number;
fallbackCount: number;
finalDecision: string;
lastFallbackReason: string;
sourceReady: boolean;
sourceStable: boolean;
supported: boolean;
sourceRevision: number;
lastInvalidationReason: string;
textureCreate: number;
compositeCreate: number;
@@ -111,8 +120,35 @@ export type ContinuousRenderSegmentDiagnostics = Readonly<{
resolvedEndLayerId: string | null;
layerCount: number;
layers: readonly RenderSegmentLayerDiagnostics[];
dependentSourceIds: readonly string[];
unknownDependencies: readonly string[];
cache: null;
}>;
export type RenderSourceReadinessDiagnostics = Readonly<{
sourceId: string;
sourceType: string;
enabled: boolean;
isLoaded: boolean;
tileBacked: boolean;
tileCount: number | null;
incompleteTileCount: number | null;
loadedTileCount: number | null;
hasFadingTiles: boolean;
lastSourceMutationSequence: number;
lastTileChangedSequence: number;
lastTileActionSequence: number;
lastTileAction: string;
lastMutationReason: string;
}>;
export type RenderMutationDiagnostics = Readonly<{
sequence: number;
contentRevision: number;
eventType: string;
reason: string;
sourceId: string | null;
affectedSegmentIndices: readonly number[];
dependencyScope: string;
}>;
export type RenderSegmentDiagnostics = Readonly<{
schemaVersion: 1;
enabled: boolean;
@@ -134,6 +170,14 @@ export type RenderSegmentDiagnostics = Readonly<{
fastPathHit: number;
fastPathMiss: number;
fullCpuFrame: number;
styleLoaded: boolean;
imageManagerLoaded: boolean;
renderOrchestratorLoaded: boolean;
spriteLoaded: boolean;
hasFadingTiles: boolean;
contentRevision: number;
sources: readonly RenderSourceReadinessDiagnostics[];
lastMutation: RenderMutationDiagnostics;
styleLayers: readonly RenderSegmentLayerDiagnostics[];
segments: readonly (RetainedRenderSegmentDiagnostics | ContinuousRenderSegmentDiagnostics)[];
}>;

View File

@@ -195,6 +195,7 @@ void MapRenderer::requestSnapshot(SnapshotCallback callback) {
// Called on OpenGL thread //
void MapRenderer::resetRenderer() {
Log::Info(Event::Android, "MapRenderer::resetRenderer entered");
renderer.reset();
if (!asyncRendererCleanup) {
@@ -203,6 +204,7 @@ void MapRenderer::resetRenderer() {
window.reset();
swapBehaviorFlush = false;
Log::Info(Event::Android, "MapRenderer::resetRenderer completed");
}
void MapRenderer::scheduleSnapshot(std::unique_ptr<SnapshotCallback> callback) {
@@ -322,9 +324,11 @@ void MapRenderer::onSurfaceChanged([[maybe_unused]] JNIEnv& env, jint width, jin
}
void MapRenderer::onRendererReset(JNIEnv&) {
Log::Info(Event::Android, "MapRenderer::nativeReset entered");
// Make sure to destroy the renderer on the GL Thread
auto self = ActorRef<MapRenderer>(*this, mailboxData.getMailbox());
self.ask(&MapRenderer::resetRenderer).wait();
Log::Info(Event::Android, "MapRenderer::nativeReset completed");
}
// needs to be called on GL thread

View File

@@ -1,6 +1,7 @@
package org.maplibre.android.maps.renderer.surfaceview;
import android.content.Context;
import android.util.Log;
import android.view.Surface;
import android.view.View;
@@ -28,11 +29,13 @@ public class SurfaceViewMapRenderer extends MapRenderer {
surfaceView.setDetachedListener(new MapLibreSurfaceView.OnSurfaceViewDetachedListener() {
@Override
public void onSurfaceViewDetached() {
Log.i("Mbgl-SurfaceRenderer", "SurfaceView detached; nativeReset entered");
// because the GL thread is destroyed when the view is detached from window,
// we need to ensure releasing the native renderer as well.
// This avoids releasing it only when the view is being recreated, which is already on a new GL thread,
// and leads to JNI crashes like https://github.com/mapbox/mapbox-gl-native/issues/14618
nativeReset();
Log.i("Mbgl-SurfaceRenderer", "nativeReset returned");
}
});
}

View File

@@ -4,6 +4,7 @@ import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.text.InputType
import android.util.Log
import android.widget.Button
@@ -23,7 +24,10 @@ import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.Style
import org.maplibre.android.maps.WeatherRuntime
import org.maplibre.android.style.layers.PaintPropertyValue
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.android.testapp.R
import org.maplibre.geojson.Feature
import org.maplibre.geojson.Point
/** Android OpenGL host for the formal raster-array to raster-particle fixture path. */
class ParticleCoreLayerValidationActivity : AppCompatActivity() {
@@ -57,6 +61,152 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private var retainedCacheEnabled = true
private var step093Mode = false
private var step097Topology: Step097StencilFixture.Topology? = null
private var step099Scenario: Step099ReadinessFixture.Scenario? = null
private var step101Scenario: String? = null
private var step101Completed = 0
private var step101DynamicTick = 0L
private var step101ReadySinceMs = 0L
private var step101LoadSinceMs = 0L
private val step101Handler = Handler(Looper.getMainLooper())
private val step101Poll = object : Runnable {
override fun run() {
val scenario = step101Scenario ?: return
if (!this@ParticleCoreLayerValidationActivity::maplibreMap.isInitialized || !styleReady) {
if (SystemClock.elapsedRealtime() - step101LoadSinceMs > 20000L) {
Log.e(TAG, "STEP101_SCENARIO_BLOCKED scenario=$scenario completed=$step101Completed reason=style-not-ready")
status.text = "STEP-101 BLOCKED: style not ready"
step101Scenario = null
return
}
step101Handler.postDelayed(this, 250L)
return
}
val snapshot = JSONObject(maplibreMap.getRenderSegmentDiagnostics())
val sources = snapshot.optJSONArray("sources")
var tileReady = false
for (index in 0 until (sources?.length() ?: 0)) {
val source = sources?.optJSONObject(index) ?: continue
if (source.optString("sourceId") == Step099ReadinessFixture.STATIC_SOURCE &&
source.optBoolean("isLoaded") && source.optInt("tileCount") > 0) tileReady = true
}
if (!tileReady) {
if (SystemClock.elapsedRealtime() - step101LoadSinceMs > 20000L) {
Log.e(TAG, "STEP101_SCENARIO_BLOCKED scenario=$scenario completed=$step101Completed reason=tile-not-ready")
status.text = "STEP-101 BLOCKED: tile not ready"
step101Scenario = null
return
}
step101Handler.postDelayed(this, 250L)
return
}
if (step101ReadySinceMs == 0L) {
step101ReadySinceMs = SystemClock.elapsedRealtime()
Log.i(TAG, "STEP101_SOURCE_TILE_READY scenario=$scenario completed=$step101Completed " +
"sources=${sources?.length()} diagnostics=${snapshot.optLong("contentRevision")}")
}
if (scenario == "T3_RESET_WITH_DYNAMIC_GEOJSON") {
step101DynamicTick += 1
updateStep099GeoJson(Step099ReadinessFixture.DYNAMIC_SOURCE, step101DynamicTick)
Log.i(TAG, "STEP101_DYNAMIC_SOURCE_MUTATION scenario=$scenario completed=$step101Completed " +
"tick=$step101DynamicTick")
}
if (SystemClock.elapsedRealtime() - step101ReadySinceMs < 2500L) {
step101Handler.postDelayed(this, 250L)
return
}
Log.i(TAG, "STEP101_NEW_RENDERER_READY scenario=$scenario completed=$step101Completed " +
"styleReady=$styleReady diagnostics=${snapshot.optLong("contentRevision")}")
val target = if (scenario == "T2_RESET_LOOP_20") 20 else 1
if (step101Completed >= target) {
Log.i(TAG, "STEP101_SCENARIO_DONE scenario=$scenario resetsRequested=$step101Completed " +
"nativeResetPairsMustMatch=$step101Completed")
status.text = "STEP-101 $scenario DONE ${step101Completed}/$target"
step101Scenario = null
return
}
if (scenario == "T4_RESET_WITH_DIAGNOSTICS") {
Log.i(TAG, "STEP101_PRE_RESET_DIAGNOSTICS ${maplibreMap.getRenderSegmentDiagnostics()}")
}
val next = step101Completed + 1
Log.i(TAG, "STEP101_RESET_REQUEST scenario=$scenario iteration=$next oldMapViewActive=true " +
"tileReady=$tileReady")
step101Completed = next
step101Handler.removeCallbacks(this)
recreate() // Activity destroys the old MapView and constructs a new native renderer.
}
}
private var step099Tick = 0L
private var step099LastOverallLoaded: Boolean? = null
private var step099FalseSinceMs: Long? = null
private var step099FalseWindowCount = 0L
private var step099FalseWindowTotalMs = 0L
private var step099FalseWindowMaxMs = 0L
private var step099Running = false
private val step099Handler = Handler(Looper.getMainLooper())
private val step099ReadinessTransitionPoll = object : Runnable {
override fun run() {
if (step099Running && step099Scenario != null && this@ParticleCoreLayerValidationActivity::maplibreMap.isInitialized && styleReady) {
val now = SystemClock.elapsedRealtime()
val snapshot = JSONObject(maplibreMap.getRenderSegmentDiagnostics())
val loaded = snapshot.optBoolean("renderOrchestratorLoaded")
val previous = step099LastOverallLoaded
if (previous == null || previous != loaded) {
var falseWindowMs: Long? = null
if (!loaded) {
step099FalseSinceMs = now
} else {
falseWindowMs = step099FalseSinceMs?.let { start ->
(now - start).also { duration ->
step099FalseWindowCount += 1
step099FalseWindowTotalMs += duration
step099FalseWindowMaxMs = maxOf(step099FalseWindowMaxMs, duration)
}
}
step099FalseSinceMs = null
}
Log.i(
TAG,
"STEP099_LOADED_TRANSITION elapsedMs=${now} from=${previous} to=${loaded} " +
"falseWindowMs=${falseWindowMs ?: -1} source=${snapshot.optJSONObject("lastMutation")?.optString("sourceId")}"
)
step099LastOverallLoaded = loaded
}
}
if (step099Running) step099Handler.postDelayed(this, STEP099_STATE_SAMPLE_INTERVAL_MS)
}
}
private val step099DiagnosticsPoll = object : Runnable {
override fun run() {
val scenario = step099Scenario ?: return
if (!step099Running) return
if (this@ParticleCoreLayerValidationActivity::maplibreMap.isInitialized && styleReady) {
step099Tick += 1
when (scenario) {
Step099ReadinessFixture.Scenario.T2_GEOJSON_1HZ -> {
val sourceId = if (step099Tick % 2L == 0L) {
Step099ReadinessFixture.DYNAMIC_SOURCE
} else {
Step099ReadinessFixture.DYNAMIC_SOURCE_B
}
updateStep099GeoJson(sourceId, step099Tick)
if (step099Tick % 2L == 0L) refreshStep099Diagnostics(log = true)
}
Step099ReadinessFixture.Scenario.T4_RCR -> {
if (step099Tick % 2L == 0L) {
updateStep099GeoJson(Step099ReadinessFixture.DYNAMIC_SOURCE, step099Tick)
refreshStep099Diagnostics(log = true)
if (step099Tick == 8L || step099Tick == 130L) {
Log.i(TAG, "STEP100_WINDOW_MARKER phase=${if (step099Tick == 8L) "baseline" else "final"} " +
"tick=$step099Tick sourceId=${Step099ReadinessFixture.DYNAMIC_SOURCE}")
}
}
}
else -> if (step099Tick % 2L == 0L) refreshStep099Diagnostics(log = true)
}
}
if (step099Running) step099Handler.postDelayed(this, STEP099_TICK_INTERVAL_MS)
}
}
private var step093Topology = Step093Topology.BOTTOM_CONTINUOUS_TOP
private var step093Variant = Step093Variant.DEFAULT
private var step093StartVisible = true
@@ -112,6 +262,12 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
step093Mode = savedInstanceState?.getBoolean(STATE_STEP093_MODE, false) ?: false
step097Topology = savedInstanceState?.getString(STATE_STEP097_TOPOLOGY)
?.let { value -> Step097StencilFixture.Topology.values().firstOrNull { it.name == value } }
step101Scenario = if (savedInstanceState?.containsKey(STATE_STEP101_SCENARIO) == true) {
savedInstanceState.getString(STATE_STEP101_SCENARIO)
} else {
intent.getStringExtra("step101_scenario")?.takeIf { it in STEP101_SCENARIOS }
}
step101Completed = savedInstanceState?.getInt(STATE_STEP101_COMPLETED, 0) ?: 0
step093Topology = savedInstanceState?.getString(STATE_STEP093_TOPOLOGY)
?.let { value -> Step093Topology.values().firstOrNull { it.name == value } }
?: Step093Topology.BOTTOM_CONTINUOUS_TOP
@@ -150,7 +306,8 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
updateNavSeaPbfButtonState()
updatePbfModeButtonState()
updateRetainedCacheButtonState()
if (step097Topology != null) loadStep097StencilStyle()
if (step101Scenario != null) loadStep101Style()
else if (step097Topology != null) loadStep097StencilStyle()
else if (step093Mode) loadStep093Style(configureBeforeStyle = false)
else loadValidationStyle(active = active, enabled = enabled, reason = "INITIAL")
}
@@ -454,6 +611,8 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
menu.add(0, 14, 14, "READ DIAGNOSTICS")
menu.add(0, 15, 15, "RESET DEFAULT A")
menu.add(0, 16, 16, "STEP-097 SAME SOURCE STENCIL")
menu.add(0, 17, 17, "STEP-099 READINESS")
menu.add(0, 18, 18, "STEP-101 TEARDOWN")
setOnMenuItemClickListener { item ->
when (item.itemId) {
in Step093Topology.values().indices -> applyStep093Scene(Step093Topology.values()[item.itemId])
@@ -464,6 +623,8 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
14 -> refreshStep093Diagnostics(log = true)
15 -> applyStep093Scene(Step093Topology.BOTTOM_CONTINUOUS_TOP)
16 -> showStep097StencilMenu(anchor)
17 -> showStep099Menu(anchor)
18 -> showStep101Menu(anchor)
}
true
}
@@ -491,7 +652,189 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
}
private fun showStep099Menu(anchor: android.view.View) {
PopupMenu(this, anchor).apply {
Step099ReadinessFixture.Scenario.values().forEachIndexed { index, scenario ->
menu.add(0, index, index, scenario.label)
}
menu.add(0, 10, 10, "STOP / RETURN TO DEFAULT")
setOnMenuItemClickListener { item ->
if (item.itemId in Step099ReadinessFixture.Scenario.values().indices) {
applyStep099Scenario(Step099ReadinessFixture.Scenario.values()[item.itemId])
} else if (item.itemId == 10) {
stopStep099Scenario()
loadValidationStyle(active, enabled, "STEP099_EXIT")
}
true
}
show()
}
}
private fun showStep101Menu(anchor: android.view.View) {
PopupMenu(this, anchor).apply {
STEP101_SCENARIOS.forEachIndexed { index, scenario -> menu.add(0, index, index, scenario) }
setOnMenuItemClickListener { item ->
val scenario = STEP101_SCENARIOS.getOrNull(item.itemId) ?: return@setOnMenuItemClickListener false
step101Scenario = scenario
step101Completed = 0
loadStep101Style()
true
}
show()
}
}
private fun loadStep101Style() {
val scenario = step101Scenario ?: return
stopStep099Scenario()
step101Handler.removeCallbacksAndMessages(null)
step101ReadySinceMs = 0L
step101LoadSinceMs = SystemClock.elapsedRealtime()
val fixture = Step099ReadinessFixture.Scenario.T4_RCR
val accepted = maplibreMap.setRenderSegmentPlan(Step099ReadinessFixture.planJson(fixture))
styleReady = false
val epoch = ++styleLoadEpoch
Log.i(TAG, "STEP101_OLD_RESET_COMPLETED_OR_INITIAL scenario=$scenario completed=$step101Completed " +
"planAccepted=$accepted")
maplibreMap.setStyle(Style.Builder().fromJson(Step099ReadinessFixture.styleJson(fixture))) {
if (step101Scenario != scenario || epoch != styleLoadEpoch) return@setStyle
styleReady = true
maplibreMap.moveCamera(CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder().target(LatLng(28.0, 135.0)).zoom(3.0).build()
))
Log.i(TAG, "STEP101_STYLE_READY scenario=$scenario completed=$step101Completed")
step101Handler.removeCallbacks(step101Poll)
step101Handler.post(step101Poll)
}
step101Handler.post(step101Poll)
}
private fun applyStep099Scenario(scenario: Step099ReadinessFixture.Scenario) {
stopStep093DiagnosticsPolling()
step093Mode = false
step097Topology = null
step099Scenario = scenario
step099Running = true
step099Tick = 0L
step099LastOverallLoaded = null
step099FalseSinceMs = null
step099FalseWindowCount = 0L
step099FalseWindowTotalMs = 0L
step099FalseWindowMaxMs = 0L
timeWindowMode = false
productionFeedMode = false
hostSubmissionMode = false
realWeatherMode = false
weatherRuntime?.dispose()
weatherRuntime = null
step099Handler.removeCallbacksAndMessages(null)
val accepted = maplibreMap.setRenderSegmentPlan(Step099ReadinessFixture.planJson(scenario))
styleReady = false
val epoch = ++styleLoadEpoch
status.text = "STEP-099 · ${scenario.label} · LOADING"
Log.i(TAG, "STEP099_SCENARIO_START scenario=${scenario.name} planAccepted=$accepted")
maplibreMap.setStyle(Style.Builder().fromJson(Step099ReadinessFixture.styleJson(scenario))) {
if (!step099Running || step099Scenario != scenario || epoch != styleLoadEpoch) return@setStyle
styleReady = true
status.text = "STEP-099 · ${scenario.label}"
refreshStep099Diagnostics(log = true)
step099Handler.post(step099ReadinessTransitionPoll)
step099Handler.post(step099DiagnosticsPoll)
maplibreMap.moveCamera(CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder().target(LatLng(28.0, 135.0)).zoom(3.0).build()
))
}
}
private fun stopStep099Scenario() {
step099Running = false
step099Handler.removeCallbacksAndMessages(null)
val previous = step099Scenario
step099Scenario = null
if (previous != null) {
val average = if (step099FalseWindowCount == 0L) 0 else step099FalseWindowTotalMs / step099FalseWindowCount
val open = step099FalseSinceMs?.let { SystemClock.elapsedRealtime() - it } ?: 0L
Log.i(TAG, "STEP099_AGGREGATE scenario=${previous.name} falseWindowCount=$step099FalseWindowCount " +
"falseWindowAvgMs=$average falseWindowMaxMs=$step099FalseWindowMaxMs openFalseWindowMs=$open")
}
step099LastOverallLoaded = null
step099FalseSinceMs = null
}
private fun updateStep099GeoJson(sourceId: String, tick: Long) {
val source = maplibreMap.style?.getSourceAs<GeoJsonSource>(sourceId) ?: return
val offset = (tick % 20L) * 0.12
val longitude = if (sourceId == Step099ReadinessFixture.DYNAMIC_SOURCE) 135.0 + offset else 136.0 - offset
val latitude = if (sourceId == Step099ReadinessFixture.DYNAMIC_SOURCE) 28.0 + offset * 0.3 else 29.0 - offset * 0.3
source.setGeoJson(Feature.fromGeometry(Point.fromLngLat(longitude, latitude)))
Log.i(TAG, "STEP099_SOURCE_UPDATE scenario=${step099Scenario?.name} sourceId=$sourceId tick=$tick")
}
private fun refreshStep099Diagnostics(log: Boolean) {
if (!this::maplibreMap.isInitialized || !styleReady) return
val snapshot = JSONObject(maplibreMap.getRenderSegmentDiagnostics())
val sources = snapshot.optJSONArray("sources")
val sourceSummary = buildList {
for (index in 0 until (sources?.length() ?: 0)) {
val source = sources?.optJSONObject(index) ?: continue
add("${source.optString("sourceId")}:${source.optString("sourceType")}:" +
"${source.optBoolean("enabled")}/${source.optBoolean("isLoaded")}" +
" tiles=${source.opt("tileCount")}/${source.opt("incompleteTileCount")}/${source.opt("loadedTileCount")}" +
" fade=${source.optBoolean("hasFadingTiles")}")
}
}
val segments = snapshot.optJSONArray("segments")
val segmentSummary = buildList {
for (index in 0 until (segments?.length() ?: 0)) {
val segment = segments?.optJSONObject(index) ?: continue
val dependencies = segment.optJSONArray("dependentSourceIds")
val dependencyText = buildList {
for (dependencyIndex in 0 until (dependencies?.length() ?: 0)) {
add(dependencies?.optString(dependencyIndex).orEmpty())
}
}.joinToString(",")
val cache = segment.optJSONObject("cache")
val cacheText = if (cache == null) {
"cache=-"
} else {
"cache=${cache.optString("cacheState")}@${cache.optLong("contentRevision")}" +
" b=${cache.optLong("build")} r=${cache.optLong("reuse")}" +
" i=${cache.optLong("invalidation")}/${cache.optString("lastInvalidationReason")}" +
" srcRev=${cache.optLong("sourceRevision")}" +
" ready=${cache.optBoolean("sourceReady")}/${cache.optBoolean("sourceStable")}" +
" supported=${cache.optBoolean("supported")}" +
" decision=${cache.optString("finalDecision")}" +
" fallback=${cache.optLong("fallbackCount")}/${cache.optString("lastFallbackReason")}"
}
add("${segment.optInt("segmentIndex")}:${segment.optString("strategy")}" +
"[${segment.optInt("beginIndex")},${segment.optInt("endExclusive")})" +
" deps=[$dependencyText] $cacheText")
}
}
val mutation = snapshot.optJSONObject("lastMutation")
val affectedSegments = mutation?.optJSONArray("affectedSegmentIndices")
val affectedText = buildList {
for (index in 0 until (affectedSegments?.length() ?: 0)) {
add(affectedSegments?.optInt(index).toString())
}
}.joinToString(",")
val message = "scenario=${step099Scenario?.name} overall=${snapshot.optBoolean("renderOrchestratorLoaded")} " +
"style=${snapshot.optBoolean("styleLoaded")} image=${snapshot.optBoolean("imageManagerLoaded")} " +
"sprite=${snapshot.optBoolean("spriteLoaded")} fade=${snapshot.optBoolean("hasFadingTiles")} " +
"rev=${snapshot.optLong("contentRevision")} sources=${sourceSummary.joinToString(";")} " +
"last=${mutation?.optLong("sequence")}/${mutation?.optString("sourceId")}/${mutation?.optString("reason")}" +
" affected=[$affectedText] scope=${mutation?.optString("dependencyScope")} " +
"fast=${snapshot.optBoolean("fastPathActive")}/${snapshot.optBoolean("fastPathEligible")}" +
"/${snapshot.optString("fastPathFallbackReason")}" +
" h=${snapshot.optLong("fastPathHit")} m=${snapshot.optLong("fastPathMiss")}" +
" full=${snapshot.optLong("fullCpuFrame")} segments=${segmentSummary.joinToString(";")}"
diagnostics.text = "STEP-099 $message"
if (log) Log.i(TAG, "STEP099_AGGREGATE_SAMPLE $message")
}
private fun applyStep097StencilScene(topology: Step097StencilFixture.Topology) {
stopStep099Scenario()
leaveRealWeatherMode()
step097Topology = topology
retainedCacheEnabled = true
@@ -630,6 +973,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
variant: Step093Variant = Step093Variant.DEFAULT,
configureBeforeStyle: Boolean = false
) {
stopStep099Scenario()
leaveRealWeatherMode()
step093Mode = true
step093Topology = topology
@@ -720,6 +1064,10 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
step093Handler.post(step093DiagnosticsPoll)
}
private fun stopStep093DiagnosticsPolling() {
step093Handler.removeCallbacks(step093DiagnosticsPoll)
}
private fun refreshStep093Diagnostics(log: Boolean) {
val snapshot = maplibreMap.retainedUnderlayDiagnostics
val cacheCount = (if (snapshot.bottomPresent) 1 else 0) + (if (snapshot.topPresent) 1 else 0)
@@ -1145,6 +1493,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
}
private fun loadValidationStyle(active: Boolean, enabled: Boolean, reason: String = "STYLE_CHANGE") {
if (step099Scenario != null) stopStep099Scenario()
step097V4GoldenReady = false
if (pureParticleMode) {
loadPureParticleStyle(active, enabled, reason)
@@ -1904,11 +2253,42 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
""".trimIndent()
}
override fun onStart() { super.onStart(); mapView.onStart(); if (step093Mode || step097Topology != null) startStep093DiagnosticsPolling() }
override fun onStart() {
super.onStart()
mapView.onStart()
if (step101Scenario != null && styleReady) step101Handler.post(step101Poll)
if (step099Scenario != null) {
step099Running = true
step099LastOverallLoaded = null
step099FalseSinceMs = null
step099Handler.removeCallbacksAndMessages(null)
if (styleReady) {
step099Handler.post(step099ReadinessTransitionPoll)
step099Handler.post(step099DiagnosticsPoll)
}
} else if (step093Mode || step097Topology != null) startStep093DiagnosticsPolling()
}
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { mapView.onPause(); super.onPause() }
override fun onStop() { step093Handler.removeCallbacks(step093DiagnosticsPoll); mapView.onStop(); super.onStop() }
override fun onDestroy() { step093Handler.removeCallbacks(step093DiagnosticsPoll); weatherRuntime?.dispose(); weatherRuntime = null; mapView.onDestroy(); super.onDestroy() }
override fun onStop() {
step101Handler.removeCallbacksAndMessages(null)
step093Handler.removeCallbacks(step093DiagnosticsPoll)
step099Running = false
step099Handler.removeCallbacksAndMessages(null)
mapView.onStop()
super.onStop()
}
override fun onDestroy() {
step101Handler.removeCallbacksAndMessages(null)
if (step101Scenario != null) Log.i(TAG, "STEP101_MAPVIEW_DESTROY_ENTER scenario=$step101Scenario completed=$step101Completed")
step093Handler.removeCallbacks(step093DiagnosticsPoll)
stopStep099Scenario()
weatherRuntime?.dispose()
weatherRuntime = null
mapView.onDestroy()
if (step101Scenario != null) Log.i(TAG, "STEP101_MAPVIEW_DESTROY_RETURN scenario=$step101Scenario completed=$step101Completed")
super.onDestroy()
}
override fun onLowMemory() { super.onLowMemory(); mapView.onLowMemory() }
override fun onSaveInstanceState(outState: Bundle) {
@@ -1935,6 +2315,8 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
outState.putBoolean(STATE_RETAINED_CACHE_ENABLED, retainedCacheEnabled)
outState.putBoolean(STATE_STEP093_MODE, step093Mode)
outState.putString(STATE_STEP097_TOPOLOGY, step097Topology?.name)
outState.putString(STATE_STEP101_SCENARIO, step101Scenario)
outState.putInt(STATE_STEP101_COMPLETED, step101Completed)
outState.putString(STATE_STEP093_TOPOLOGY, step093Topology.name)
outState.putString(STATE_STEP093_VARIANT, step093Variant.name)
super.onSaveInstanceState(outState)
@@ -1972,12 +2354,19 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
private const val STATE_RETAINED_CACHE_ENABLED = "particle_validation.retained_cache_enabled"
private const val STATE_STEP093_MODE = "particle_validation.step093_mode"
private const val STATE_STEP097_TOPOLOGY = "particle_validation.step097_topology"
private const val STATE_STEP101_SCENARIO = "particle_validation.step101_scenario"
private const val STATE_STEP101_COMPLETED = "particle_validation.step101_completed"
private val STEP101_SCENARIOS = listOf(
"T1_RESET_ONCE", "T2_RESET_LOOP_20", "T3_RESET_WITH_DYNAMIC_GEOJSON", "T4_RESET_WITH_DIAGNOSTICS"
)
private const val STATE_STEP093_TOPOLOGY = "particle_validation.step093_topology"
private const val STATE_STEP093_VARIANT = "particle_validation.step093_variant"
private const val PARTICLE_LAYER_ID = "particle-raster-core"
private const val WEATHER_COLOR_LAYER_ID = "weather-color-real"
private const val PARTICLE_SOURCE_ID = "particle-fixture"
private const val STEP093_DIAGNOSTICS_INTERVAL_MS = 1000L
private const val STEP099_STATE_SAMPLE_INTERVAL_MS = 200L
private const val STEP099_TICK_INTERVAL_MS = 500L
private const val STEP093_WEATHER_LAYER_ID = WEATHER_COLOR_LAYER_ID
private const val STEP093_PARTICLE_LAYER_ID = PARTICLE_LAYER_ID
private const val STEP093_BOTTOM_BACKGROUND_ID = "step093-bottom-background"

View File

@@ -0,0 +1,149 @@
package org.maplibre.android.testapp.activity.particle
import org.json.JSONArray
import org.json.JSONObject
/** STEP-099/100 offline source readiness and retained segment fixtures. */
internal object Step099ReadinessFixture {
enum class Scenario(val label: String) {
T1_STATIC("T1 STATIC"),
T2_GEOJSON_1HZ("T2 GEOJSON 1HZ"),
T3_PERMANENT_UNREADY("T3 PERMANENT UNREADY"),
T4_RCR("T4 R-C-R")
}
const val PARTICLE_SOURCE = "particle-fixture"
const val STATIC_SOURCE = "step099-static"
const val UPPER_STATIC_SOURCE = "step099-upper-static"
const val DYNAMIC_SOURCE = "step099-dynamic"
const val DYNAMIC_SOURCE_B = "step099-dynamic-b"
const val UNREADY_SOURCE = "step099-unready-raster-array"
const val STATIC_LAYER = "step099-static-fill"
const val WEATHER_LAYER = "step099-weather"
const val PARTICLE_LAYER = "step099-particle"
const val DYNAMIC_LAYER = "step099-dynamic-circle"
const val DYNAMIC_LAYER_B = "step099-dynamic-circle-b"
const val UPPER_STATIC_LAYER = "step099-static-upper-fill"
fun styleJson(scenario: Scenario): String {
val sources = JSONObject()
.put(PARTICLE_SOURCE, JSONObject()
.put("type", "raster-array")
.put("fixture", "uniform-east")
.put("metadata", "asset://integration/particle-fixtures/uniform-east/metadata.json")
.put("field", "asset://integration/particle-fixtures/uniform-east/field.f32"))
.put(STATIC_SOURCE, geoJsonSource(
"""{"type":"Polygon","coordinates":[[[112,0],[158,0],[158,56],[112,56],[112,0]]]}"""
))
if (scenario == Scenario.T2_GEOJSON_1HZ || scenario == Scenario.T4_RCR) {
sources.put(DYNAMIC_SOURCE, geoJsonSource(
"""{"type":"Point","coordinates":[135.0,28.0]}"""
))
}
if (scenario == Scenario.T1_STATIC || scenario == Scenario.T3_PERMANENT_UNREADY) {
sources.put(UPPER_STATIC_SOURCE, geoJsonSource(
"""{"type":"Polygon","coordinates":[[[116,4],[154,4],[154,52],[116,52],[116,4]]]}"""
))
}
if (scenario == Scenario.T2_GEOJSON_1HZ) {
sources.put(DYNAMIC_SOURCE_B, geoJsonSource(
"""{"type":"Point","coordinates":[136.0,29.0]}"""
))
}
if (scenario == Scenario.T3_PERMANENT_UNREADY) {
// A valid host-submission RasterArraySource with no submitted field is intentionally isLoaded=false.
// It has no layer on purpose: STEP-099 verifies whether a globally present but unrelated Source
// can still hold RenderOrchestrator::isLoaded() false.
sources.put(UNREADY_SOURCE, JSONObject()
.put("type", "raster-array")
.put("host-field-submission", true))
}
val layers = JSONArray()
.put(JSONObject()
.put("id", "step099-background")
.put("type", "background")
.put("paint", JSONObject().put("background-color", "#0b2942")))
.put(JSONObject()
.put("id", STATIC_LAYER)
.put("type", "fill")
.put("source", STATIC_SOURCE)
.put("paint", JSONObject()
.put("fill-color", "#176b87")
.put("fill-opacity", 0.58)))
.put(JSONObject()
.put("id", WEATHER_LAYER)
.put("type", "weather-color")
.put("source", PARTICLE_SOURCE)
.put("paint", JSONObject().put("weather-color-opacity", 0.32)))
.put(JSONObject()
.put("id", PARTICLE_LAYER)
.put("type", "raster-particle")
.put("source", PARTICLE_SOURCE)
.put("paint", JSONObject()
.put("raster-particle-active", true)
.put("raster-particle-particle-count", 1024)))
if (scenario == Scenario.T2_GEOJSON_1HZ || scenario == Scenario.T4_RCR) {
layers.put(JSONObject()
.put("id", DYNAMIC_LAYER)
.put("type", "circle")
.put("source", DYNAMIC_SOURCE)
.put("paint", JSONObject()
.put("circle-radius", 12)
.put("circle-color", "#ffcc00")
.put("circle-opacity", 0.95)))
}
if (scenario == Scenario.T2_GEOJSON_1HZ) {
layers.put(JSONObject()
.put("id", DYNAMIC_LAYER_B)
.put("type", "circle")
.put("source", DYNAMIC_SOURCE_B)
.put("paint", JSONObject()
.put("circle-radius", 10)
.put("circle-color", "#ff6600")
.put("circle-opacity", 0.90)))
}
if (scenario == Scenario.T1_STATIC || scenario == Scenario.T3_PERMANENT_UNREADY) {
layers.put(JSONObject()
.put("id", UPPER_STATIC_LAYER)
.put("type", "fill")
.put("source", UPPER_STATIC_SOURCE)
.put("paint", JSONObject().put("fill-color", "#d3a640").put("fill-opacity", 0.38)))
}
return JSONObject()
.put("version", 8)
.put("name", "STEP-099 Source Readiness Diagnostics")
.put("sources", sources)
.put("layers", layers)
.toString()
}
fun planJson(scenario: Scenario): String {
val segments = JSONArray()
fun add(strategy: String, start: String? = null) {
val segment = JSONObject().put("strategy", strategy)
if (start != null) segment.put("startLayerId", start)
segments.put(segment)
}
add("RETAINED")
add("CONTINUOUS", WEATHER_LAYER)
add("RETAINED", if (scenario == Scenario.T2_GEOJSON_1HZ || scenario == Scenario.T4_RCR) DYNAMIC_LAYER else UPPER_STATIC_LAYER)
return JSONObject()
.put("schemaVersion", 1)
.put("segments", segments)
.toString()
}
private fun geoJsonSource(geometryJson: String): JSONObject = JSONObject()
.put("type", "geojson")
.put("data", JSONObject()
.put("type", "Feature")
.put("properties", JSONObject())
.put("geometry", JSONObject(geometryJson)))
}

View File

@@ -1 +1 @@
13.4.1-particle.17
13.4.1-particle.19

View File

@@ -9,8 +9,8 @@ let package = Package(
targets: [
.binaryTarget(
name: "MapLibre",
url: "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.17/MapLibre.dynamic.xcframework.zip",
checksum: "5535345dd480f80c0f083811abe93f011b41b4d1a03588189eec5c50a36af0be"
url: "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.19/MapLibre.dynamic.xcframework.zip",
checksum: "221c069b6ca073854716799d8593784310d4f0050be9c9d9a88215b352512efd"
),
]
)

View File

@@ -1,14 +1,14 @@
# MapLibre Particle iOS 发行
当前正式发行:6.28.0-particle.17
当前正式发行:6.28.0-particle.19
- Renderer:Metal
- Product / Module:MapLibre
- Binary:https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.17/MapLibre.dynamic.xcframework.zip
- SHA-256:5535345dd480f80c0f083811abe93f011b41b4d1a03588189eec5c50a36af0be
- Binary:https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.19/MapLibre.dynamic.xcframework.zip
- SHA-256:221c069b6ca073854716799d8593784310d4f0050be9c9d9a88215b352512efd
- SwiftPM:https://pbf.jpcn.ccwu.cc/swift/maplibre-particle-spm.git
- Exact Version:6.28.0-particle.17
- Source Commit:95f0a45d6d97c55f0fd417cf4d7a9a3746ab5aa3
- Exact Version:6.28.0-particle.19
- Source Commit:7c5ed27b0b3ae5fe74ab245cbef9dabf913dde4f
构建命令固定为:

View File

@@ -1,19 +1,19 @@
{
"schemaVersion": 2,
"status": "PUBLISHED_PUBLIC_READBACK_PASS",
"distributionVersion": "6.28.0-particle.17",
"distributionVersion": "6.28.0-particle.19",
"moduleName": "MapLibre",
"artifact": {
"fileName": "MapLibre.dynamic.xcframework.zip",
"sizeBytes": 8443308,
"sha256": "5535345dd480f80c0f083811abe93f011b41b4d1a03588189eec5c50a36af0be",
"url": "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.17/MapLibre.dynamic.xcframework.zip"
"sizeBytes": 8480397,
"sha256": "221c069b6ca073854716799d8593784310d4f0050be9c9d9a88215b352512efd",
"url": "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.19/MapLibre.dynamic.xcframework.zip"
},
"sourceCommit": "95f0a45d6d97c55f0fd417cf4d7a9a3746ab5aa3",
"sourceCommit": "7c5ed27b0b3ae5fe74ab245cbef9dabf913dde4f",
"swiftPackage": {
"url": "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle-spm.git",
"requirement": "exactVersion",
"version": "6.28.0-particle.17",
"version": "6.28.0-particle.19",
"productName": "MapLibre"
},
"notes": "Metal renderer release built with bazel build --//:renderer=metal //platform/ios:MapLibre.dynamic"

View File

@@ -1,48 +1,48 @@
{
"schemaVersion": 2,
"releaseState": "PUBLISHED_PUBLIC_READBACK_PASS",
"particleRevision": 17,
"sourceCommit": "95f0a45d6d97c55f0fd417cf4d7a9a3746ab5aa3",
"generatedAtUtc": "2026-09-29T00:43:56.789316+00:00",
"particleRevision": 19,
"sourceCommit": "7c5ed27b0b3ae5fe74ab245cbef9dabf913dde4f",
"generatedAtUtc": "2026-10-07T07:17:04.530231+00:00",
"reactNative": {
"name": "@navsea/maplibre-particle-rn",
"version": "13.4.1-particle.17",
"version": "13.4.1-particle.19",
"artifact": {
"fileName": "navsea-maplibre-particle-rn-13.4.1-particle.17.tgz",
"sizeBytes": 28526,
"sha256": "7415a094da5351b86a3c6343a8bc2ef75b7a8ad49577a5f0efc81407d72a79ec",
"url": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.17.tgz"
"fileName": "navsea-maplibre-particle-rn-13.4.1-particle.19.tgz",
"sizeBytes": 28825,
"sha256": "c98f1aef868c671a99556dbdd2518cdaa3a88a3d6133e620e5e8a38080d19b1a",
"url": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.19.tgz"
},
"mapLibreReactNative": "11.3.6"
},
"android": {
"version": "13.4.1-particle.17",
"coordinate": "cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.17",
"version": "13.4.1-particle.19",
"coordinate": "cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.19",
"renderer": "opengl",
"artifact": {
"fileName": "android-sdk-particle-opengl-13.4.1-particle.17.aar",
"sizeBytes": 170158515,
"sha256": "6c5f10f3ddd8b81e1c14ed9c310f8d5e216dfdded32c3db55bfb0ba5f5d175d5",
"url": "https://pbf.jpcn.ccwu.cc/maven/cc/navsea/maplibre/android-sdk-particle-opengl/13.4.1-particle.17/android-sdk-particle-opengl-13.4.1-particle.17.aar"
"fileName": "android-sdk-particle-opengl-13.4.1-particle.19.aar",
"sizeBytes": 170726451,
"sha256": "8ef9de51564aad6d5d8a9678534120cee34d3ee65c41f9f266c29bf057029712",
"url": "https://pbf.jpcn.ccwu.cc/maven/cc/navsea/maplibre/android-sdk-particle-opengl/13.4.1-particle.19/android-sdk-particle-opengl-13.4.1-particle.19.aar"
},
"ndk": "28.2.13676358",
"gradle": "9.5.1",
"jdk": "openjdk version \"17.0.20.1\" 2026-08-18"
},
"ios": {
"distributionVersion": "6.28.0-particle.17",
"distributionVersion": "6.28.0-particle.19",
"renderer": "metal",
"moduleName": "MapLibre",
"artifact": {
"fileName": "MapLibre.dynamic.xcframework.zip",
"sizeBytes": 8443308,
"sha256": "5535345dd480f80c0f083811abe93f011b41b4d1a03588189eec5c50a36af0be",
"url": "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.17/MapLibre.dynamic.xcframework.zip"
"sizeBytes": 8480397,
"sha256": "221c069b6ca073854716799d8593784310d4f0050be9c9d9a88215b352512efd",
"url": "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.19/MapLibre.dynamic.xcframework.zip"
},
"swiftPackage": {
"url": "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle-spm.git",
"requirement": "exactVersion",
"version": "6.28.0-particle.17",
"version": "6.28.0-particle.19",
"productName": "MapLibre"
},
"xcode": "Xcode 26.3 Build version 17C529 ",

View File

@@ -65,6 +65,50 @@ RendererObserver& nullObserver() {
return observer;
}
const char* sourceTypeText(style::SourceType type) noexcept {
switch (type) {
case style::SourceType::Vector: return "vector";
case style::SourceType::Raster: return "raster";
case style::SourceType::RasterDEM: return "raster-dem";
case style::SourceType::GeoJSON: return "geojson";
case style::SourceType::Video: return "video";
case style::SourceType::Annotations: return "annotations";
case style::SourceType::Image: return "image";
case style::SourceType::CustomVector: return "customvector";
case style::SourceType::RasterArray: return "raster-array";
}
return "unknown";
}
const char* tileOperationText(TileOperation op) noexcept {
switch (op) {
case TileOperation::RequestedFromCache: return "RequestedFromCache";
case TileOperation::RequestedFromNetwork: return "RequestedFromNetwork";
case TileOperation::LoadFromNetwork: return "LoadFromNetwork";
case TileOperation::LoadFromCache: return "LoadFromCache";
case TileOperation::StartParse: return "StartParse";
case TileOperation::EndParse: return "EndParse";
case TileOperation::Error: return "Error";
case TileOperation::Cancelled: return "Cancelled";
case TileOperation::NullOp: return "NullOp";
}
return "Unknown";
}
const char* readinessEventType(RetainedUnderlayReason reason) noexcept {
switch (reason) {
case RetainedUnderlayReason::SourceMutation: return "SourceMutation";
case RetainedUnderlayReason::TileMutation: return "TileMutation";
case RetainedUnderlayReason::ImageMutation: return "ImageMutation";
case RetainedUnderlayReason::GlyphMutation: return "GlyphMutation";
case RetainedUnderlayReason::StyleMutation: return "StyleMutation";
case RetainedUnderlayReason::LayerMutation:
case RetainedUnderlayReason::LayerGroupMutation: return "LayerMutation";
case RetainedUnderlayReason::ContextReset: return "ContextReset";
default: return "OtherMutation";
}
}
class RenderTreeImpl final : public RenderTree {
public:
RenderTreeImpl(std::unique_ptr<RenderTreeParameters> parameters_,
@@ -147,15 +191,134 @@ RenderOrchestrator::~RenderOrchestrator() {
}
}
// Wait for any deferred cleanup tasks to complete before releasing and potentially
// destroying the scheduler. Those cleanup tasks must not hold the final reference
// to the scheduler because it cannot be destroyed from one of its own pool threads.
// Finish work that can still reach the source observers before detaching them.
threadPool.waitForEmpty();
for (auto& entry : renderSources) {
entry.second->setObserver(nullptr);
}
glyphManager->setObserver(nullptr);
imageManager->setObserver(nullptr);
// Tile destructors can synchronously report Cancelled and enqueue further work.
// Destroy sources while the readiness state and its mutex are still alive.
renderSources.clear();
// Drain work submitted by source/tile destruction before the scheduler and
// the remaining orchestrator state are torn down.
threadPool.waitForEmpty();
}
void RenderOrchestrator::advanceRetainedUnderlayContentRevision(RetainedUnderlayReason reason) noexcept {
void RenderOrchestrator::recordReadinessMutation(const char* eventType,
RetainedUnderlayReason reason,
const std::optional<std::string>& sourceId,
const std::string& tileAction) noexcept {
try {
std::lock_guard<std::mutex> lock(readinessDiagnosticsMutex);
const auto sequence = ++readinessEventSequence;
lastReadinessMutation.sequence = sequence;
lastReadinessMutation.contentRevision = retainedUnderlayContentRevision.load(std::memory_order_acquire);
lastReadinessMutation.eventType = eventType;
lastReadinessMutation.reason = retainedUnderlayReasonText(reason);
lastReadinessMutation.sourceId = sourceId;
lastReadinessMutation.affectedSegmentIndices.clear();
lastReadinessMutation.dependencyScope = sourceId ? "unresolved-until-snapshot" : "shared/global/unknown";
if (sourceId) {
auto& state = sourceReadinessEvents[*sourceId];
state.lastMutationReason = retainedUnderlayReasonText(reason);
if (reason == RetainedUnderlayReason::SourceMutation) state.lastSourceMutationSequence = sequence;
if (reason == RetainedUnderlayReason::TileMutation) state.lastTileChangedSequence = sequence;
if (!tileAction.empty()) {
state.lastTileActionSequence = sequence;
state.lastTileAction = tileAction;
}
}
} catch (...) {
// Diagnostic recording is best-effort and must not escape into renderer mutation paths.
}
}
void RenderOrchestrator::advanceRetainedSourceRevision(RetainedUnderlayReason reason,
const std::string& sourceId) noexcept {
if (sourceId.empty()) {
std::lock_guard<std::mutex> lock(readinessDiagnosticsMutex);
retainedSegmentRevisions.advanceGlobal();
return;
}
try {
std::lock_guard<std::mutex> lock(readinessDiagnosticsMutex);
retainedSegmentRevisions.advanceSource(sourceId);
} catch (...) {
// Correctness state must fail closed if a source revision cannot be recorded.
std::lock_guard<std::mutex> lock(readinessDiagnosticsMutex);
retainedSegmentRevisions.advanceGlobal();
}
recordReadinessMutation(readinessEventType(reason), reason, sourceId);
}
void RenderOrchestrator::advanceRetainedUnderlayContentRevision(
RetainedUnderlayReason reason,
const std::optional<std::string>& sourceId) noexcept {
retainedUnderlayContentReason.store(reason, std::memory_order_release);
retainedUnderlayContentRevision.fetch_add(1, std::memory_order_acq_rel);
if (sourceId && (reason == RetainedUnderlayReason::SourceMutation ||
reason == RetainedUnderlayReason::TileMutation)) {
advanceRetainedSourceRevision(reason, *sourceId);
return;
}
{
std::lock_guard<std::mutex> lock(readinessDiagnosticsMutex);
retainedSegmentRevisions.advanceGlobal();
}
recordReadinessMutation(readinessEventType(reason), reason, sourceId);
}
RendererReadinessDiagnostics RenderOrchestrator::buildReadinessDiagnostics(bool styleLoaded,
bool spriteLoaded) const {
RendererReadinessDiagnostics result;
result.styleLoaded = styleLoaded;
result.imageManagerLoaded = imageManager->isLoaded();
result.spriteLoaded = spriteLoaded;
result.contentRevision = retainedUnderlayContentRevision.load(std::memory_order_acquire);
std::unordered_map<std::string, SourceReadinessEventState> sourceEvents;
{
std::lock_guard<std::mutex> lock(readinessDiagnosticsMutex);
// Snapshot-only: do not prune or otherwise mutate diagnostic history from a query path.
// Entries for sources no longer present are naturally ignored below because we enumerate
// the current renderSources set and only look up matching ids.
sourceEvents = sourceReadinessEvents;
result.lastMutation = lastReadinessMutation;
}
result.renderOrchestratorLoaded = result.imageManagerLoaded;
result.sources.reserve(renderSources.size());
for (const auto& [sourceId, renderSource] : renderSources) {
const auto stats = renderSource->getReadinessDiagnostics();
RenderSourceReadinessDiagnostics source;
source.sourceId = sourceId;
source.sourceType = sourceTypeText(renderSource->baseImpl->type);
source.enabled = renderSource->isEnabled();
source.isLoaded = renderSource->isLoaded();
source.tileBacked = stats.tileBacked;
source.tileCount = stats.tileCount;
source.incompleteTileCount = stats.incompleteTileCount;
source.loadedTileCount = stats.loadedTileCount;
source.hasFadingTiles = stats.hasFadingTiles;
result.hasFadingTiles = result.hasFadingTiles || stats.hasFadingTiles;
result.renderOrchestratorLoaded = result.renderOrchestratorLoaded && source.isLoaded;
if (const auto it = sourceEvents.find(sourceId); it != sourceEvents.end()) {
source.lastSourceMutationSequence = it->second.lastSourceMutationSequence;
source.lastTileChangedSequence = it->second.lastTileChangedSequence;
source.lastTileActionSequence = it->second.lastTileActionSequence;
source.lastTileAction = it->second.lastTileAction;
source.lastMutationReason = it->second.lastMutationReason;
}
result.sources.push_back(std::move(source));
}
std::ranges::sort(result.sources, {}, &RenderSourceReadinessDiagnostics::sourceId);
return result;
}
void RenderOrchestrator::markContextLost() {
@@ -360,7 +523,17 @@ std::unique_ptr<RenderTree> RenderOrchestrator::createRenderTree(
const SourceDifference sourceDiff = diffSources(sourceImpls, updateParameters->sources);
if (!sourceDiff.added.empty() || !sourceDiff.removed.empty() || !sourceDiff.changed.empty()) {
advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::SourceMutation);
retainedUnderlayContentReason.store(RetainedUnderlayReason::SourceMutation, std::memory_order_release);
retainedUnderlayContentRevision.fetch_add(1, std::memory_order_acq_rel);
for (const auto& entry : sourceDiff.added) {
advanceRetainedSourceRevision(RetainedUnderlayReason::SourceMutation, entry.first);
}
for (const auto& entry : sourceDiff.removed) {
advanceRetainedSourceRevision(RetainedUnderlayReason::SourceMutation, entry.first);
}
for (const auto& entry : sourceDiff.changed) {
advanceRetainedSourceRevision(RetainedUnderlayReason::SourceMutation, entry.first);
}
}
sourceImpls = updateParameters->sources;
@@ -449,11 +622,13 @@ std::unique_ptr<RenderTree> RenderOrchestrator::createRenderTree(
if (!impl.sourceLayer.empty()) value.sourceLayer = impl.sourceLayer;
styleLayerDiagnostics.push_back(std::move(value));
}
retainedStyleLayers = styleLayerDiagnostics;
updateParameters->retainedUnderlayState->recordRenderSegmentResolution(
renderSegmentPlan, std::move(styleLayerDiagnostics));
}
} else {
renderSegmentPlan = {};
retainedStyleLayers.clear();
}
// Update all sources and initialize renderItems.
@@ -519,7 +694,17 @@ std::unique_ptr<RenderTree> RenderOrchestrator::createRenderTree(
addChanges(changes);
}
// Readiness is sampled for diagnostics only. Preserve the renderer's formal load gate.
renderTreeParameters->loaded = updateParameters->styleLoaded && isLoaded();
if (updateParameters->retainedUnderlayState) {
try {
auto readinessDiagnostics =
buildReadinessDiagnostics(updateParameters->styleLoaded, updateParameters->spriteLoaded);
updateParameters->retainedUnderlayState->recordRendererReadiness(std::move(readinessDiagnostics));
} catch (...) {
// A failed diagnostic snapshot must not affect the render tree or its formal loaded gate.
}
}
if (!isMapModeContinuous && !renderTreeParameters->loaded) {
return nullptr;
}
@@ -835,7 +1020,7 @@ void RenderOrchestrator::setFeatureState(const std::string& sourceID,
if (RenderSource* renderSource = getRenderSource(sourceID)) {
renderSource->setFeatureState(sourceLayerID, featureID, state);
advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::SourceMutation);
advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::SourceMutation, sourceID);
}
}
@@ -858,7 +1043,7 @@ void RenderOrchestrator::removeFeatureState(const std::string& sourceID,
if (RenderSource* renderSource = getRenderSource(sourceID)) {
renderSource->removeFeatureState(sourceLayerID, featureID, stateKey);
advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::SourceMutation);
advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::SourceMutation, sourceID);
}
}
@@ -976,6 +1161,52 @@ bool RenderOrchestrator::retainedRangeIsStable(TimePoint timePoint, int32_t begi
return true;
}
RenderOrchestrator::RetainedSegmentSourceState RenderOrchestrator::retainedSegmentSourceState(
int32_t begin, int32_t endExclusive) const {
RetainedSegmentSourceState result;
const auto dependencies = renderSegmentDependencies(retainedStyleLayers, begin, endExclusive);
if (dependencies.unknown) return result;
// The global epoch contains only changes whose effect cannot be scoped to one source.
{
std::lock_guard<std::mutex> lock(readinessDiagnosticsMutex);
const auto value = retainedSegmentRevisions.revisionFor(dependencies);
if (!value) return result;
result.revision = *value;
}
const auto assessment = assessRenderSegmentSources(dependencies, [&](const std::string& sourceId)
-> std::optional<RenderSegmentSourceStatus> {
const auto source = renderSources.find(sourceId);
if (source == renderSources.end()) return std::nullopt;
return RenderSegmentSourceStatus{source->second->isLoaded(), source->second->hasFadingTiles()};
});
result.ready = assessment.ready;
result.stable = assessment.stable;
result.reason = assessment.reason;
return result;
}
bool RenderOrchestrator::retainedSegmentIsStable(TimePoint timePoint,
int32_t begin,
int32_t endExclusive) const {
if (renderLight.hasTransition()) return false;
if (retainedRangeUsesPlacement(begin, endExclusive) && placementController.hasTransitions(timePoint)) return false;
const auto first = std::clamp<int32_t>(begin, 0, static_cast<int32_t>(orderedLayers.size()));
const auto last = std::clamp<int32_t>(endExclusive, first, static_cast<int32_t>(orderedLayers.size()));
for (int32_t index = first; index < last; ++index) {
const auto& layer = orderedLayers[index].get();
if (layer.hasTransition() || layer.needsRepaint()) return false;
}
return true;
}
bool RenderOrchestrator::retainedGlobalResourcesReady() const {
return imageManager->isLoaded();
}
bool RenderOrchestrator::retainedGlobalLightTransition() const {
return renderLight.hasTransition();
}
bool RenderOrchestrator::retainedUnderlayHasUnsupportedLayer(int32_t boundary) const {
return retainedRangeHasUnsupportedLayer(0, boundary);
}
@@ -1080,6 +1311,13 @@ void RenderOrchestrator::clearData() {
imageManager->clear();
glyphManager->evict(fontStacks(*layerImpls));
try {
std::lock_guard<std::mutex> lock(readinessDiagnosticsMutex);
sourceReadinessEvents.clear();
lastReadinessMutation = {};
} catch (...) {
// Keep renderer style cleanup independent from optional diagnostic history cleanup.
}
advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::StyleMutation);
}
@@ -1295,10 +1533,10 @@ void RenderOrchestrator::onTileError(RenderSource& source, const OverscaledTileI
observer->onResourceError(error);
}
void RenderOrchestrator::onTileChanged(RenderSource&, const OverscaledTileID&) {
void RenderOrchestrator::onTileChanged(RenderSource& source, const OverscaledTileID&) {
MLN_TRACE_FUNC();
advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::TileMutation);
advanceRetainedUnderlayContentRevision(RetainedUnderlayReason::TileMutation, source.baseImpl->id);
observer->onInvalidate();
}
@@ -1306,6 +1544,15 @@ void RenderOrchestrator::onTileAction(RenderSource&,
TileOperation op,
const OverscaledTileID& id,
const std::string& sourceID) {
try {
std::lock_guard<std::mutex> lock(readinessDiagnosticsMutex);
const auto sequence = ++readinessEventSequence;
auto& state = sourceReadinessEvents[sourceID];
state.lastTileActionSequence = sequence;
state.lastTileAction = tileOperationText(op);
} catch (...) {
// Tile action observation is best-effort; always forward the original observer callback.
}
observer->onTileAction(op, id, sourceID);
}

View File

@@ -20,6 +20,7 @@
#include <atomic>
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <unordered_map>
@@ -127,6 +128,16 @@ public:
bool retainedUnderlayHasUnsupportedLayer(int32_t boundary) const;
bool retainedRangeUsesPlacement(int32_t begin, int32_t endExclusive) const;
bool retainedRangeIsStable(TimePoint, int32_t begin, int32_t endExclusive) const;
struct RetainedSegmentSourceState {
uint64_t revision = 0;
bool ready = false;
bool stable = false;
const char* reason = "unknown-dependency";
};
RetainedSegmentSourceState retainedSegmentSourceState(int32_t begin, int32_t endExclusive) const;
bool retainedSegmentIsStable(TimePoint, int32_t begin, int32_t endExclusive) const;
bool retainedGlobalResourcesReady() const;
bool retainedGlobalLightTransition() const;
bool retainedRangeHasUnsupportedLayer(int32_t begin, int32_t endExclusive) const;
bool continuousRangeSupportsParticleFastPath(int32_t begin, int32_t endExclusive) const;
uint64_t retainedRangeContentFingerprint(int32_t begin, int32_t endExclusive) const;
@@ -300,6 +311,9 @@ public:
const ZoomHistory& getZoomHistory() const { return zoomHistory; }
private:
// Allows the focused teardown test to install a pending tile-backed source.
friend struct RenderOrchestratorTeardownTestAccess;
bool isLoaded() const;
bool hasTransitions(TimePoint) const;
@@ -332,7 +346,15 @@ private:
/// Move changes into the pending set, clearing the provided collection
void addChanges(UniqueChangeRequestVec&);
void advanceRetainedUnderlayContentRevision(RetainedUnderlayReason) noexcept;
void advanceRetainedUnderlayContentRevision(
RetainedUnderlayReason,
const std::optional<std::string>& sourceId = std::nullopt) noexcept;
void advanceRetainedSourceRevision(RetainedUnderlayReason, const std::string& sourceId) noexcept;
void recordReadinessMutation(const char* eventType,
RetainedUnderlayReason reason,
const std::optional<std::string>& sourceId = std::nullopt,
const std::string& tileAction = {}) noexcept;
RendererReadinessDiagnostics buildReadinessDiagnostics(bool styleLoaded, bool spriteLoaded) const;
RendererObserver* observer;
@@ -364,8 +386,21 @@ private:
std::string retainedGlyphURL;
const void* retainedFontFacesIdentity = nullptr;
std::atomic<uint64_t> retainedUnderlayContentRevision{1};
RenderSegmentRevisionBook retainedSegmentRevisions;
std::atomic<RetainedUnderlayReason> retainedUnderlayContentReason{RetainedUnderlayReason::None};
struct SourceReadinessEventState {
uint64_t lastSourceMutationSequence = 0;
uint64_t lastTileChangedSequence = 0;
uint64_t lastTileActionSequence = 0;
std::string lastTileAction{"none"};
std::string lastMutationReason{"none"};
};
mutable std::mutex readinessDiagnosticsMutex;
uint64_t readinessEventSequence = 0;
std::unordered_map<std::string, SourceReadinessEventState> sourceReadinessEvents;
RenderMutationDiagnostics lastReadinessMutation;
#if MLN_RENDER_BACKEND_OPENGL
bool androidGoldfishMitigationEnabled{false};
#endif
@@ -374,6 +409,7 @@ private:
// reallocation on each frame.
std::vector<Immutable<style::LayerProperties>> filteredLayersForSource;
RenderLayerReferences orderedLayers;
std::vector<StyleLayerDiagnostics> retainedStyleLayers;
ResolvedRenderSegmentPlan renderSegmentPlan;
bool renderSegmentStyleReady = false;
RenderLayerReferences layersNeedPlacement;

View File

@@ -81,6 +81,20 @@ inline void writeOptionalString(Writer& writer, const char* key, const std::opti
if (value) writer.String(value->c_str()); else writer.Null();
}
template <typename Writer>
inline void writeOptionalUint32(Writer& writer, const char* key, const std::optional<uint32_t>& value) {
writer.Key(key);
if (value) writer.Uint(*value); else writer.Null();
}
template <typename Writer>
inline void writeStringArray(Writer& writer, const char* key, const std::vector<std::string>& values) {
writer.Key(key);
writer.StartArray();
for (const auto& value : values) writer.String(value.c_str());
writer.EndArray();
}
template <typename Writer>
inline void writeStyleLayerDiagnostics(Writer& writer, const StyleLayerDiagnostics& layer) {
writer.StartObject();
@@ -117,6 +131,43 @@ inline std::string renderSegmentDiagnosticsToJSON(const RenderSegmentDiagnostics
writer.Key("fastPathHit"); writer.Uint64(snapshot.fastPathHit);
writer.Key("fastPathMiss"); writer.Uint64(snapshot.fastPathMiss);
writer.Key("fullCpuFrame"); writer.Uint64(snapshot.fullCpuFrame);
writer.Key("styleLoaded"); writer.Bool(snapshot.styleLoaded);
writer.Key("imageManagerLoaded"); writer.Bool(snapshot.imageManagerLoaded);
writer.Key("renderOrchestratorLoaded"); writer.Bool(snapshot.renderOrchestratorLoaded);
writer.Key("spriteLoaded"); writer.Bool(snapshot.spriteLoaded);
writer.Key("hasFadingTiles"); writer.Bool(snapshot.hasFadingTiles);
writer.Key("contentRevision"); writer.Uint64(snapshot.contentRevision);
writer.Key("sources"); writer.StartArray();
for (const auto& source : snapshot.sources) {
writer.StartObject();
writer.Key("sourceId"); writer.String(source.sourceId.c_str());
writer.Key("sourceType"); writer.String(source.sourceType.c_str());
writer.Key("enabled"); writer.Bool(source.enabled);
writer.Key("isLoaded"); writer.Bool(source.isLoaded);
writer.Key("tileBacked"); writer.Bool(source.tileBacked);
writeOptionalUint32(writer, "tileCount", source.tileCount);
writeOptionalUint32(writer, "incompleteTileCount", source.incompleteTileCount);
writeOptionalUint32(writer, "loadedTileCount", source.loadedTileCount);
writer.Key("hasFadingTiles"); writer.Bool(source.hasFadingTiles);
writer.Key("lastSourceMutationSequence"); writer.Uint64(source.lastSourceMutationSequence);
writer.Key("lastTileChangedSequence"); writer.Uint64(source.lastTileChangedSequence);
writer.Key("lastTileActionSequence"); writer.Uint64(source.lastTileActionSequence);
writer.Key("lastTileAction"); writer.String(source.lastTileAction.c_str());
writer.Key("lastMutationReason"); writer.String(source.lastMutationReason.c_str());
writer.EndObject();
}
writer.EndArray();
writer.Key("lastMutation"); writer.StartObject();
writer.Key("sequence"); writer.Uint64(snapshot.lastMutation.sequence);
writer.Key("contentRevision"); writer.Uint64(snapshot.lastMutation.contentRevision);
writer.Key("eventType"); writer.String(snapshot.lastMutation.eventType.c_str());
writer.Key("reason"); writer.String(snapshot.lastMutation.reason.c_str());
writeOptionalString(writer, "sourceId", snapshot.lastMutation.sourceId);
writer.Key("affectedSegmentIndices"); writer.StartArray();
for (const auto index : snapshot.lastMutation.affectedSegmentIndices) writer.Uint(index);
writer.EndArray();
writer.Key("dependencyScope"); writer.String(snapshot.lastMutation.dependencyScope.c_str());
writer.EndObject();
writer.Key("styleLayers"); writer.StartArray();
for (const auto& layer : snapshot.styleLayers) writeStyleLayerDiagnostics(writer, layer);
writer.EndArray();
@@ -134,6 +185,8 @@ inline std::string renderSegmentDiagnosticsToJSON(const RenderSegmentDiagnostics
writer.Key("layers"); writer.StartArray();
for (const auto& layer : segment.layers) writeStyleLayerDiagnostics(writer, layer);
writer.EndArray();
writeStringArray(writer, "dependentSourceIds", segment.dependentSourceIds);
writeStringArray(writer, "unknownDependencies", segment.unknownDependencies);
writer.Key("cache");
if (!segment.cache) {
writer.Null();
@@ -145,6 +198,13 @@ inline std::string renderSegmentDiagnosticsToJSON(const RenderSegmentDiagnostics
writer.Key("build"); writer.Uint64(cache.build);
writer.Key("reuse"); writer.Uint64(cache.reuse);
writer.Key("invalidation"); writer.Uint64(cache.invalidation);
writer.Key("fallbackCount"); writer.Uint64(cache.fallbackCount);
writer.Key("finalDecision"); writer.String(cache.finalDecision.c_str());
writer.Key("lastFallbackReason"); writer.String(cache.lastFallbackReason.c_str());
writer.Key("sourceReady"); writer.Bool(cache.sourceReady);
writer.Key("sourceStable"); writer.Bool(cache.sourceStable);
writer.Key("supported"); writer.Bool(cache.supported);
writer.Key("sourceRevision"); writer.Uint64(cache.sourceRevision);
writer.Key("lastInvalidationReason"); writer.String(cache.lastInvalidationReason.c_str());
writer.Key("textureCreate"); writer.Uint64(cache.textureCreate);
writer.Key("compositeCreate"); writer.Uint64(cache.compositeCreate);

View File

@@ -71,7 +71,7 @@ RenderSource::RenderSource(Immutable<style::Source::Impl> impl)
RenderSource::~RenderSource() = default;
void RenderSource::setObserver(RenderSourceObserver* observer_) {
observer = observer_;
observer = observer_ ? observer_ : &nullObserver;
}
void RenderSource::onTileChanged(Tile& tile) {

View File

@@ -11,8 +11,10 @@
#include <mbgl/style/source_impl.hpp>
#include <mbgl/style/layer_properties.hpp>
#include <cstdint>
#include <map>
#include <memory>
#include <optional>
#include <unordered_map>
#include <vector>
@@ -49,6 +51,14 @@ public:
using RenderTiles = std::shared_ptr<const std::vector<std::reference_wrapper<const RenderTile>>>;
struct RenderSourceReadinessStats {
bool tileBacked = false;
std::optional<uint32_t> tileCount;
std::optional<uint32_t> incompleteTileCount;
std::optional<uint32_t> loadedTileCount;
bool hasFadingTiles = false;
};
class RenderSource : protected TileObserver {
public:
static std::unique_ptr<RenderSource> create(const Immutable<style::Source::Impl>&, const TaggedScheduler&);
@@ -68,6 +78,17 @@ public:
virtual void prepare(const SourcePrepareParameters&) = 0;
virtual void updateFadingTiles() = 0;
virtual bool hasFadingTiles() const = 0;
// Read on the renderer thread while the source and its tile pyramid are owned by RenderOrchestrator.
// This is an observational summary and must not define or modify isLoaded().
virtual RenderSourceReadinessStats getReadinessDiagnostics() const {
return {
.tileBacked = false,
.tileCount = std::nullopt,
.incompleteTileCount = std::nullopt,
.loadedTileCount = std::nullopt,
.hasFadingTiles = hasFadingTiles(),
};
}
// If supported, returns a shared list of RenderTiles, sorted by tile id and
// excluding tiles hold for fade; returns nullptr otherwise.
virtual RenderTiles getRenderTiles() const { return nullptr; }

View File

@@ -551,17 +551,17 @@ void Renderer::Impl::render(const RenderTree& renderTree,
const auto retainedTopologyRevision = particleFastFrame ? retainedUnderlayTopologyTracker.current()
: retainedUnderlayTopologyTracker.observe(retainedTopology);
bool legacyInRetainedRange = false;
std::vector<bool> legacyInSegment(segmentPlan.segments.size(), false);
if (retainedMode) {
for (const auto& item : layerRenderItems) {
const auto index = static_cast<int32_t>(item.index);
for (const auto& segment : segmentPlan.segments) {
if (segment.strategy == RenderSegmentStrategy::Retained && index >= segment.beginIndex &&
index < segment.endExclusive && item.layer.get().usesLegacyRendering()) {
legacyInSegment[segment.segmentIndex] = true;
legacyInRetainedRange = true;
break;
}
}
if (legacyInRetainedRange) break;
}
}
bool retainedStaticUsesPlacement = false;
@@ -601,7 +601,7 @@ void Renderer::Impl::render(const RenderTree& renderTree,
.has3D = renderTreeParameters.has3D,
.unsupportedStaticLayer = retainedUnsupportedStaticLayer,
};
const auto retainedStaticRevision = retainedUnderlayRevisionTracker.observe(
retainedUnderlayRevisionTracker.observe(
retainedRevisionState, retainedUnderlayReasonText(retainedContentReason));
const bool retainedSupported = retainedMode &&
renderTreeParameters.loaded && !renderTreeParameters.has3D &&
@@ -636,7 +636,8 @@ void Renderer::Impl::render(const RenderTree& renderTree,
const auto wantsResources = [](RetainedUnderlayDecision decision) {
return decision == RetainedUnderlayDecision::Build || decision == RetainedUnderlayDecision::Reuse;
};
bool resourcesReady = true;
std::vector<RenderOrchestrator::RetainedSegmentSourceState> segmentSourceStates(segmentPlan.segments.size());
std::vector<bool> segmentSupportsCache(segmentPlan.segments.size(), false);
for (const auto& segment : segmentPlan.segments) {
if (segment.strategy != RenderSegmentStrategy::Retained) continue;
auto& cache = retainedSegmentCaches[segment.segmentIndex];
@@ -646,34 +647,38 @@ void Renderer::Impl::render(const RenderTree& renderTree,
const bool layerContentChanged = retainedSegmentFingerprints[segment.segmentIndex] != 0 &&
retainedSegmentFingerprints[segment.segmentIndex] != fingerprint;
retainedSegmentFingerprints[segment.segmentIndex] = fingerprint;
const auto segmentContentRevision = retainedStaticRevision ^
(fingerprint +
0x9e3779b97f4a7c15ULL + (retainedStaticRevision << 6U) + (retainedStaticRevision >> 2U));
const auto sourceState = orchestrator.retainedSegmentSourceState(
segment.beginIndex, segment.endExclusive);
segmentSourceStates[segment.segmentIndex] = sourceState;
const auto segmentContentRevision = sourceState.revision ^
(fingerprint + 0x9e3779b97f4a7c15ULL + (sourceState.revision << 6U) +
(sourceState.revision >> 2U)) ^
static_cast<uint64_t>(updateParameters->pixelRatio * 1000000.0f);
const bool segmentStable = sourceState.stable && orchestrator.retainedSegmentIsStable(
renderTreeParameters.timePoint, segment.beginIndex, segment.endExclusive);
const bool segmentSupported = retainedMode && updateParameters->styleLoaded &&
updateParameters->spriteLoaded && orchestrator.retainedGlobalResourcesReady() &&
updateParameters->mode == MapMode::Continuous && !renderTreeParameters.has3D &&
renderTreeParameters.debugOptions == MapDebugOptions::NoDebug && segmentStable &&
retainedPlacementStable && !legacyInSegment[segment.segmentIndex] &&
!orchestrator.retainedRangeHasUnsupportedLayer(segment.beginIndex, segment.endExclusive);
segmentSupportsCache[segment.segmentIndex] = segmentSupported;
const char* segmentFallbackReason = !sourceState.ready ? sourceState.reason
: !sourceState.stable ? sourceState.reason
: !updateParameters->styleLoaded || !updateParameters->spriteLoaded ||
!orchestrator.retainedGlobalResourcesReady() ? "style-not-loaded"
: !segmentStable ? "static-transition" : retainedFallbackReason;
decision = cache->evaluate(segmentKeys[segment.segmentIndex],
segmentContentRevision,
retainedSupported,
retainedFallbackReason,
segmentSupported,
segmentFallbackReason,
layerContentChanged ? "layer-mutation"
: retainedUnderlayRevisionTracker.reason().c_str());
if (resourcesReady && wantsResources(decision)) {
resourcesReady = staticData->shaders && cache->prepareResources(
context, *staticData->shaders, *segmentKeys[segment.segmentIndex]);
}
}
if (!resourcesReady) {
for (const auto& segment : segmentPlan.segments) {
if (segment.strategy != RenderSegmentStrategy::Retained) continue;
auto& decision = segmentDecisions[segment.segmentIndex];
if (wantsResources(decision)) {
retainedSegmentCaches[segment.segmentIndex]->markBuildFailed("resource-or-compositor");
decision = RetainedUnderlayDecision::Fallback;
}
}
}
bool useRetainedRanges = retainedSupported;
for (const auto& segment : segmentPlan.segments) {
if (segment.strategy == RenderSegmentStrategy::Retained) {
useRetainedRanges = useRetainedRanges && wantsResources(segmentDecisions[segment.segmentIndex]);
: "source-or-global-mutation");
if (wantsResources(decision) &&
(!staticData->shaders || !cache->prepareResources(
context, *staticData->shaders, *segmentKeys[segment.segmentIndex]))) {
cache->markBuildFailed("resource-or-compositor");
decision = RetainedUnderlayDecision::Fallback;
}
}
@@ -726,9 +731,10 @@ void Renderer::Impl::render(const RenderTree& renderTree,
if (!particleFastFrame) {
orchestrator.visitDebugLayerGroups([&](LayerGroupBase& layerGroup) { layerGroup.upload(*uploadPass); });
}
if (useRetainedRanges) {
if (retainedMode) {
for (const auto& segment : segmentPlan.segments) {
if (segment.strategy == RenderSegmentStrategy::Retained) {
if (segment.strategy == RenderSegmentStrategy::Retained &&
wantsResources(segmentDecisions[segment.segmentIndex])) {
retainedSegmentCaches[segment.segmentIndex]->upload(*uploadPass);
}
}
@@ -797,10 +803,10 @@ void Renderer::Impl::render(const RenderTree& renderTree,
};
const auto buildRetainedRange = [&](RetainedUnderlayCache& cache,
RetainedUnderlayDecision decision,
RetainedUnderlayDecision& decision,
int32_t begin,
int32_t endExclusive) {
if (!useRetainedRanges || decision != RetainedUnderlayDecision::Build || begin >= endExclusive) return;
if (decision != RetainedUnderlayDecision::Build || begin >= endExclusive) return;
bool drawFailed = false;
bool globalUniformsBound = false;
try {
@@ -841,7 +847,7 @@ void Renderer::Impl::render(const RenderTree& renderTree,
}
if (drawFailed) {
cache.markBuildFailed("resource-or-compositor");
useRetainedRanges = false;
decision = RetainedUnderlayDecision::Fallback;
} else {
cache.markBuildSucceeded();
}
@@ -878,7 +884,7 @@ void Renderer::Impl::render(const RenderTree& renderTree,
PaintParameters::depthEpsilon;
// draw layer groups, opaque pass
if (useRetainedRanges && retainedMode) {
if (retainedMode) {
orchestrator.visitLayerGroupsInRangeReversed(
begin, endExclusive,
[&](LayerGroupBase& layerGroup, size_t ordinal) {
@@ -902,7 +908,7 @@ void Renderer::Impl::render(const RenderTree& renderTree,
PaintParameters::depthEpsilon;
// draw layer groups, translucent pass
if (useRetainedRanges && retainedMode) {
if (retainedMode) {
orchestrator.visitLayerGroupsInRange(
begin, endExclusive,
[&](LayerGroupBase& layerGroup, size_t ordinal) {
@@ -929,7 +935,7 @@ void Renderer::Impl::render(const RenderTree& renderTree,
parameters.currentLayer = i;
const LayerRenderItem& layerItem = *it;
const RenderItem& item = layerItem;
const bool itemInLiveRange = !useRetainedRanges || !retainedMode ||
const bool itemInLiveRange = !retainedMode ||
(static_cast<int32_t>(layerItem.index) >= begin &&
static_cast<int32_t>(layerItem.index) < endExclusive);
if (itemInLiveRange && item.hasRenderPass(parameters.pass)) {
@@ -964,20 +970,39 @@ void Renderer::Impl::render(const RenderTree& renderTree,
segment.endExclusive);
}
}
if (retainedRuntimeState) {
for (const auto& segment : segmentPlan.segments) {
if (segment.strategy != RenderSegmentStrategy::Retained) continue;
const auto index = segment.segmentIndex;
const auto decision = segmentDecisions[index];
const char* decisionText = decision == RetainedUnderlayDecision::Build ? "build" :
decision == RetainedUnderlayDecision::Reuse ? "reuse" :
decision == RetainedUnderlayDecision::Fallback ? "fallback" : "disabled";
const auto& source = segmentSourceStates[index];
retainedRuntimeState->recordSegmentExecution(index, source.ready, source.stable,
segmentSupportsCache[index], source.revision, decisionText,
decision == RetainedUnderlayDecision::Fallback
? retainedSegmentCaches[index]->getLastInvalidationReason().c_str() : nullptr);
}
}
commonClearPass();
context.bindGlobalUniformBuffers(*parameters.renderPass);
if (useRetainedRanges) {
for (const auto& segment : segmentPlan.segments) {
if (segment.strategy == RenderSegmentStrategy::Retained) {
retainedSegmentCaches[segment.segmentIndex]->composite(orchestrator, parameters);
} else {
drawableOpaquePass(segment.beginIndex, segment.endExclusive);
drawableTranslucentPass(segment.beginIndex, segment.endExclusive);
}
}
} else {
const bool globalLiveFallback = !retainedMode || renderTreeParameters.has3D ||
renderTreeParameters.debugOptions != MapDebugOptions::NoDebug ||
updateParameters->mode != MapMode::Continuous || !updateParameters->spriteLoaded ||
!orchestrator.retainedGlobalResourcesReady() || !retainedPlacementStable ||
orchestrator.retainedGlobalLightTransition();
if (globalLiveFallback) {
drawableOpaquePass(0, segmentPlan.layerCount);
drawableTranslucentPass(0, segmentPlan.layerCount);
} else {
visitRenderSegmentExecution(segmentPlan,
[&](uint32_t index) { return wantsResources(segmentDecisions[index]); },
[&](uint32_t index) { retainedSegmentCaches[index]->composite(orchestrator, parameters); },
[&](int32_t begin, int32_t endExclusive) {
drawableOpaquePass(begin, endExclusive);
drawableTranslucentPass(begin, endExclusive);
});
}
drawableDebugOverlays();

View File

@@ -42,6 +42,11 @@ RetainedUnderlayReason retainedReason(const char* reason) noexcept {
if (value == "style-mutation") return RetainedUnderlayReason::StyleMutation;
if (value == "layer-mutation") return RetainedUnderlayReason::LayerMutation;
if (value == "source-mutation") return RetainedUnderlayReason::SourceMutation;
if (value == "source-or-global-mutation") return RetainedUnderlayReason::SourceOrGlobalMutation;
if (value == "source-not-loaded") return RetainedUnderlayReason::SourceNotLoaded;
if (value == "source-fading") return RetainedUnderlayReason::SourceFading;
if (value == "missing-source") return RetainedUnderlayReason::MissingSource;
if (value == "unknown-dependency") return RetainedUnderlayReason::UnknownDependency;
if (value == "tile-mutation") return RetainedUnderlayReason::TileMutation;
if (value == "image-mutation") return RetainedUnderlayReason::ImageMutation;
if (value == "glyph-mutation") return RetainedUnderlayReason::GlyphMutation;
@@ -177,6 +182,7 @@ RetainedUnderlayDecision RetainedUnderlayCache::evaluate(const std::optional<Ret
return RetainedUnderlayDecision::Disabled;
}
if (runtimeState && !runtimeState->isEnabled()) {
lastInvalidationReason = "runtime-disabled";
++fallbackCount;
runtimeState->recordFallback();
particle::incrementRendererDebugCounter(particle::RendererDebugCounter::RetainedUnderlayFallback);

View File

@@ -5,8 +5,11 @@
#include <mbgl/renderer/render_segment.hpp>
#include <mbgl/renderer/render_segment_plan.hpp>
#include <algorithm>
#include <atomic>
#include <mutex>
#include <set>
#include <string_view>
#include <tuple>
#include <utility>
@@ -43,6 +46,11 @@ enum class RetainedUnderlayReason : uint8_t {
StyleMutation,
LayerMutation,
SourceMutation,
SourceOrGlobalMutation,
SourceNotLoaded,
SourceFading,
MissingSource,
UnknownDependency,
TileMutation,
ImageMutation,
GlyphMutation,
@@ -73,6 +81,11 @@ inline const char* retainedUnderlayReasonText(RetainedUnderlayReason reason) noe
case RetainedUnderlayReason::StyleMutation: return "style-mutation";
case RetainedUnderlayReason::LayerMutation: return "layer-mutation";
case RetainedUnderlayReason::SourceMutation: return "source-mutation";
case RetainedUnderlayReason::SourceOrGlobalMutation: return "source-or-global-mutation";
case RetainedUnderlayReason::SourceNotLoaded: return "source-not-loaded";
case RetainedUnderlayReason::SourceFading: return "source-fading";
case RetainedUnderlayReason::MissingSource: return "missing-source";
case RetainedUnderlayReason::UnknownDependency: return "unknown-dependency";
case RetainedUnderlayReason::TileMutation: return "tile-mutation";
case RetainedUnderlayReason::ImageMutation: return "image-mutation";
case RetainedUnderlayReason::GlyphMutation: return "glyph-mutation";
@@ -224,6 +237,11 @@ public:
entry.layers.assign(next.styleLayers.begin() + segment.beginIndex,
next.styleLayers.begin() + segment.endExclusive);
}
const auto dependencies = renderSegmentDependencies(next.styleLayers,
segment.beginIndex, segment.endExclusive);
entry.dependentSourceIds = dependencies.sourceIds;
entry.unknownDependencies = dependencies.globalPrerequisites;
if (dependencies.unknown) entry.unknownDependencies.push_back("unknown/source-or-custom");
if (segment.strategy == RenderSegmentStrategy::Retained) {
++next.retainedSegmentCount;
entry.cache.emplace();
@@ -240,6 +258,37 @@ public:
renderSegmentDiagnostics = std::move(next);
}
void recordRendererReadiness(RendererReadinessDiagnostics readiness) {
std::lock_guard<std::mutex> lock(rangeMutex);
renderSegmentDiagnostics.styleLoaded = readiness.styleLoaded;
renderSegmentDiagnostics.imageManagerLoaded = readiness.imageManagerLoaded;
renderSegmentDiagnostics.renderOrchestratorLoaded = readiness.renderOrchestratorLoaded;
renderSegmentDiagnostics.spriteLoaded = readiness.spriteLoaded;
renderSegmentDiagnostics.hasFadingTiles = readiness.hasFadingTiles;
renderSegmentDiagnostics.contentRevision = readiness.contentRevision;
renderSegmentDiagnostics.sources = std::move(readiness.sources);
auto mutation = std::move(readiness.lastMutation);
mutation.affectedSegmentIndices.clear();
if (mutation.sequence != 0) {
if (mutation.sourceId) {
for (const auto& segment : renderSegmentDiagnostics.segments) {
if (std::find(segment.dependentSourceIds.begin(),
segment.dependentSourceIds.end(),
*mutation.sourceId) != segment.dependentSourceIds.end()) {
mutation.affectedSegmentIndices.push_back(segment.segmentIndex);
}
}
mutation.dependencyScope = mutation.affectedSegmentIndices.empty()
? "source-not-in-render-segments"
: "source-local-diagnostic";
} else {
mutation.dependencyScope = "shared/global/unknown";
}
}
renderSegmentDiagnostics.lastMutation = std::move(mutation);
}
RenderSegmentDiagnostics renderSegmentSnapshot() const {
std::lock_guard<std::mutex> lock(rangeMutex);
auto result = renderSegmentDiagnostics;
@@ -263,6 +312,19 @@ public:
entry.cache->cacheState = retainedRangeCacheStateText(state);
entry.cache->contentRevision = contentRevision;
}
void recordSegmentExecution(uint32_t index, bool sourceReady, bool sourceStable,
bool supported, uint64_t sourceRevision,
const char* decision, const char* fallbackReason) {
updateSegmentCache(index, [&](auto& value) {
value.sourceReady = sourceReady;
value.sourceStable = sourceStable;
value.supported = supported;
value.sourceRevision = sourceRevision;
value.finalDecision = decision;
if (fallbackReason) value.lastFallbackReason = fallbackReason;
if (std::string_view(decision) == "fallback") ++value.fallbackCount;
});
}
void recordSegmentBuild(uint32_t index) { updateSegmentCache(index, [](auto& value) { ++value.build; }); }
void recordSegmentReuse(uint32_t index) { updateSegmentCache(index, [](auto& value) { ++value.reuse; }); }
void recordSegmentInvalidation(uint32_t index, RetainedUnderlayReason reason) {

View File

@@ -486,6 +486,23 @@ bool RenderTileSource::hasFadingTiles() const {
return tilePyramid.hasFadingTiles();
}
RenderSourceReadinessStats RenderTileSource::getReadinessDiagnostics() const {
RenderSourceReadinessStats result;
result.tileBacked = true;
result.tileCount = static_cast<uint32_t>(tilePyramid.getTiles().size());
uint32_t incomplete = 0;
uint32_t loaded = 0;
for (const auto& entry : tilePyramid.getTiles()) {
const auto& tile = *entry.second;
if (!tile.isComplete()) ++incomplete;
if (tile.isLoaded()) ++loaded;
}
result.incompleteTileCount = incomplete;
result.loadedTileCount = loaded;
result.hasFadingTiles = tilePyramid.hasFadingTiles();
return result;
}
RenderTiles RenderTileSource::getRenderTiles() const {
if (!filteredRenderTiles) {
auto result = std::make_shared<std::vector<std::reference_wrapper<const RenderTile>>>();

View File

@@ -23,6 +23,7 @@ public:
void prepare(const SourcePrepareParameters&) override;
void updateFadingTiles() override;
bool hasFadingTiles() const override;
RenderSourceReadinessStats getReadinessDiagnostics() const override;
RenderTiles getRenderTiles() const override;
RenderTiles getRenderTilesSortedByYPosition() const override;

View File

@@ -4,13 +4,29 @@
#include <mbgl/renderer/render_orchestrator.hpp>
#include <mbgl/renderer/renderer_observer.hpp>
#include <mbgl/renderer/retained_underlay_cache.hpp>
#include <mbgl/renderer/render_source.hpp>
#include <mbgl/renderer/render_source_observer.hpp>
#include <mbgl/renderer/tile_render_data.hpp>
#include <mbgl/style/sources/geojson_source_impl.hpp>
#include <mbgl/tile/tile.hpp>
#include <mbgl/tile/tile_observer.hpp>
#include <gtest/gtest.h>
#include <atomic>
#include <stdexcept>
using namespace mbgl;
namespace mbgl {
struct RenderOrchestratorTeardownTestAccess {
static void add(RenderOrchestrator& orchestrator, std::unique_ptr<RenderSource> source) {
source->setObserver(&orchestrator);
orchestrator.renderSources.emplace(source->baseImpl->id, std::move(source));
}
};
} // namespace mbgl
namespace {
class TestUniformBuffers final : public gfx::UniformBufferArray {
@@ -108,3 +124,128 @@ TEST(RenderOrchestratorError, FailedRangeCannotReuseAndCleanRetryCanRecover) {
cache.markBuildSucceeded();
EXPECT_EQ(cache.evaluate(key, 1, true), RetainedUnderlayDecision::Reuse);
}
namespace {
class TeardownTile final : public Tile {
public:
TeardownTile(TileObserver* observer_)
: Tile(Kind::Geometry, OverscaledTileID{0, 0, {0, 0, 0}}, "step101-source", observer_) {}
~TeardownTile() override { observer->onTileAction(id, sourceID, TileOperation::Cancelled); }
std::unique_ptr<TileRenderData> createRenderData() override { return nullptr; }
void cancel() override {}
bool layerPropertiesUpdated(const Immutable<style::LayerProperties>&) override { return true; }
void emitChanged() { observer->onTileChanged(*this); }
void emitError() { observer->onTileError(*this, std::make_exception_ptr(std::runtime_error("tile error"))); }
void emitAction() { observer->onTileAction(id, sourceID, TileOperation::Cancelled); }
};
class TeardownSource final : public RenderSource {
public:
TeardownSource(const TaggedScheduler& scheduler_, std::atomic<int>& deferred_, std::atomic<int>& renderJobs_,
std::atomic<int>& detached_)
: RenderSource(makeMutable<style::GeoJSONSource::Impl>(
"step101-source", style::GeoJSONOptions::defaultOptions())),
scheduler(scheduler_), deferred(deferred_), renderJobs(renderJobs_), detached(detached_),
tile(std::make_unique<TeardownTile>(static_cast<TileObserver*>(this))) {}
~TeardownSource() override {
if (observer != oldObserver) ++detached;
tile.reset(); // A pending tile emits Cancelled synchronously during source destruction.
scheduler.schedule([&count = deferred] { ++count; });
scheduler.runOnRenderThread([&count = renderJobs] { ++count; });
}
void expectDetachFrom(RenderSourceObserver* observer_) { oldObserver = observer_; }
void emitAll() {
tile->emitChanged();
tile->emitError();
tile->emitAction();
}
bool isLoaded() const override { return false; }
void update(Immutable<style::Source::Impl>, const std::vector<Immutable<style::LayerProperties>>&,
bool, bool, const TileParameters&) override {}
std::unique_ptr<RenderItem> createRenderItem() override { return nullptr; }
void prepare(const SourcePrepareParameters&) override {}
void updateFadingTiles() override {}
bool hasFadingTiles() const override { return false; }
RenderSourceReadinessStats getReadinessDiagnostics() const override {
return {.tileBacked = true,
.tileCount = std::nullopt,
.incompleteTileCount = std::nullopt,
.loadedTileCount = std::nullopt,
.hasFadingTiles = false};
}
std::unordered_map<std::string, std::vector<Feature>> queryRenderedFeatures(
const ScreenLineString&, const TransformState&,
const std::unordered_map<std::string, const RenderLayer*>&,
const RenderedQueryOptions&, const mat4&) const override { return {}; }
std::vector<Feature> querySourceFeatures(const SourceQueryOptions&) const override { return {}; }
void reduceMemoryUse() override {}
void dumpDebugLogs() const override {}
private:
TaggedScheduler scheduler;
std::atomic<int>& deferred;
std::atomic<int>& renderJobs;
std::atomic<int>& detached;
RenderSourceObserver* oldObserver = nullptr;
std::unique_ptr<TeardownTile> tile;
};
class CountingSourceObserver final : public RenderSourceObserver {
public:
void onTileChanged(RenderSource&, const OverscaledTileID&) override { ++changed; }
void onTileError(RenderSource&, const OverscaledTileID&, std::exception_ptr) override { ++errors; }
void onTileAction(RenderSource&, TileOperation, const OverscaledTileID&, const std::string&) override { ++actions; }
int changed = 0;
int errors = 0;
int actions = 0;
};
} // namespace
TEST(RenderOrchestratorTeardown, NullObserverAndDetachCoverEveryTileCallback) {
TaggedScheduler scheduler{Scheduler::GetBackground(), {}};
std::atomic<int> deferred{0};
std::atomic<int> renderJobs{0};
std::atomic<int> detached{0};
auto source = std::make_unique<TeardownSource>(scheduler, deferred, renderJobs, detached);
source->emitAll(); // Constructor default NullObserver.
CountingSourceObserver observer;
source->setObserver(&observer);
source->expectDetachFrom(&observer);
source->emitAll();
EXPECT_EQ(observer.changed, 1);
EXPECT_EQ(observer.errors, 1);
EXPECT_EQ(observer.actions, 1);
source->setObserver(nullptr);
source->emitAll();
source.reset(); // Cancelled must not reach the detached observer.
EXPECT_EQ(detached.load(), 1);
EXPECT_EQ(observer.changed, 1);
EXPECT_EQ(observer.errors, 1);
EXPECT_EQ(observer.actions, 1);
scheduler.waitForEmpty();
scheduler.runRenderJobs();
}
TEST(RenderOrchestratorTeardown, PendingTileCancellationAndSecondDrainRepeatSafely) {
TaggedScheduler scheduler{Scheduler::GetBackground(), {}};
std::atomic<int> deferred{0};
std::atomic<int> renderJobs{0};
std::atomic<int> detached{0};
for (int iteration = 0; iteration < 20; ++iteration) {
auto orchestrator = std::make_unique<RenderOrchestrator>(false, scheduler, std::nullopt);
auto source = std::make_unique<TeardownSource>(scheduler, deferred, renderJobs, detached);
source->expectDetachFrom(orchestrator.get());
RenderOrchestratorTeardownTestAccess::add(*orchestrator, std::move(source));
orchestrator.reset();
EXPECT_EQ(detached.load(), iteration + 1); // Source was detached before its tile cancelled.
EXPECT_EQ(deferred.load(), iteration + 1); // Work queued by source destruction was drained.
scheduler.runRenderJobs(); // Render jobs have a separate backend-owned queue.
EXPECT_EQ(renderJobs.load(), iteration + 1);
}
}

View File

@@ -387,6 +387,8 @@ TEST(RetainedUnderlayRevision, EquivalentIntraFrameRemoveAddDoesNotAdvanceTopolo
const RetainedUnderlayTopologySnapshot initial{
.boundary = 32,
.staticPrefix = {{3, "sea-area-fill", 1}, {31, "clip-outline", 1}},
.continuousEndExclusive = std::nullopt,
.staticSuffix = {},
};
const auto settledRevision = tracker.observe(initial);
@@ -404,6 +406,8 @@ TEST(RetainedUnderlayRevision, PersistentTopologyChangeAdvancesOnce) {
const RetainedUnderlayTopologySnapshot initial{
.boundary = 32,
.staticPrefix = {{3, "sea-area-fill", 1}, {31, "clip-outline", 1}},
.continuousEndExclusive = std::nullopt,
.staticSuffix = {},
};
const auto initialRevision = tracker.observe(initial);
@@ -419,6 +423,8 @@ TEST(RetainedUnderlayRevision, OrderingAndIndexChangesAdvanceTopology) {
RetainedUnderlayTopologySnapshot topology{
.boundary = 32,
.staticPrefix = {{3, "sea-area-fill", 1}, {31, "clip-outline", 1}},
.continuousEndExclusive = std::nullopt,
.staticSuffix = {},
};
const auto initialRevision = tracker.observe(topology);
@@ -934,6 +940,223 @@ TEST(RenderSegmentDiagnostics, SnapshotCoversStyleExactlyAndSerializesContinuous
EXPECT_NE(json.find("\"cache\":null"), std::string::npos);
}
TEST(RenderSegmentDiagnostics, ReadinessSnapshotMapsSourcesToSegmentsWithoutSideEffects) {
RetainedUnderlayRuntimeState state;
RenderSegmentPlan configured;
configured.segments = {{RenderSegmentStrategy::Retained, std::nullopt},
{RenderSegmentStrategy::Continuous, "weather"},
{RenderSegmentStrategy::Retained, "land"}};
ASSERT_TRUE(state.setRenderSegmentPlan(configured));
const auto resolved = resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), segmentLayerIds(), true);
std::vector<StyleLayerDiagnostics> layers;
for (std::size_t i = 0; i < segmentLayerIds().size(); ++i) {
layers.push_back({static_cast<int32_t>(i), segmentLayerIds()[i], "fill", true, std::nullopt, std::nullopt});
}
layers[1].sourceId = "chart";
layers[2].sourceId = "dynamic";
layers[3].sourceId = "dynamic";
layers[4].sourceId = "chart";
layers[5].layerType = "symbol";
layers[5].sourceId = "ais";
state.recordRenderSegmentResolution(resolved, layers);
state.recordSegmentBuild(0);
state.recordSegmentCacheState(0, RetainedRangeCacheState::Ready, 41);
state.recordSegmentExecution(0, true, true, true, 17, "reuse", nullptr);
RendererReadinessDiagnostics readiness;
readiness.styleLoaded = true;
readiness.imageManagerLoaded = true;
readiness.renderOrchestratorLoaded = false;
readiness.spriteLoaded = true;
readiness.hasFadingTiles = false;
readiness.contentRevision = 42;
readiness.sources = {
{.sourceId = "chart",
.sourceType = "vector",
.enabled = true,
.isLoaded = true,
.tileBacked = true,
.tileCount = 4,
.incompleteTileCount = 0,
.loadedTileCount = 4},
{.sourceId = "dynamic",
.sourceType = "raster",
.enabled = true,
.isLoaded = false,
.tileBacked = true,
.tileCount = 1,
.incompleteTileCount = 1,
.loadedTileCount = 0,
.lastSourceMutationSequence = 11,
.lastTileChangedSequence = 12,
.lastTileActionSequence = 13,
.lastTileAction = "StartParse",
.lastMutationReason = "tile-mutation"}};
readiness.lastMutation = {.sequence = 12,
.contentRevision = 42,
.eventType = "TileMutation",
.reason = "tile-mutation",
.sourceId = "dynamic",
.affectedSegmentIndices = {},
.dependencyScope = "none"};
state.recordRendererReadiness(readiness);
const auto first = state.renderSegmentSnapshot();
ASSERT_EQ(first.sources.size(), 2u);
EXPECT_TRUE(first.styleLoaded);
EXPECT_TRUE(first.imageManagerLoaded);
EXPECT_FALSE(first.renderOrchestratorLoaded);
EXPECT_EQ(first.contentRevision, 42u);
EXPECT_EQ(first.sources[0].sourceId, "chart");
EXPECT_TRUE(first.sources[0].isLoaded);
EXPECT_EQ(first.sources[0].tileCount, std::optional<uint32_t>{4});
EXPECT_EQ(first.sources[1].sourceId, "dynamic");
EXPECT_FALSE(first.sources[1].isLoaded);
EXPECT_EQ(first.sources[1].incompleteTileCount, std::optional<uint32_t>{1});
ASSERT_EQ(first.segments.size(), 3u);
EXPECT_EQ(first.segments[0].dependentSourceIds, std::vector<std::string>({"chart"}));
EXPECT_EQ(first.segments[1].dependentSourceIds, std::vector<std::string>({"dynamic"}));
EXPECT_EQ(first.segments[2].dependentSourceIds, std::vector<std::string>({"ais", "chart"}));
EXPECT_TRUE(first.segments[0].unknownDependencies.empty());
EXPECT_EQ(first.segments[2].unknownDependencies,
std::vector<std::string>({"symbol/glyph/image/placement"}));
EXPECT_EQ(first.segments[0].dependentSourceIds.size(), 1u);
const auto backgroundLayer = std::find_if(first.segments[0].layers.begin(), first.segments[0].layers.end(),
[](const auto& layer) { return layer.layerId == "background"; });
ASSERT_NE(backgroundLayer, first.segments[0].layers.end());
EXPECT_FALSE(backgroundLayer->sourceId.has_value());
EXPECT_EQ(first.lastMutation.affectedSegmentIndices, std::vector<uint32_t>({1}));
EXPECT_EQ(first.lastMutation.dependencyScope, "source-local-diagnostic");
const auto topologyRevision = first.topologyRevision;
const auto cacheBuild = first.segments[0].cache->build;
state.recordRendererReadiness(readiness);
const auto second = state.renderSegmentSnapshot();
EXPECT_EQ(second.topologyRevision, topologyRevision);
ASSERT_TRUE(second.segments[0].cache);
EXPECT_EQ(second.segments[0].cache->build, cacheBuild);
EXPECT_EQ(second.segments[0].cache->contentRevision, 41u);
EXPECT_EQ(second.segments[0].cache->sourceRevision, 17u);
EXPECT_EQ(second.segments[0].cache->finalDecision, "reuse");
const auto third = state.renderSegmentSnapshot();
EXPECT_EQ(third.topologyRevision, second.topologyRevision);
EXPECT_EQ(third.contentRevision, second.contentRevision);
EXPECT_EQ(third.sources, second.sources);
EXPECT_EQ(third.lastMutation, second.lastMutation);
const auto json = renderSegmentDiagnosticsToJSON(second);
EXPECT_EQ(renderSegmentDiagnosticsToJSON(state.renderSegmentSnapshot()), json);
EXPECT_NE(json.find("\"renderOrchestratorLoaded\":false"), std::string::npos);
EXPECT_NE(json.find("\"incompleteTileCount\":1"), std::string::npos);
EXPECT_NE(json.find("\"dependentSourceIds\":[\"dynamic\"]"), std::string::npos);
EXPECT_NE(json.find("\"affectedSegmentIndices\":[1]"), std::string::npos);
EXPECT_NE(json.find("\"sourceRevision\":17"), std::string::npos);
}
TEST(RenderSegmentLocality, SourceRevisionsAreScopedAndRecreationUsesNewIdentity) {
const std::vector<StyleLayerDiagnostics> layers{
{0, "background", "background", true, std::nullopt, std::nullopt},
{1, "a0", "fill", true, "a", std::nullopt},
{2, "b", "circle", true, "b", std::nullopt},
{3, "a1", "line", true, "a", std::nullopt}};
const auto r0 = renderSegmentDependencies(layers, 0, 2);
const auto r1 = renderSegmentDependencies(layers, 2, 3);
const auto r2 = renderSegmentDependencies(layers, 3, 4);
const auto sourceLess = renderSegmentDependencies(layers, 0, 1);
EXPECT_EQ(r0.sourceIds, std::vector<std::string>({"a"}));
EXPECT_EQ(r2.sourceIds, r0.sourceIds);
EXPECT_TRUE(sourceLess.sourceIds.empty());
RenderSegmentRevisionBook book;
book.advanceSource("a");
book.advanceSource("b");
const auto beforeA = book.revisionFor(r0);
const auto beforeB = book.revisionFor(r1);
const auto beforeA2 = book.revisionFor(r2);
const auto beforeSourceLess = book.revisionFor(sourceLess);
book.advanceSource("a");
EXPECT_NE(book.revisionFor(r0), beforeA);
EXPECT_NE(book.revisionFor(r2), beforeA2);
EXPECT_EQ(book.revisionFor(r1), beforeB);
EXPECT_EQ(book.revisionFor(sourceLess), beforeSourceLess);
const auto beforeRecreate = book.revisionFor(r0);
book.advanceSource("a"); // removal
book.advanceSource("a"); // recreation with the same ID
EXPECT_NE(book.revisionFor(r0), beforeRecreate);
book.advanceGlobal(); // image, glyph, style, context or unknown mutation
EXPECT_NE(book.revisionFor(r1), beforeB);
EXPECT_NE(book.revisionFor(sourceLess), beforeSourceLess);
const auto unknown = renderSegmentDependencies(
{{0, "custom", "custom", true, std::nullopt, std::nullopt}}, 0, 1);
EXPECT_TRUE(unknown.unknown);
EXPECT_FALSE(book.revisionFor(unknown));
const auto symbol = renderSegmentDependencies(
{{0, "label", "symbol", true, "a", std::nullopt}}, 0, 1);
EXPECT_FALSE(symbol.unknown);
EXPECT_EQ(symbol.sourceIds, std::vector<std::string>({"a"}));
EXPECT_EQ(symbol.globalPrerequisites,
std::vector<std::string>({"symbol/glyph/image/placement"}));
}
TEST(RenderSegmentLocality, ReadinessAndFadeFollowOnlyDependencies) {
const RenderSegmentDependencies r0{{"static"}, {}, false};
const RenderSegmentDependencies r1{{"dynamic"}, {}, false};
const auto lookup = [](bool dynamicLoaded, bool dynamicFading) {
return [=](const std::string& id) -> std::optional<RenderSegmentSourceStatus> {
if (id == "static") return RenderSegmentSourceStatus{true, false};
if (id == "dynamic") return RenderSegmentSourceStatus{dynamicLoaded, dynamicFading};
return std::nullopt;
};
};
EXPECT_TRUE(assessRenderSegmentSources(r0, lookup(false, false)).stable);
EXPECT_FALSE(assessRenderSegmentSources(r1, lookup(false, false)).ready);
EXPECT_TRUE(assessRenderSegmentSources(r0, lookup(true, true)).stable);
const auto fading = assessRenderSegmentSources(r1, lookup(true, true));
EXPECT_TRUE(fading.ready);
EXPECT_FALSE(fading.stable);
EXPECT_STREQ(fading.reason, "source-fading");
EXPECT_FALSE(assessRenderSegmentSources({{"missing"}, {}, false}, lookup(true, false)).ready);
EXPECT_FALSE(assessRenderSegmentSources({{}, {}, true}, lookup(true, false)).ready);
}
TEST(RenderSegmentLocality, MixedExecutionDrawsEachLiveRangeOnceInStyleOrder) {
ResolvedRenderSegmentPlan plan;
plan.valid = true;
plan.layerCount = 5;
plan.segments = {{0, RenderSegmentStrategy::Retained, std::nullopt, 0, 1},
{1, RenderSegmentStrategy::Continuous, std::nullopt, 1, 3},
{2, RenderSegmentStrategy::Retained, std::nullopt, 3, 5}};
std::vector<std::string> events;
visitRenderSegmentExecution(plan,
[](uint32_t index) { return index == 0; },
[&](uint32_t index) { events.push_back("composite:" + std::to_string(index)); },
[&](int32_t begin, int32_t end) {
for (auto i = begin; i < end; ++i) events.push_back("live:" + std::to_string(i));
});
EXPECT_EQ(events, (std::vector<std::string>{"composite:0", "live:1", "live:2", "live:3", "live:4"}));
events.clear();
plan.valid = false;
visitRenderSegmentExecution(plan, [](uint32_t) { return true; },
[&](uint32_t) { ADD_FAILURE() << "invalid plan composite"; },
[&](int32_t begin, int32_t end) {
for (auto i = begin; i < end; ++i) events.push_back("live:" + std::to_string(i));
});
EXPECT_EQ(events.size(), 5u);
}
TEST(RenderSegmentLocality, FailedBuildCannotReuseOldTexture) {
RetainedUnderlayCache cache;
const auto viewportKey = key();
EXPECT_EQ(cache.evaluate(viewportKey, 7, true), RetainedUnderlayDecision::Build);
cache.markBuildSucceeded();
EXPECT_EQ(cache.evaluate(viewportKey, 7, true), RetainedUnderlayDecision::Reuse);
EXPECT_EQ(cache.evaluate(viewportKey, 8, true), RetainedUnderlayDecision::Build);
cache.markBuildFailed("resource-or-compositor");
EXPECT_FALSE(cache.isValid());
EXPECT_EQ(cache.evaluate(viewportKey, 8, true), RetainedUnderlayDecision::Build);
}
TEST(RenderSegmentPlan, StrictJSONRejectsUnknownFieldsStrategyAndInvalidFirstBoundary) {
EXPECT_FALSE(parseRenderSegmentPlanJSON(
R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED","unexpected":true}]})"));

View File

@@ -99,7 +99,7 @@ unzip -tq "$SOURCE_APK" >/dev/null || fail "APK ZIP integrity check failed."
ABI_LIST="$(unzip -Z1 "$SOURCE_APK" | sed -n 's#^lib/\([^/]*\)/.*#\1#p' | sort -u | tr '\n' ' ' | sed 's/ $//')"
[[ "$ABI_LIST" == "x86_64" ]] || fail "Unexpected APK ABI set: [$ABI_LIST]"
LIB_BYTES="$(unzip -l "$SOURCE_APK" 'lib/x86_64/libmaplibre.so' | awk '/lib\/x86_64\/libmaplibre\.so$/ {print $1; exit}')"
LIB_BYTES="$(unzip -l "$SOURCE_APK" 'lib/x86_64/libmaplibre.so' | awk '/lib\/x86_64\/libmaplibre\.so$/ {print $1}')"
[[ -n "$LIB_BYTES" ]] || fail "lib/x86_64/libmaplibre.so not found in APK."
APK_BYTES="$(wc -c < "$SOURCE_APK" | tr -d ' ')"

View File

@@ -1022,3 +1022,48 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2`
- 统一发行 Android 四 ABI 和 Maven Local 通过;首次 iOS Metal Bazel 在 render_weather_color_layer.cpp 编译时找不到新增 WeatherColor lifecycle 头文件。
- 在 bazel/core.bzl 的 MLN_CORE_HEADERS 精确登记该头文件;保留 WeatherColor 业务实现不变。由 RDC 重跑完整 v16 构建、公开回读,iPad 人工验收仍待执行。
### 2026-10-06 STEP-099 RenderSource readiness 与 RenderSegment 失效诊断(CLOSED / PASS)
- frozen release 为 `maplibre-particle/rn/13.4.1-particle.17` / `e8b451fe70054107773e82484c80e90df3889147`;implementation commit 为 `8a5214d92dd2f8899db76d2194da42999fa0c87d`。未修改 Integration/NavSea。
- 完成 renderer/source readiness additive snapshot、tile stats、mutation source/tile action、只读 source→segment dependency 与 Android T1~T4。正式 `renderTreeParameters->loaded = styleLoaded && isLoaded()`、Source `isLoaded()`、retained cache、fast path、singleContinuous、多 Continuous 和 RenderSegment execution 均未改变。
- Metal host `RenderSegmentDiagnostics.*` 6/6 PASS;最终 Android x86_64 OpenGL APK BUILD SUCCESSFUL in 4m54s,APK 61,923,024 bytes,SHA-256 `5de73f0358b835a78d3ae7e2ac22f4efdb2a66775b8c63654a8bb7313957d90a`。Vulkan/最终 Metal rerun 因当前 Mac 缺 libuv pkg-config 未执行,不虚报 PASS。
- FZG1-2 / Android 13 / API33 实跑:T1 静态仅 1 个 204ms 启动 false window 后稳定;T2 双 GeoJSON 各约 1Hz,10 个 false window、avg 203ms、max 205ms;T3 无 layer 的 disabled RasterArray 仍 isLoaded=false,使 global loaded 持续 false 21.459s;T4 61 次动态更新产生 4 个 false window、avg 203ms、max 207ms。
- T4 证实 dependency diagnostics 能正确给动态 source `affected=[2]`,但 global contentRevision 1760→2540,R0 虽只依赖 `step099-static`,cache build 仍 1→121、invalidation 20→200,确认 `GLOBAL_CONTENT_REVISION`。源码审计同时确认 global fade/update signature/pending/placement/repaint/annotation/singleContinuous 等 fast-path gate 仍存在。
- Q1~Q7 与 blocker 分类已闭合:runtime confirmed `PERMANENT_SOURCE_UNLOADED`、`DYNAMIC_SOURCE_READINESS_THRASH`、`GLOBAL_CONTENT_REVISION`;source confirmed `GLOBAL_FADE`、`GLOBAL_UPDATE_SIGNATURE`。
- 完成报告:`docs/报告/STEP-099_RenderSource就绪与RenderSegment失效诊断_Codex完成报告_v1.md`;Step:`docs/50_Step记录/STEP-099_RenderSource就绪与RenderSegment失效诊断_v1.md`;运行证据保存在忽略目录 `artifacts/maplibre-particle/android-validation/`。
### 2026-10-06 STEP-100 SourceScoped RenderSegment 局部失效(CLOSED / PASS)
- 基于 `c4b481fd2c5b5da20d31f6ae54011f0552d7dbd0`,Codex `gpt-6-sol / high` 实现 source-scoped revision、production/diagnostics 共用 dependency、per-segment loaded/fading/readiness 以及独立 Build/Reuse/Fallback;global loaded/content revision 与 Fast Path gate 保持原兼容语义。
- RDC 标准 Android x86_64 OpenGL validation build PASS,APK 61,956,640 bytes,`libmaplibre.so` 39,636,472 bytes,SHA-256 `86e22bf081bf7d3196f0bf0e7c56af51bde01e9f0b01e0bab238cb794273315a`。RDC 同时修复 validation script 在 `pipefail` 下的 `unzip | awk ... exit` SIGPIPE/141,小修不改变产物校验语义。
- FZG1-2 T4 R-C-R 约 61.6 秒主窗口:global revision 128→973;R0 build 1→1、invalidation 20→20、reuse 132→4042、source revision 不变;R1 build 5→135、invalidation 26→211。动态 source 持续 `affected=[2]`,核心 locality 硬门槛 PASS。
- T2 双 GeoJSON 约 35 秒仍产生 6 个约 203ms loaded false window,但 R0 build/invalidation 不变;T3 unrelated permanently-unready RasterArray 使 global loaded=false 持续 21.468s,但 R0/R1 build=1、invalidation=19 不变,reuse 11→1200,证明无关 source 不再拖垮 retained cache。
- T4 运行期间 R1 fallback count 增长而 R0 fallback 不变、reuse 持续增长,实际验证 mixed `R0 Reuse + C Live + R1 Fallback`。未发现 crash/ANR。Fast Path、`singleContinuousSegment`、multi-continuous 语义未改。
- Native host GTest 因当前 Intel Mac 缺 `libuv` 环境阻塞;尝试 Homebrew 安装时发现会连带升级 CMake/OpenSSL/Python 且 Intel 无 bottle 支持,已停止,未把环境阻塞虚报 PASS。Android full native build + device runtime 作为本 Step 功能验收事实。Jest 未运行。
- Step:`docs/50_Step记录/STEP-100_SourceScoped_RenderSegment局部失效_v1.md`;完成报告:`docs/报告/STEP-100_SourceScoped_RenderSegment局部失效_Codex完成报告_v1.md`;运行证据在忽略目录 `artifacts/maplibre-particle/android-validation/`。允许进入 V18 unified release。
### 2026-10-06 STEP-101 RenderOrchestrator 销毁生命周期修复(源码交付,待 RDC 验收)
- 基于 V18 Final HEAD `d29e84c1a90daeea104b389d28b000c4f017d08d`,按 STEP-101 任务 v2 审计现有差异。已证实 readiness mutex/state 早于 RenderSource 自动析构且 tile/source 销毁可同步回调的生命周期漏洞;与 Android FORTIFY 现场高度吻合,但仍待等价实机闭环确认。
- 现有源码差异包含 RenderSource nullptr observer 回退 NullObserver、Orchestrator 析构双 tagged drain、三类 observer detach 与显式 source clear;Android Validation 增加 T1~T4 Activity recreate 场景和 Native reset 配对日志。静态审计发现 teardown 测试 helper 没有注册 Orchestrator observer,本轮仅补正这一行。
- render-job 队列独立于 `waitForEmpty()`;OpenGL backend/context 后续清理与真实 Android reset 仍须由 RDC 验收。STEP-100 locality 实现无本次差异,运行态回归待执行。
- 本轮仅做源码/任务静态审计与 `git diff --check`;CMake/Ninja、Native GTest、Gradle/APK、ADB/设备、NavSea、Jest、iOS、candidate/release、commit、push、tag、publish 全部 **NOT RUN**。不能写 STEP-101 PASS。
- Step:`docs/50_Step记录/STEP-101_RenderOrchestrator销毁生命周期修复_v1.md`;完成报告:`docs/报告/STEP-101_RenderOrchestrator销毁生命周期修复_Codex完成报告_v1.md`。V19 未发布;下一步 RDC 按报告命令独立验证。
### 2026-10-07 STEP-101 源码交付补充
- 审计 V18 Final HEAD、任务 v2、正常 source erase/clearData、TileCache 延迟释放和独立 render-job 队列;本轮不扩大正常 source 更新语义。
- 补齐 C++ 测试对旧 observer 已 detach 的断言;Android 增加 SurfaceView detach、Java/JNI `nativeReset` 与 Native `resetRenderer` 逐轮日志,并补 T3 动态 GeoJSON mutation 标记。
- `git diff --check` 通过;Native 编译/GTest、Android Gradle/APK/ADB/设备、STEP-100 locality 运行态、NavSea 和 V19 发布均未执行。结论仍为 PARTIAL,待 RDC 独立验收。
### 2026-10-07 STEP-101 RDC 独立验收 CLOSED / PASS
- Android x86_64 OpenGL Validation full native build PASS:APK 61,957,678 bytes,libmaplibre 39,637,512 bytes,SHA-256 `14cbd35ed0bc1366389ddea69925901013e3b209ed3575462421a7920c73c721`。
- FZG1-2 T1/T3/T4 PASS;T2 完整 20 次真实 Activity recreate / `nativeReset` / `MapRenderer::resetRenderer` PASS,四组 crash buffer 均为空,不再出现 destroyed-mutex SIGABRT。
- STEP-100 locality 回归 PASS:global revision 62→894,R0 build 1→1、invalidation 19→19、reuse 12→3890;R1 正常持续 build/invalidate。
- Android Native C++ TestRunner 构建 PASS;4 个 RenderSegmentLocality + 2 个 RenderOrchestratorTeardown focused tests 全部 PASS,status=0。TestRunner 临时 fixtures asset 已删除,不纳入 Git。
- Mac host GTest 仍受本机 libuv pkg-config 环境阻塞,未安装系统依赖、未虚报 PASS。Jest NOT RUN。
- STEP-101 允许关闭;下一步进入 V19 candidate → NavSea 冷启动 Gate → 正式发布。
- STEP-101 implementation commit `64cf8f44350eb8ec4b45360db4d3f9505ac4ac0b` 已推送 `origin/particle/main`,远端回读一致;后续只做 docs closeout 与 V19 发行。