feat: protect mod payloads with main-thread ownership in 1.1.17
This commit is contained in:
1 parent
691e949691
commit
763c015103
29 files changed
+1551
-52
No files matched your search
@@ -2,6 +2,32 @@
|
||||
|
||||
Dimensional Ripper 是一个基于 **NeoForge 1.21.1** 的多线程维度并行优化模组。它通过独立维度 tick 循环与细粒度并行调度,充分利用多核 CPU 提升服务端性能,同时保持稳定的 20 TPS。
|
||||
|
||||
## 1.1.17:公共收包线程归属候选修复
|
||||
|
||||
服务端游戏阶段经 NeoForge `ServerPayloadContext.enqueueWork` 明确提交到主线程的工作,现在统一进入有界队列,并在维度更新归还执行权后,由真正的服务器主线程调用原处理器。Create/航空学旧组装入口复用同一安全阶段;采用这条公共入口的模组不需要主动适配。登录、配置阶段、客户端及纯网络计算不改派,也不代表任意模组的任意线程访问已自动安全。
|
||||
|
||||
队列上限为服务器 4096 项、单连接 256 项,每批最多 64 项或 2 ms(不强行中断已经开始的处理器)。保留观察到的入队顺序;执行前取消的工作不再修改世界,换世界/换玩家/停服后的旧工作明确失效。超载通过原断线流程处理;业务处理器自己抛出的拒绝异常不会被误判为超载踢人。新循环继承菜单暂停,仍在退出的循环也必须归还执行权。等待期间继续服务 Sable 主线程交接,拒绝会产生锁反转的同步重入。
|
||||
|
||||
真实本地模组回归:Sable 物理子世界内创建了真实把手约束,复现原路径松手后约束仍有效;经新入口处理同一松手包后约束失效。Create 调速包及 Jade 查询通过,原距离拒绝仍有效。另有队列、取消、暂停、停止和真实 CC 回归。详细环境、日志标记及未覆盖项见 [1.1.17 验证报告](docs/payload-ownership-1.1.17-validation.md)。这些是隔离服务器测试,不等于用户完整整合包、客户端飞行或多人压力验收。
|
||||
|
||||
阶段之外维度并行仍保留;有交互时会等待最慢维度收口,不承诺零开销。没有待处理交互时不建立收包屏障。`/dimensionalripper status` 与归属崩溃快照包含队列积压、最老等待、最近批次等待/执行时间;仅凭 Carpet 的低 MSPT 不能判断所有维度正常。记录最多 128 个具名包 ID 加 other、最近 32 条结果,失败警告全局 30 秒限频,不记录包正文。
|
||||
|
||||
**仍为测试候选,不要直接替换正式服务器:ServerWarashi(坐服童子)启动分析崩溃、C2ME 冲突、任意后台世界访问及既有停服超时不在这次修复范围内。** 工作线程提交后同步等待公共队列 future 仍不受支持;不能把它宣传为所有模组免适配。
|
||||
|
||||
## 1.1.16:CC 调度线程归属候选修复
|
||||
|
||||
针对 CC:Tweaked 1.118.0 `TickScheduler` 主线程读取正在被维度线程更新的世界、导致显示器身份检查得到 `null` 的问题:有待处理 token 时,先暂停维度续排并等待在途更新归还所有权,在服务器主线程执行原调度检查,最后恢复原先运行的循环。等待期间继续处理主线程回调,允许 Sable 交接完成;不绕过身份检查,不吞掉真实异常,不强加载未加载区块。CC 的全局延迟队列表对应的区块回调也做串行保护。未安装 CC 时不启用此补丁。
|
||||
|
||||
这不是零开销修复:CC 有任务时,安全阶段会等待当前最慢的维度收口,可能暂时影响主线程 TPS;没有任务时不建立屏障,阶段结束后维度与细粒度并行继续运行。不能保证所有维度在这一兼容阶段完全互不影响。`/dimensionalripper status` 增加最近一次交接等待/执行耗时。
|
||||
|
||||
线程诊断记录维度、Level/ChunkSource 所属线程、调用线程、位置、阶段和 Sable 交接状态。每维度最多保留 16 条最近事件;入口归属不匹配警告每维度最多 30 秒一次,抑制期间累计计数,慢安全阶段全局最多 30 秒一次。入口快照不等于原子世界快照,也不据此单独判定死锁。崩溃报告新增 `DimensionalRipper Thread Ownership`。
|
||||
|
||||
隔离测试:`gradlew runServer -PownershipRegression -PccTestJar=<本机CC的jar绝对路径>`。使用 `runs/server` 下的专用测试世界(运行前确认 `level-name=interaction-regression`、`server-ip=127.0.0.1`、`server-port=25579`),不要指向正式存档。测试源码和 CC jar 不加入常规发布包。已在修复前用真实 CC 显示器复现 `Expected MonitorBlockEntity ... got null`;修复后运行任务会检查日志中的 `OWNERSHIP REGRESSION PASS`、`OWNERSHIP EDGE PASS`、`OWNERSHIP CALLBACK PASS` 和 `OWNERSHIP RESUME PASS`。Minecraft 崩溃有时仍以零退出码结束,不能只检查 Java 退出码。
|
||||
|
||||
覆盖:真实显示器身份检查、实际主线程交接桥的回调执行、嵌套/异常后的暂停恢复、拆除与真实身份不匹配、未加载 token、80 个延迟 token 的双维度并发 ticket/unload 回调,以及崩溃诊断。1001 次入口归属不匹配在测试日志中仅输出 1 条警告。另用 1 TPS 的空闲预算复现暂停后仍等待整秒的问题,修正为世界工作完成后立即退出已请求暂停的空闲轮询;保留在途工作的收尾。一次本机测试的 CC 等待/执行为约 7.4/0.12 ms,仅是隔离样本,不是整合包性能承诺。测试调用实际交接桥,但没有加载 Sable 原生物理引擎。
|
||||
|
||||
本修复限定于 CC 调度路径,不是对所有模组跨线程访问的通用放行。仍需在完整整合包的存档副本验证,尤其 CC 显示器随 Sable 结构移动的情况;不要据此认为所有航空学、区块发送、停服问题均已解决。
|
||||
|
||||
## 优化范围(均为服务端运算,不影响客户端画面/渲染)
|
||||
|
||||
### 🗺️ 维度
|
||||
@@ -44,7 +70,7 @@ Dimensional Ripper 是一个基于 **NeoForge 1.21.1** 的多线程维度并行
|
||||
|
||||
## ⚠️ 兼容性说明
|
||||
|
||||
**已知未修复问题(1.1.15)**:与 C2ME 的 `c2me_fixes_worldgen_threading_issues` 同时加载时,双方对 `Level` 构造器内随机源初始化的 `@Redirect` 冲突,可能导致启动失败。日志已确认 C2ME 优先级 1100 的重定向覆盖本模组优先级 1000 的 `modzuozhi_threadSafeRandom`,继而触发注入检查失败。当前源码尚未包含此兼容修复,不能视为可直接部署的稳定版。
|
||||
**已知未修复问题(1.1.15 / 1.1.16)**:与 C2ME 的 `c2me_fixes_worldgen_threading_issues` 同时加载时,双方对 `Level` 构造器内随机源初始化的 `@Redirect` 冲突,可能导致启动失败。日志已确认 C2ME 优先级 1100 的重定向覆盖本模组优先级 1000 的 `modzuozhi_threadSafeRandom`,继而触发注入检查失败。当前源码尚未包含此兼容修复,不能视为可直接部署的稳定版。
|
||||
|
||||
1.1.15 针对 Sable 碰撞查询期间反复创建实心占位区块的问题:
|
||||
|
||||
|
||||
@@ -77,9 +77,52 @@ dependencies {
|
||||
}
|
||||
|
||||
// Opt-in isolated runtime fixture; excluded from normal builds.
|
||||
if (providers.gradleProperty('payloadRegression').isPresent()) {
|
||||
if (providers.gradleProperty('reviewRegression').isPresent()) {
|
||||
runs.configureEach { systemProperty 'dimensionalripper.reviewRegression', 'true' }
|
||||
}
|
||||
if (providers.gradleProperty('interactionRegression').isPresent() || providers.gradleProperty('ownershipRegression').isPresent()) {
|
||||
throw new GradleException('Run payloadRegression separately.')
|
||||
}
|
||||
sourceSets.main.java.srcDir('tests/payload')
|
||||
if (providers.gradleProperty('payloadModsDir').isPresent()) {
|
||||
dependencies {
|
||||
runtimeOnly fileTree(dir: providers.gradleProperty('payloadModsDir').get(), includes: [
|
||||
'create-1.21.1-6.0.10.starlight.jar', 'create-aeronautics-bundled-1.21.1-1.3.0.starlight.jar',
|
||||
'sable-neoforge-1.21.1-2.0.5.starlight.jar', 'veil-neoforge-1.21.1-4.2.0.starlight.jar',
|
||||
'Jade-1.21.1-NeoForge-15.10.5.starlight.jar'
|
||||
])
|
||||
}
|
||||
}
|
||||
tasks.matching { it.name == 'runServer' }.configureEach {
|
||||
doLast {
|
||||
def output = file('runs/server/logs/latest.log').getText('UTF-8')
|
||||
if (providers.gradleProperty('reviewRegression').isPresent()) {
|
||||
if (!output.contains('PAYLOAD REVIEW PASS') || output.contains('Encountered an unexpected exception'))
|
||||
throw new GradleException('Payload review regression did not pass.')
|
||||
return
|
||||
}
|
||||
if (!['PAYLOAD PHASE PASS', 'PAYLOAD ENTRY PASS', 'PAYLOAD ASYNC PASS', 'PAYLOAD LIFECYCLE PASS', 'PAYLOAD DIAGNOSTICS PASS'].every { output.contains(it) }
|
||||
|| output.contains('Encountered an unexpected exception'))
|
||||
throw new GradleException('Payload phase regression did not pass.')
|
||||
if (providers.gradleProperty('payloadModsDir').isPresent()
|
||||
&& !['SIMULATED HANDLE PACKET PASS', 'CREATE CONFIG PASS', 'JADE REQUEST PASS'].every { output.contains(it) })
|
||||
throw new GradleException('Real Simulated/Create/Jade packet regression did not pass.')
|
||||
}
|
||||
}
|
||||
}
|
||||
if (providers.gradleProperty('interactionRegression').isPresent()) {
|
||||
sourceSets.main.java.srcDir('tests/integration')
|
||||
sourceSets.main.java.exclude('**/ChunkHandoffRegression.java')
|
||||
tasks.matching { it.name == 'runServer' }.configureEach {
|
||||
doLast {
|
||||
def output = file('runs/server/logs/latest.log').getText('UTF-8')
|
||||
if (!['ENTITY ROUTING REGRESSION PASS', 'MISSING CHUNK REGRESSION PASS',
|
||||
'REQUIRED CHUNK REGRESSION PASS', 'INTERACTION REGRESSION PASS'].every { output.contains(it) }
|
||||
|| output.contains('Encountered an unexpected exception'))
|
||||
throw new GradleException('Interaction/water regression did not pass.')
|
||||
}
|
||||
}
|
||||
}
|
||||
if (providers.gradleProperty('ownershipRegression').isPresent()) {
|
||||
if (providers.gradleProperty('interactionRegression').isPresent()) {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# 1.1.17 公共收包归属:候选验证记录
|
||||
|
||||
日期:2026-10-06。结论:公共入口与已列交互路径已实施;不是完整整合包兼容认证,也不是 ServerWarashi 修复。
|
||||
|
||||
## 环境与范围
|
||||
|
||||
Java 21 Zulu、Minecraft 1.21.1、NeoForge 21.1.244。仅使用仓库 `runs/server` 的 `interaction-regression` 世界、127.0.0.1:25579。没有修改用户正式世界、启动器 mods 或远端仓库。用户报告过的 NeoForge 21.1.233 未在本轮单独运行。
|
||||
|
||||
可选模组为用户本机实际 jar:Create 6.0.10.starlight、Create Aeronautics bundled 1.3.0.starlight(含 Simulated)、Sable 2.0.5.starlight、Veil 4.2.0.starlight、Jade 15.10.5.starlight;CC:Tweaked 1.118.0.starlight 单独运行。
|
||||
|
||||
## 证据层级
|
||||
|
||||
| 层级 | 已验证内容 | 证据 |
|
||||
|---|---|---|
|
||||
| 纯 Java | FIFO、至多一次、256/4096 容量、取消回收、64 项/2 ms 预算、关闭、重入完成、8 生产者 | `runPayloadWorkQueueRegression` 的 `PAYLOAD QUEUE PASS` |
|
||||
| 实际 Minecraft/NeoForge 上下文 | Runnable/Supplier、返回值、主线程异常身份、异步 guard、取消、换世界失效、配置阶段原路径、嵌套、Sable 同步重入拒绝 | `PAYLOAD PHASE/ENTRY/ASYNC PASS` |
|
||||
| 实际 Minecraft 生命周期 | 新循环、菜单暂停、退出中循环仍等待、停止不恢复、停止后晚到任务不能复建诊断 | `PAYLOAD LIFECYCLE PASS`、`PAYLOAD REVIEW PASS` |
|
||||
| 实际可选模组和原生物理 | 真实子世界、真实把手 BE、真实包和 Veil 上下文、Rapier 约束的创建及松手销毁 | `SIMULATED NATIVE RED` 后 `SIMULATED HANDLE PACKET PASS` |
|
||||
| 实际 Create / Jade | 调速写入、查询 BE、原始距离拒绝 | `CREATE CONFIG PASS`、`JADE REQUEST PASS` |
|
||||
| 实际 CC | 显示器同一性、回调等待、80 个延迟 token、双维度 ticket/unload、恢复后继续 tick | 四个 `OWNERSHIP … PASS` |
|
||||
| 已有流水/区块/实体路径 | 600000 次缺失读取、400000 次已加载读取、必须生成请求安全失败、实体路由至多一次、真实水流扩散 | 四个 `… REGRESSION PASS`,其中包括 `INTERACTION REGRESSION PASS` |
|
||||
| 空载/无可选模组 | 不安装上述可选模组仍启动;100 次空 drain 没有新增批次 | `PAYLOAD IDLE PASS` 及通用 payload 标记 |
|
||||
|
||||
真实把手的失败证明不是“函数调用成功”:测试让维度线程持有 plot 世界归属,直接运行原松手包后物理约束仍然 `isValid=true`;相同真实包经公共安全入口运行后 `isValid=false`。测试使用 FakePlayer(有真实测试监听器),并非真人客户端输入或真实网络连接。
|
||||
|
||||
四个审查问题均先失败后通过:业务拒绝异常误触发踢人、动态循环丢失菜单暂停、退出中的写入者漏过屏障、停服清理后晚到诊断重新持有服务器。日志 `payload-review-red-2.log` 对应失败,`payload-review-final.log` 对应修正后的通过。
|
||||
|
||||
诊断分类额外使用 `payload-counter-red.log` 复现“业务失败与容量拒绝混计”,`payload-counter-green.log` 通过;`payload-candidate-real.log` 再次通过真实模组测试并检查换世界旧任务计入 expired。failed、cancelled、expired、rejected 分开统计。
|
||||
|
||||
旧交互回归第一次运行失败,不能因 Gradle 返回零而算通过:旧夹具没有 connection,且仍要求已经替换掉的单维度桥。夹具改为 FakePlayer 与公共独占断言,保留所有流水/区块检查,并补上日志 PASS/异常检测门禁;`payload-final-interaction-green.log` 才是通过记录。
|
||||
|
||||
## 本轮取舍
|
||||
|
||||
1. Windows 无 bash,以 PowerShell/apply_patch 维护执行记录;风险是记录遗漏,测试门禁不变。
|
||||
2. 主线程独占标志与菜单暂停分离;否则嵌套 resume 会提前放走维度线程。代价是多一组生命周期状态。
|
||||
3. 阶段、入口、生命周期共用夹具及文件,相关提交合并验证,不把中间状态当发布版本。
|
||||
4. 保留 NeoForge 异步业务异常返回 null 的 guard 行为,但替换无限逐条异常打印为有界诊断。显式策略失败(旧玩家/停止/溢出)保留异常 future,执行前取消传播到底层队列;这是有意的兼容语义细化,依赖“失败也返回 null”的调用方可能观察到变化。
|
||||
5. 普通主线程同步调用为保序与同步完成可连续处理多个有界批次;2 ms 不是整 tick 硬上限,慢维度和慢处理器仍可能拉长等待。独占阶段内嵌套立即运行。
|
||||
6. 旧交互夹具适配新连接/归属契约而非放松生产守卫;不能替代真实客户端网络压力验证。
|
||||
7. 既有停服 30 秒超时、ServerWarashi、C2ME、任意后台世界/光照访问保留为独立问题;它们仍可能造成完整整合包失败。
|
||||
8. 只交付隔离环境验证的候选,不将以下未测项标成通过。审查没有列出待办 Minor。
|
||||
|
||||
## 未完成的验收,不属于已修复声明
|
||||
|
||||
- 真人客户端整套飞行、抓握输入、GUI 与完整整合包存档副本测试。
|
||||
- 两玩家同时抓握、死亡、断线、换维度、拆把手、卸载子世界及溢出拒绝松手后的完整原生约束清理矩阵。已测普通真实松手、通用旧世界拒绝和溢出断线调用,不能互相替代。
|
||||
- 跨服务器实例隔离的双实例并行压力测试;实现按服务器分区,不能仅凭代码视为此项实测。
|
||||
- 空载、普通交互、密集抓握、多人查询和慢维度的完整对比压测。空队列无批次及慢 worker 等待机制已验证,不承诺开销比例或全服 20 TPS。
|
||||
- 任意 worker 提交后同步 join 主线程队列不受支持。夹具通过 250 ms 超时检测并取消这种等待环,不表示生产代码会自动化解任意 join 死锁。
|
||||
- ServerWarashi 分析立即崩溃仍需单独从采样栈修复。
|
||||
|
||||
## 重跑
|
||||
|
||||
以下命令均使用 `--no-daemon --no-configuration-cache --console=plain`,先检查隔离世界配置,不能指向正式存档:
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat check
|
||||
.\gradlew.bat runServer -PpayloadRegression
|
||||
.\gradlew.bat runServer -PpayloadRegression -PreviewRegression
|
||||
.\gradlew.bat runServer -PpayloadRegression '-PpayloadModsDir=<本机上述模组目录>'
|
||||
.\gradlew.bat runServer -PownershipRegression '-PccTestJar=<本机CC jar>'
|
||||
.\gradlew.bat runServer -PinteractionRegression
|
||||
.\gradlew.bat clean build
|
||||
```
|
||||
|
||||
每组运行必须完成后再启动下一组。查看对应 PASS 标记及服务器异常,而非只看 Java/Gradle 退出码。正常构建不可带任何回归夹具属性。
|
||||
|
||||
## 打包门禁
|
||||
|
||||
`check runServer -PpayloadRegression` 成功(五个纯 Java 回归程序及无可选模组服务器夹具);随后不带任何夹具属性的 `clean build` 成功,五个纯 Java 回归再次执行通过。真实模组、CC、交互及审查运行见上述分组,未将测试标记缺失的运行计入通过。
|
||||
|
||||
发布 jar:`build/libs/dimensionalripper-1.1.17.jar`,277211 字节。ZIP 内容检查确认元数据版本为 1.1.17,包含公共入口 mixin/执行器,不含 Regression、FakePlayer、tests 或打包的可选模组 jar/类。
|
||||
|
||||
SHA256:`CF1DE863DE119272098142CF5F0ABE78543BA55B15074369A1A66FBC922FBAD3`。
|
||||
|
||||
完整本地日志已保存在 `build/reports/payload/`(忽略提交)。关键最终文件:`payload-clean-build.log`、`payload-candidate-check.log`、`payload-candidate-real.log`、`payload-counter-green.log`、`payload-final-cc.log`、`payload-final-interaction-green.log`。失败前例也保留供对照。执行工作区仍保留未完成验收记录,不把剩余矩阵标为完成。
|
||||
@@ -0,0 +1,133 @@
|
||||
# 公共收包线程归属 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将受支持的服务端游戏包交给真正主线程独占处理,修复把手松开路径,同时保留阶段之外的维度并行。
|
||||
|
||||
**Architecture:** 每服务器有界 FIFO 接收显式主线程工作;服务器外层 tick 建立全维度安全阶段,批量调用原处理器。阶段状态阻止递归收口和新增维度写入,旧组装入口复用同一执行权。生命周期清理与通用包失效分开处理。
|
||||
|
||||
**Tech Stack:** Java 21、Minecraft 1.21.1、NeoForge、Mixin/MixinExtras、现有 Gradle 隔离服务器回归。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-06-payload-ownership-design.md`
|
||||
|
||||
**执行状态(2026-10-06):** Task 1 已提交 `691e949`;Task 2–5 的公共入口、阶段、适配与诊断代码已实施并有回归证据。Task 4 的完整原生资源异常清理矩阵、Task 5 的双服务器压力测试、Task 6 的完整性能/客户端矩阵未全部验收,因此不勾选混合这些要求的条目。具体通过项和剩余项以 `docs/payload-ownership-1.1.17-validation.md` 为准,本轮只交付候选包。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 第一版新公共入口默认全维度隔离,不凭“玩家当前维度”或包内坐标猜测整个处理器的访问范围。
|
||||
- 初始批次上限为 64 项或 2 ms 的处理时间,达到任一条件后不启动下一项;不强行中断已开始的处理器。
|
||||
- 服务器队列上限 4096 项、单连接上限 256 项。
|
||||
- 记录上限 128 个 ID,超出归入 other,避免恶意 ID 撑大统计。
|
||||
- 登录、握手、配置阶段、客户端接收和纯网络线程处理不被无条件改派。
|
||||
- 超时用于诊断,不是允许并发执行的理由。
|
||||
- 不自动上传远端或部署正式服务器。测试仅操作仓库隔离世界或显式创建的存档副本。
|
||||
- 不改变原处理器权限、距离、强加载规则;不放松 Level 的线程守卫。
|
||||
- ServerWarashi 是后续独立修复,需要单独的采样设计及失败测试;本计划不宣称解决该崩溃。
|
||||
- C2ME、后台光照查询及既有停服超时不纳入修复声明。
|
||||
|
||||
## Review Focus
|
||||
|
||||
- 主线程正在服务等待中的 Sable 回调时再次 enqueue:不得等待该回调所属 worker,任务 2、3 验证。
|
||||
- 前一个包传送/替换玩家后,同连接后续旧包不得操作新世界:任务 3、4 验证。
|
||||
- 嵌套调用导致新世界/循环出现:不得在独占期间启动另一个写入者,任务 2 验证。
|
||||
- 队列满载时恰好收到松手:拒绝要显式完成 future,断线清理不能遗留约束,任务 1、4 验证。
|
||||
- 停服与阶段 finally、开关/菜单暂停同时发生:不得恢复已停止或仍应暂停的循环,任务 2、5 验证。
|
||||
|
||||
## 执行与证据约定
|
||||
|
||||
当前工作树含尚未提交的 1.1.16 归属修复,不能用新 worktree 的裸 HEAD 替代这些依赖。执行开始按 worktree 技能检查隔离方式;保留并核对所有现有变更,禁止 reset 或覆盖。不把原有未验证内容顺手算作本次通过。
|
||||
|
||||
以下路径相对仓库根。每个任务先写断言、记录失败,再改生产代码;每任务结束显式暂存该任务文件并提交(git 使用本仓库 safe.directory)。混合文件先核对 diff,不能误纳入用户后续改动。
|
||||
|
||||
命令统一使用 Java 21、`--no-daemon --no-configuration-cache --console=plain`。新的 `-PpayloadRegression` 夹具与 `ownershipRegression`、`interactionRegression` 互斥;运行结束必须有全部指定 PASS 标记且没有 unexpected exception,不能仅依赖进程退出码。证据放 `build/reports/payload/`。
|
||||
|
||||
## 文件职责
|
||||
|
||||
- `core/compat/PayloadWorkQueue.java`:不依赖 Minecraft 的 FIFO、容量、取消及批次预算。
|
||||
- `core/compat/MainThreadPhaseCoordinator.java`:服务器阶段状态与独占租约。
|
||||
- `core/compat/PayloadMainThreadDispatch.java`:实际玩家上下文、future、生命周期与 drain。
|
||||
- `core/compat/PayloadDiagnostics.java`:有界计数及耗时快照。
|
||||
- `mixin/ServerPayloadContextMixin.java`:两个 enqueueWork 重载,保留原有分支及 guard。
|
||||
- `mixin/MinecraftServerPayloadMixin.java`:外层 tick 的 drain,不扩展到通用 pollTask。
|
||||
- 已有 ServerManager、LevelTickLoop、Sable 桥、组装兼容、停止入口:接入阶段生命周期。
|
||||
- `tests/PayloadWorkQueueRegression.java`:纯 Java 队列测试。
|
||||
- `tests/payload/PayloadRegression.java`:实际 NeoForge 上下文与多维度集成。
|
||||
- `tests/payload/SimulatedHandleRegression.java`:可选真实模组处理器及约束清理测试。
|
||||
|
||||
## Task 1: 可测试的有界队列
|
||||
|
||||
**Files:** 创建 `src/main/java/dev/modzuozhi/core/compat/PayloadWorkQueue.java`、`tests/PayloadWorkQueueRegression.java`;修改 `build.gradle`。
|
||||
|
||||
**Interfaces:** `PayloadWorkQueue<K>(LongSupplier nanoTime)`;`<T> CompletableFuture<T> submit(K connection, Supplier<T> action)`;`int drainBatch()`;`void close(Throwable reason)`;`int size()`。拒绝返回异常 future;执行前已取消的任务不产生副作用。锁只保护队列及计数,绝不包住 action 或 completion 回调。
|
||||
|
||||
- [ ] 写 `fifoAndExactlyOnce`、`capacityAndCancellation`、`batchCountAndTime`、`closeAndReentrantCompletion` 断言:同连接顺序 0..255;第 257 项拒绝;16 个连接各 256 项填满后第 4097 项拒绝;取消使容量回收;批次最多 64;假时钟到 2_000_000 ns 后不再启动下一项;慢 action 不被中断;close 后旧 future 全结束且不能再接收。
|
||||
- [ ] 添加 `runPayloadWorkQueueRegression` 到 check;先运行并保存失败证据,不能以无测试发现作为 RED。
|
||||
- [ ] 实现上述接口;future 完成回调可重新 submit/close 而无锁死;Supplier 异常只影响本项。
|
||||
- [ ] 运行 `./gradlew.bat runPayloadWorkQueueRegression`,输出 `PAYLOAD QUEUE PASS`;并行提交/取消压力测试核对计数为零。
|
||||
- [ ] 审阅并提交本任务文件。
|
||||
|
||||
## Task 2: 安全阶段与循环生命周期
|
||||
|
||||
**Files:** 创建 `src/main/java/dev/modzuozhi/core/compat/MainThreadPhaseCoordinator.java`、`tests/payload/PayloadRegression.java`;修改 `core/dimthread/ServerManager.java`、`core/dimthread/LevelTickLoop.java`、`core/compat/SableMainThreadBridge.java`(均位于 `src/main/java/dev/modzuozhi/`),修改 `build.gradle`。
|
||||
|
||||
**Interfaces:** coordinator 的 `void run(MinecraftServer server, String reason, Runnable action)`、`boolean owns(MinecraftServer server)`、`boolean blocksWorkerStart(MinecraftServer server)`、`void stopping(MinecraftServer server)`、`void clear(MinecraftServer server)`;Sable 桥新增 `boolean inHandoff(MinecraftServer server)`。ServerManager.runMainThreadPhase 委托 coordinator,保持现有 CC 调用不变。
|
||||
|
||||
- [ ] 写 `exclusivePhase`、`nestedPhase`、`handoffReentryRejected`、`newLoopDuringPhase`、`stopDuringPhase`:处理期间全部世界 owner 为真主线程、worker 写入计数不增加;等待可完成 Sable 回调;独占内嵌套只建一次屏障;handoff 中建全局屏障立即明确失败;新 loop 在阶段退出前不能开跑;预先暂停、菜单暂停、停止状态不被 finally 恢复。
|
||||
- [ ] `./gradlew.bat runServer -PpayloadRegression` 得到预期断言失败,保存日志;夹具另用超时终止测试而非让生产代码越过屏障。
|
||||
- [ ] 实现 IDLE/DRAINING/EXCLUSIVE/RESTORING/STOPPING 状态、租约与入口检查。循环注册、scheduleIfNeeded、resume 共享阶段启动门禁;新 loop 参与退出处理。等待不持锁,继续 pump 必要回调;嵌套 drain 不递归。循环已经在途时等待其子任务及线程字段全部归还。
|
||||
- [ ] 运行夹具输出 `PAYLOAD PHASE PASS`;复跑 ownershipRegression,确认 CC 预置暂停及 Sable 回调仍通过。
|
||||
- [ ] 审阅并提交本任务文件。
|
||||
|
||||
## Task 3: NeoForge 公共入口与 future 语义
|
||||
|
||||
**Files:** 创建 `src/main/java/dev/modzuozhi/core/compat/PayloadMainThreadDispatch.java`、`src/main/java/dev/modzuozhi/mixin/ServerPayloadContextMixin.java`、`src/main/java/dev/modzuozhi/mixin/MinecraftServerPayloadMixin.java`;修改 `tests/payload/PayloadRegression.java`、`src/main/resources/dimensionalripper.mixins.json`。
|
||||
|
||||
**Interfaces:** dispatch 的 `<T> CompletableFuture<T> submit(ServerPlayer player, ResourceLocation payloadId, Supplier<T> action)`、`boolean owns(MinecraftServer server)`、`void drain(MinecraftServer server)`、`void stop(MinecraftServer server)`。Runnable 适配 Supplier<Void>。任务保存实际 connection、player、接收时 ServerLevel 身份及时间;各服务器实例独立。
|
||||
|
||||
- [ ] 用真实 ServerPayloadContext 的 Runnable/Supplier 路径写 `mainPayloadSeesBlockEntity`、`futureContract`、`phaseEscapeAndStalePlayer`、`configurationPassthrough`。断言并发维度下能取得原 BE、true main、至多一次;Supplier 返回 42;异常、取消和 guard 的对外结果与未修改 NeoForge 一致,内部真实失败仍可见;权限拒绝未发生世界变更;配置阶段不调用 player();网络计算原线程不变。
|
||||
- [ ] 运行 payloadRegression 记录原入口 wrong-owner/null 的失败;记录尚未覆盖的真实 Veil 路径,不冒充实测。
|
||||
- [ ] 两个 enqueueWork 仅拦截游戏玩家的显式主线程工作。网络/worker 投递不等待;独占内嵌套立即完成;普通主线程外层进入安全阶段时先遵守同连接已排队顺序;DRAINING 中普通投递只入队;同步 handoff 重入按任务 2 契约明确失败。原 handler、上下文与 NetworkRegistry.guard 保留,不双重执行。
|
||||
- [ ] 在 MinecraftServer.tickServer HEAD 的外层主线程 drain 一批;不在 pollTask 或 worker 回调内 drain。执行前验证连接、player 身份及世界;失效 future 明确结束。无队列不建屏障。
|
||||
- [ ] 加 `workerWaitCycleDetected` 测试:worker 等待队列 future 时测试必须识别无法收口且取消夹具,不误报自动兼容。运行集成输出 `PAYLOAD ENTRY PASS`,覆盖没有并行 loop 的原版情况。
|
||||
- [ ] 审阅并提交本任务文件。
|
||||
|
||||
## Task 4: 原模组交互与清理路径
|
||||
|
||||
**Files:** 修改 `src/main/java/dev/modzuozhi/core/compat/SableAssemblyDispatch.java`、`src/main/java/dev/modzuozhi/mixin/CreateConfigurationPacketMixin.java`、`src/main/java/dev/modzuozhi/mixin/SimulatedAssemblePacketMixin.java`;创建 `tests/payload/SimulatedHandleRegression.java`;修改 `build.gradle`。若真实路径证明需要专用适配,仅新增 `src/main/java/dev/modzuozhi/mixin/JadeRequestDispatchMixin.java` 与 `src/main/java/dev/modzuozhi/mixin/SimulatedHandleLifecycleMixin.java`,并登记可选 mixin。
|
||||
|
||||
**Interfaces:** 保持 `SableAssemblyDispatch.dispatch(ServerPlayer,Runnable)`,改为复用公共阶段/投递,禁止再送进已暂停的维度队列。具体可选模组方法描述符从本地实际 jar 审计后锁定,不能猜注入目标。
|
||||
|
||||
- [ ] 先记录真实 `UpdatePlayerUsingHandlePacket.handle` 的抓取、距离、松手入口、BE 身份、HandleConstraint 移除结果,建立 RED。核对 HandleBlockEntity.checkPlayers 及断线/死亡/卸载清理,不仅测试模拟 Runnable。
|
||||
- [ ] 审计 Veil 注册和 Jade 二次 server.execute;建立逃逸测试。只有已观察的逃逸点使用定点包装,不改写全部 MinecraftServer.execute。旧 Create/组装入口在公共独占内直接调用原 handler;阶段外走相同队列。
|
||||
- [ ] 测试顺序与副作用:抓取一次、更新距离一次、松手一次,最后 constraint 不存在;两个玩家不会互相释放;转速配置、组装拆解保留原权限和距离行为,错误坐标不强加载。
|
||||
- [ ] 覆盖断线、换维度、死亡、把手拆除、sublevel 卸载和单连接 256 满载后松手被拒绝;若原清理不足,可信生命周期适配在旧世界安全阶段释放资源,不重放过期客户端包。过载断开连接调用原 disconnect/cleanup,日志限频。
|
||||
- [ ] 真实运行输出 `SIMULATED HANDLE PASS`、`CREATE CONFIG PASS`、`JADE REQUEST PASS`。原模组依赖或原生物理无法启动时,记录未通过项并暂停“把手已修复”的交付结论,不能用假夹具替代。
|
||||
- [ ] 审阅并提交已验证的适配文件;无必要不创建专用 mixin。
|
||||
|
||||
## Task 5: 停止、可观测性与限频
|
||||
|
||||
**Files:** 创建 `src/main/java/dev/modzuozhi/core/compat/PayloadDiagnostics.java`;修改 `src/main/java/dev/modzuozhi/core/compat/PayloadMainThreadDispatch.java`、`src/main/java/dev/modzuozhi/mixin/MinecraftServerDimMixin.java`、`src/main/java/dev/modzuozhi/core/compat/ThreadOwnershipDiagnostics.java`、`src/main/java/dev/modzuozhi/command/MasterCommand.java`、`tests/payload/PayloadRegression.java`。
|
||||
|
||||
**Interfaces:** `PayloadDiagnostics.snapshot(MinecraftServer server): String`,只读且不新建循环;dispatch.stop 拒绝接收、终结待执行 future,阶段退出不恢复停止中的 loop;clear 在清理结束移除服务器引用。
|
||||
|
||||
- [ ] 写 `boundedDiagnostics`、`stopRejectsAndReleases`、`twoServersIsolated`:1000 个不同 ID 最多 128 个具名项加 other;1000 次失败不输出1000份堆栈;停止后无遗留 future/连接/世界引用;某一服务器 stop 不影响另一服务器。
|
||||
- [ ] 运行得到 RED。实现每服务器计数、队列最老等待、最近 32 事件、等待/执行耗时、批次数;异常全局 30 秒限频,快照无包正文。正常/异常停止入口在既有循环停止与保存之前关闭新队列;不要把旧 30 秒停服风险说成已解决。
|
||||
- [ ] status 和 crash report 附快照,区分等待 worker、handler 慢和 Sable handoff;测试所有失败 future 最终完成且 handler 从不重复执行。
|
||||
- [ ] 运行 payloadRegression 输出 `PAYLOAD LIFECYCLE PASS`、`PAYLOAD DIAGNOSTICS PASS`,审阅并提交。
|
||||
|
||||
## Task 6: 兼容验收与候选包
|
||||
|
||||
**Files:** 修改 `README.md`、`gradle.properties`(候选版本 1.1.17);证据 `build/reports/payload/`。
|
||||
|
||||
**Interfaces:** 交付明确列出“源码/字节码审计”“夹具通过”“真实模组通过”的覆盖表,不将它们合并为兼容保证。
|
||||
|
||||
- [ ] 分别运行 `./gradlew.bat check`、`runServer -PpayloadRegression`、`runServer -PownershipRegression -PccTestJar=<已验证本地CC路径>`、`runServer -PinteractionRegression`。逐项确认纯 Java、CC、区块、水流、实体路由和新 PASS 标记,不把预期故障夹具当成功事故。
|
||||
- [ ] 无 Create/Veil/Jade/Simulated 可选模组启动通过;真实模组组合另跑,不自动复制到用户 mods 目录。
|
||||
- [ ] 对比无交互、普通交互、密集抓握、多人查询及慢维度:主线程 TPS、维度完成 TPS、队列延迟、阶段耗时。无交互新增屏障数必须为零;报告慢维度代价,不以 Carpet MSPT 独立判定。
|
||||
- [ ] 正常构建 `./gradlew.bat clean build`,不带任何夹具属性。检查 jar 无 tests/Regression/测试模组类、元数据 1.1.17,记录 SHA256。
|
||||
- [ ] 按 requesting-code-review 技能审查完整差异,修复阻塞问题并重跑受影响测试。README 清楚标注 ServerWarashi 仍未由本计划修复以及真实航空学验收状态;提交本任务。
|
||||
- [ ] 只交付本地候选 jar 和覆盖/风险说明;如真实物理未通过,交付测试候选而非宣称全部修好。不推远端、不部署。
|
||||
|
||||
## 自审结果
|
||||
|
||||
设计第 4 节对应任务 1–3;旧适配、Veil/Jade 与生命周期对应任务 4–5;有界诊断与性能/打包对应任务 5–6。所有五个 Review Focus 已映射到断言。ServerWarashi 明确作为独立后续工作,不以当前方案替代采样栈修复。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
日期:2026-10-06(Asia/Shanghai)
|
||||
|
||||
状态:用户已同意“共用入口、正确性优先”的方向;本文待审阅,尚未进入实施。
|
||||
状态:用户于 2026-10-06 批准直接实施;公共入口已实现并进行候选验证。完整验收尚有未测项,见 `docs/payload-ownership-1.1.17-validation.md`,不得把本文的目标清单当作全部通过。
|
||||
|
||||
## 1. 用户目标与边界
|
||||
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ mod_name=Dimensional Ripper
|
||||
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
|
||||
mod_license=MIT
|
||||
# The mod version. See https://semver.org/
|
||||
mod_version=1.1.15
|
||||
mod_version=1.1.17
|
||||
# The group ID for the mod. It is only important when publishing build artifacts to Maven.
|
||||
mod_group_id=dev.modzuozhi
|
||||
# The authors of the mod. This is a simple text string that is used for display purposes in the mod list.
|
||||
|
||||
@@ -166,6 +166,10 @@ public final class MasterCommand {
|
||||
dimensionName(dimension),
|
||||
EntityTickParallel.missedShards(), ShardGate.teMissed(), ShardGate.chunkMissed());
|
||||
ctx.getSource().sendSuccess(() -> Component.literal(msg), false);
|
||||
ctx.getSource().sendSuccess(() -> Component.literal("线程交接:"
|
||||
+ dev.modzuozhi.core.compat.ThreadOwnershipDiagnostics.timing()), false);
|
||||
ctx.getSource().sendSuccess(() -> Component.literal(
|
||||
dev.modzuozhi.core.compat.PayloadDiagnostics.snapshot(ctx.getSource().getServer())), false);
|
||||
var loops = DimThreadCore.MANAGER.loopsSnapshot();
|
||||
if (loops.isEmpty()) {
|
||||
ctx.getSource().sendSuccess(() -> Component.literal("维度循环:无数据(尚未启动或已停止),不按 20 TPS 显示。"), false);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package dev.modzuozhi.core.compat;
|
||||
|
||||
import dev.modzuozhi.core.dimthread.DimThreadCore;
|
||||
import dev.modzuozhi.core.dimthread.LevelTickLoop;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/** Separate compatibility lease; ordinary pause/resume cannot release it. */
|
||||
public final class MainThreadPhaseCoordinator {
|
||||
private enum Phase { IDLE, DRAINING, EXCLUSIVE, RESTORING, STOPPING }
|
||||
private static final class State { volatile Phase phase = Phase.IDLE; }
|
||||
private static final ConcurrentHashMap<MinecraftServer, State> STATES = new ConcurrentHashMap<>();
|
||||
private MainThreadPhaseCoordinator() {}
|
||||
private static State state(MinecraftServer server) { return STATES.computeIfAbsent(server, s -> new State()); }
|
||||
|
||||
public static boolean owns(MinecraftServer server) {
|
||||
State s = STATES.get(server);
|
||||
return server.isSameThread() && s != null && s.phase == Phase.EXCLUSIVE;
|
||||
}
|
||||
public static boolean blocksWorkerStart(MinecraftServer server) {
|
||||
State s = STATES.get(server);
|
||||
return s != null && s.phase != Phase.IDLE;
|
||||
}
|
||||
public static String phase(MinecraftServer server) {
|
||||
State s = STATES.get(server); return s == null ? "IDLE" : s.phase.name();
|
||||
}
|
||||
/** Serializes loop registration with taking/releasing all leases; never waits for workers. */
|
||||
public static LevelTickLoop register(MinecraftServer server, Supplier<LevelTickLoop> registration) {
|
||||
State s = state(server);
|
||||
synchronized (s) {
|
||||
LevelTickLoop loop = registration.get();
|
||||
if (s.phase != Phase.IDLE || PayloadMainThreadDispatch.closed(server)) loop.holdForMainThread(true);
|
||||
return loop;
|
||||
}
|
||||
}
|
||||
/** Pause changes and loop creation use the same short lock; never hold it while draining. */
|
||||
public static void updatePause(MinecraftServer server, Runnable change) {
|
||||
synchronized(state(server)){change.run();}
|
||||
}
|
||||
public static void run(MinecraftServer server, String reason, Runnable action) {
|
||||
if (!server.isSameThread()) throw new IllegalStateException("Compatibility phase requires server thread");
|
||||
State s = state(server);
|
||||
if (owns(server)) { action.run(); return; }
|
||||
if (SableMainThreadBridge.inHandoff(server))
|
||||
throw new IllegalStateException("Global phase inside synchronous Sable handoff would deadlock: " + reason);
|
||||
synchronized (s) {
|
||||
if (s.phase != Phase.IDLE) throw new IllegalStateException("Reentrant global phase during " + s.phase);
|
||||
s.phase = Phase.DRAINING;
|
||||
loops(server).forEach(loop -> loop.holdForMainThread(true));
|
||||
}
|
||||
long began = System.nanoTime(), drained = began;
|
||||
try {
|
||||
server.managedBlock(() -> {
|
||||
boolean idle = loops(server).stream().noneMatch(LevelTickLoop::isTicking);
|
||||
if (!idle) ThreadOwnershipDiagnostics.slowPhase(server, reason, began);
|
||||
return idle;
|
||||
});
|
||||
synchronized (s) {
|
||||
if (s.phase == Phase.STOPPING) throw new IllegalStateException("Server stopping during compatibility phase");
|
||||
s.phase = Phase.EXCLUSIVE;
|
||||
}
|
||||
drained = System.nanoTime();
|
||||
action.run();
|
||||
} finally {
|
||||
ThreadOwnershipDiagnostics.phaseTiming(reason, began, drained);
|
||||
synchronized (s) {
|
||||
if (s.phase != Phase.STOPPING) {
|
||||
s.phase = Phase.RESTORING;
|
||||
loops(server).forEach(loop -> loop.holdForMainThread(false));
|
||||
s.phase = Phase.IDLE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private static java.util.List<LevelTickLoop> loops(MinecraftServer server) {
|
||||
return DimThreadCore.MANAGER.ownershipLoopsSnapshot().stream()
|
||||
.filter(loop -> loop.getLevel().getServer() == server).toList();
|
||||
}
|
||||
public static void stopping(MinecraftServer server) {
|
||||
State s = state(server);
|
||||
synchronized (s) { s.phase = Phase.STOPPING; loops(server).forEach(loop -> loop.holdForMainThread(true)); }
|
||||
}
|
||||
public static void clear(MinecraftServer server) { STATES.remove(server); }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package dev.modzuozhi.core.compat;
|
||||
|
||||
import dev.modzuozhi.ModZuozhi;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/** Bounded metadata only; never records payload bodies or exception messages containing user data. */
|
||||
public final class PayloadDiagnostics {
|
||||
private static final ConcurrentHashMap<MinecraftServer, Stats> STATS = new ConcurrentHashMap<>();
|
||||
private static final AtomicLong NEXT_WARNING = new AtomicLong();
|
||||
public record Ticket(String label, long started) {}
|
||||
private static final class Stats {
|
||||
final Map<String,long[]> ids = new LinkedHashMap<>();
|
||||
// received, completed, handler failure, cancelled, expired, capacity rejected
|
||||
final long[] other = new long[6];
|
||||
final Map<Ticket,Boolean> pending = new IdentityHashMap<>();
|
||||
final ArrayDeque<String> recent = new ArrayDeque<>();
|
||||
long batches; double waitMs, actionMs;
|
||||
}
|
||||
private PayloadDiagnostics() {}
|
||||
public static Ticket begin(MinecraftServer server, String id) {
|
||||
synchronized(PayloadMainThreadDispatch.LIFECYCLE) {
|
||||
if(PayloadMainThreadDispatch.closed(server))return null;
|
||||
Stats s = STATS.computeIfAbsent(server, key -> new Stats());
|
||||
synchronized (s) {
|
||||
String label = s.ids.containsKey(id) || s.ids.size()<128 ? id : "other";
|
||||
long[] values = label.equals("other") ? s.other : s.ids.computeIfAbsent(label, k -> new long[6]);
|
||||
values[0]++;
|
||||
Ticket ticket = new Ticket(label,System.nanoTime()); s.pending.put(ticket,Boolean.TRUE); return ticket;
|
||||
}
|
||||
}
|
||||
}
|
||||
public static void end(MinecraftServer server, Ticket ticket, Throwable error) {
|
||||
if(ticket==null)return;
|
||||
Stats s=STATS.get(server); if(s==null) return;
|
||||
synchronized(s) {
|
||||
if(s.pending.remove(ticket)==null) return;
|
||||
long[] values=ticket.label.equals("other") ? s.other : s.ids.get(ticket.label);
|
||||
int outcome = error == null ? 1
|
||||
: PayloadMainThreadDispatch.expired(error) ? 4
|
||||
: error instanceof PayloadWorkQueue.CapacityExceeded ? 5
|
||||
: error instanceof java.util.concurrent.CancellationException ? 3 : 2;
|
||||
values[outcome]++;
|
||||
if(error!=null) {
|
||||
if(s.recent.size()==32) s.recent.removeFirst();
|
||||
s.recent.addLast(ticket.label+":"+error.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
if(error!=null) warn(ticket.label,error);
|
||||
}
|
||||
private static void warn(String id, Throwable error) {
|
||||
long now=System.nanoTime(),next=NEXT_WARNING.get();
|
||||
if((next==0 || now-next>=0) && NEXT_WARNING.compareAndSet(next,now+TimeUnit.SECONDS.toNanos(30)))
|
||||
ModZuozhi.LOGGER.warn("[PayloadOwnership] Task {} ended with {}; further failures aggregated for 30s",id,error.getClass().getSimpleName());
|
||||
}
|
||||
public static void batch(MinecraftServer server,long start,long acquired) {
|
||||
Stats s=STATS.get(server);if(s==null)return;
|
||||
synchronized(s){s.batches++;s.waitMs=(acquired-start)/1_000_000.0;s.actionMs=(System.nanoTime()-acquired)/1_000_000.0;}
|
||||
}
|
||||
public static String snapshot(MinecraftServer server) {
|
||||
Stats s=STATS.get(server);if(s==null)return "payloads: pending=0 batches=0";
|
||||
synchronized(s) {
|
||||
long oldest=s.pending.keySet().stream().mapToLong(Ticket::started).min().orElse(System.nanoTime());
|
||||
long received=s.other[0],completed=s.other[1],failed=s.other[2],cancelled=s.other[3],expired=s.other[4],rejected=s.other[5];
|
||||
for(long[] v:s.ids.values()){received+=v[0];completed+=v[1];failed+=v[2];cancelled+=v[3];expired+=v[4];rejected+=v[5];}
|
||||
return "payloads: phase="+MainThreadPhaseCoordinator.phase(server)+" received="+received+" completed="+completed
|
||||
+" failed="+failed+" cancelled="+cancelled+" expired="+expired+" rejected="+rejected
|
||||
+" pending="+s.pending.size()+" oldestMs="+(System.nanoTime()-oldest)/1_000_000.0
|
||||
+" batches="+s.batches+" waitMs="+s.waitMs+" actionMs="+s.actionMs+" ids="+s.ids.size()+" other="+Arrays.toString(s.other)+" recent="+s.recent;
|
||||
}
|
||||
}
|
||||
public static void clear(MinecraftServer server){STATS.remove(server);}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package dev.modzuozhi.core.compat;
|
||||
|
||||
import net.minecraft.network.Connection;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/** Shared game payload handoff. No Minecraft world access is authorized merely by thread name. */
|
||||
public final class PayloadMainThreadDispatch {
|
||||
static final Object LIFECYCLE = new Object();
|
||||
private static final class StalePayload extends CancellationException {
|
||||
StalePayload(String reason){super(reason);}
|
||||
}
|
||||
private static final ConcurrentHashMap<MinecraftServer, PayloadWorkQueue<Connection>> QUEUES = new ConcurrentHashMap<>();
|
||||
private static final java.util.Map<MinecraftServer,Boolean> STOPPED = new java.util.WeakHashMap<>();
|
||||
private static final ConcurrentHashMap<MinecraftServer, ConcurrentHashMap<Connection,ServerPlayer>> OVERLOADED = new ConcurrentHashMap<>();
|
||||
private PayloadMainThreadDispatch() {}
|
||||
|
||||
public static boolean owns(MinecraftServer server) { return MainThreadPhaseCoordinator.owns(server); }
|
||||
static boolean closed(MinecraftServer server) { synchronized(LIFECYCLE){return STOPPED.containsKey(server)||server.isStopped();} }
|
||||
static boolean expired(Throwable error) { return error instanceof StalePayload; }
|
||||
|
||||
public static <T> CompletableFuture<T> submit(ServerPlayer player, ResourceLocation payloadId, Supplier<T> action) {
|
||||
var level = player.serverLevel();
|
||||
var server = level.getServer();
|
||||
var listener = player.connection;
|
||||
if (listener == null || player.hasDisconnected() || player.isRemoved() || closed(server))
|
||||
return CompletableFuture.failedFuture(new StalePayload("Stale payload player"));
|
||||
if (owns(server)) return CompletableFuture.completedFuture(action.get());
|
||||
if (server.isSameThread() && SableMainThreadBridge.inHandoff(server))
|
||||
throw new IllegalStateException("Payload " + payloadId + " requested global ownership from synchronous Sable callback");
|
||||
PayloadWorkQueue<Connection> queue;
|
||||
synchronized(LIFECYCLE) {
|
||||
if(closed(server)) return CompletableFuture.failedFuture(new StalePayload("Server stopping"));
|
||||
queue = QUEUES.computeIfAbsent(server, s -> new PayloadWorkQueue<>(System::nanoTime));
|
||||
}
|
||||
var ticket=PayloadDiagnostics.begin(server,payloadId.toString());
|
||||
CompletableFuture<T> result = queue.submit(listener.getConnection(), () -> {
|
||||
if (player.connection != listener || listener.player != player || player.hasDisconnected()
|
||||
|| player.isRemoved() || player.serverLevel() != level)
|
||||
throw new StalePayload("Payload player/world changed before execution");
|
||||
try{return action.get();}
|
||||
catch(CancellationException handlerFailure){throw new CompletionException(handlerFailure);}
|
||||
});
|
||||
result.whenComplete((value,error) -> {
|
||||
PayloadDiagnostics.end(server,ticket,error);
|
||||
if(error instanceof PayloadWorkQueue.CapacityExceeded) synchronized(LIFECYCLE) {
|
||||
if(!closed(server)) OVERLOADED.computeIfAbsent(server,s -> new ConcurrentHashMap<>()).putIfAbsent(listener.getConnection(),player);
|
||||
}
|
||||
});
|
||||
// Preserve synchronous main-thread enqueueWork semantics and observed FIFO order.
|
||||
// Each acquisition still obeys the per-batch budget. Never drain recursively while pumping callbacks.
|
||||
if (server.isSameThread() && !MainThreadPhaseCoordinator.blocksWorkerStart(server)) {
|
||||
while (!result.isDone()) drain(server);
|
||||
try { result.join(); }
|
||||
catch (CompletionException failed) {
|
||||
if (failed.getCause() instanceof RuntimeException runtime) throw runtime;
|
||||
if (failed.getCause() instanceof Error error) throw error;
|
||||
throw failed;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
public static void drain(MinecraftServer server) {
|
||||
if (!server.isSameThread()) throw new IllegalStateException("Payload drain requires server thread");
|
||||
var queue = QUEUES.get(server);
|
||||
var overloaded=OVERLOADED.get(server);
|
||||
if ((queue == null || queue.size() == 0) && (overloaded==null || overloaded.isEmpty())) return;
|
||||
if (MainThreadPhaseCoordinator.blocksWorkerStart(server)
|
||||
|| SableMainThreadBridge.inHandoff(server)) return;
|
||||
long start=System.nanoTime();
|
||||
MainThreadPhaseCoordinator.run(server, "custom payloads", () -> {
|
||||
long acquired=System.nanoTime();
|
||||
try {
|
||||
// Cleanup goes through the original disconnect lifecycle with world ownership.
|
||||
// A cleanup-only batch avoids bypassing the payload batch count/time budget.
|
||||
if(overloaded!=null && !overloaded.isEmpty()) {
|
||||
int count=0;
|
||||
for(var entry:overloaded.entrySet()) {
|
||||
if(count++>=64 || System.nanoTime()-acquired>=2_000_000L) break;
|
||||
if(overloaded.remove(entry.getKey(),entry.getValue())) {
|
||||
var player=entry.getValue();
|
||||
if(player.connection!=null && player.connection.getConnection()==entry.getKey() && !player.hasDisconnected())
|
||||
player.connection.disconnect(net.minecraft.network.chat.Component.literal("Too many pending mod interactions; please reconnect."));
|
||||
}
|
||||
}
|
||||
} else if(queue!=null) queue.drainBatch();
|
||||
} finally {PayloadDiagnostics.batch(server,start,acquired);}
|
||||
});
|
||||
}
|
||||
public static void stop(MinecraftServer server) {
|
||||
PayloadWorkQueue<Connection> queue;
|
||||
synchronized(LIFECYCLE){STOPPED.put(server,Boolean.TRUE);queue=QUEUES.get(server);OVERLOADED.remove(server);}
|
||||
if(queue!=null)queue.close(new StalePayload("Server stopping"));
|
||||
MainThreadPhaseCoordinator.stopping(server);
|
||||
}
|
||||
public static void clear(MinecraftServer server) {
|
||||
synchronized(LIFECYCLE){QUEUES.remove(server); OVERLOADED.remove(server); PayloadDiagnostics.clear(server);}
|
||||
MainThreadPhaseCoordinator.clear(server);
|
||||
}
|
||||
/** NeoForge guard's null-on-handler-error contract, with bounded diagnostics already attached to raw result. */
|
||||
public static <T> CompletableFuture<T> guard(CompletableFuture<T> result) {
|
||||
CompletableFuture<T> guarded=result.handle((value,error) -> {
|
||||
// Preserve NeoForge's handler-error result, not false success for queue policy failures.
|
||||
if(error instanceof StalePayload || error instanceof PayloadWorkQueue.CapacityExceeded || result.isCancelled())
|
||||
throw new CompletionException(error);
|
||||
return error==null ? value : null;
|
||||
});
|
||||
guarded.whenComplete((value,error) -> {if(guarded.isCancelled())result.cancel(false);});
|
||||
return guarded;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import java.util.function.Supplier;
|
||||
|
||||
/** Bounded FIFO. Queue locks never encompass handlers or future completion callbacks. */
|
||||
public final class PayloadWorkQueue<K> {
|
||||
public static final class CapacityExceeded extends RejectedExecutionException {
|
||||
private CapacityExceeded() { super("Payload queue capacity exceeded"); }
|
||||
}
|
||||
private final LongSupplier clock;
|
||||
private final LinkedHashSet<Entry<?>> pending = new LinkedHashSet<>();
|
||||
private final Map<K, Integer> counts = new HashMap<>();
|
||||
@@ -24,7 +27,7 @@ public final class PayloadWorkQueue<K> {
|
||||
synchronized (this) {
|
||||
rejected = closed;
|
||||
if (rejected == null && (pending.size() >= 4096 || counts.getOrDefault(connection, 0) >= 256))
|
||||
rejected = new RejectedExecutionException("Payload queue capacity exceeded");
|
||||
rejected = new CapacityExceeded();
|
||||
if (rejected == null) {
|
||||
pending.add(entry);
|
||||
counts.merge(connection, 1, Integer::sum);
|
||||
|
||||
@@ -1,38 +1,15 @@
|
||||
package dev.modzuozhi.core.compat;
|
||||
|
||||
import dev.modzuozhi.ModZuozhi;
|
||||
import dev.modzuozhi.core.dimthread.DimThreadCore;
|
||||
import dev.modzuozhi.core.dimthread.IPlayerPacketQueue;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
|
||||
/** Main -> dimension safe point -> exclusive main-thread phase, without blocking main on dimension. */
|
||||
/** Legacy Create/assembly callers share the generic payload phase; no second dimension queue. */
|
||||
public final class SableAssemblyDispatch {
|
||||
private SableAssemblyDispatch() {}
|
||||
|
||||
public static void dispatch(ServerPlayer player, Runnable action) {
|
||||
ServerLevel level = player.serverLevel();
|
||||
var server = level.getServer();
|
||||
if (!server.isSameThread()) {
|
||||
server.execute(() -> dispatch(player, action));
|
||||
return;
|
||||
}
|
||||
if (player.hasDisconnected() || player.isRemoved()) return;
|
||||
if (!DimThreadCore.MANAGER.isLoopScheduled(level) || SableMainThreadBridge.ownsHandoff(level)) {
|
||||
action.run();
|
||||
return;
|
||||
}
|
||||
// Return immediately: waiting here would deadlock against an in-flight Sable tick.
|
||||
((IPlayerPacketQueue) level).modzuozhi$playerPacketQueue().offer(() ->
|
||||
SableMainThreadBridge.runForLevel(level, () -> {
|
||||
// A queued interaction must not act on a different dimension after teleport/logout.
|
||||
if (player.hasDisconnected() || player.isRemoved() || player.serverLevel() != level) return;
|
||||
try {
|
||||
action.run();
|
||||
} catch (RuntimeException failure) {
|
||||
ModZuozhi.LOGGER.error("[Compatibility] 航空学组装/拆解请求执行失败", failure);
|
||||
throw failure;
|
||||
}
|
||||
}));
|
||||
PayloadMainThreadDispatch.submit(player, ResourceLocation.fromNamespaceAndPath("dimensionalripper", "legacy_interaction"),
|
||||
() -> { action.run(); return null; });
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,10 @@ public final class SableMainThreadBridge {
|
||||
public static boolean ownsHandoff(ServerLevel level) {
|
||||
return HANDED_OFF_LEVEL.get() == level;
|
||||
}
|
||||
public static boolean inHandoff(MinecraftServer server) {
|
||||
ServerLevel level = HANDED_OFF_LEVEL.get();
|
||||
return level != null && level.getServer() == server;
|
||||
}
|
||||
private static final ConcurrentHashMap<Class<?>, Method> GET_LEVEL_METHODS = new ConcurrentHashMap<>();
|
||||
|
||||
private SableMainThreadBridge() {
|
||||
@@ -72,12 +76,14 @@ public final class SableMainThreadBridge {
|
||||
CompletableFuture<Void> completion = new CompletableFuture<>();
|
||||
// 0 = queued, 1 = running, 2 = cancelled before start.
|
||||
AtomicInteger state = new AtomicInteger();
|
||||
ThreadOwnershipDiagnostics.handoff(level, "queued");
|
||||
server.execute(() -> {
|
||||
// A timed-out queued task must not execute later, detached from its waiting level tick.
|
||||
if (!state.compareAndSet(0, 1)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ThreadOwnershipDiagnostics.handoff(level, "running");
|
||||
// The parent dimension worker temporarily owns both vanilla thread fields while
|
||||
// it is paused here. Restore real server-thread ownership for the complete Sable
|
||||
// segment, then hand ownership back before waking the worker.
|
||||
@@ -88,8 +94,10 @@ public final class SableMainThreadBridge {
|
||||
} finally {
|
||||
if (previous == null) HANDED_OFF_LEVEL.remove(); else HANDED_OFF_LEVEL.set(previous);
|
||||
}
|
||||
ThreadOwnershipDiagnostics.handoff(level, "completed-owner-restored");
|
||||
completion.complete(null);
|
||||
} catch (Throwable throwable) {
|
||||
ThreadOwnershipDiagnostics.handoff(level, "failed-owner-restored: " + throwable.getClass().getSimpleName());
|
||||
completion.completeExceptionally(throwable);
|
||||
}
|
||||
});
|
||||
@@ -98,6 +106,7 @@ public final class SableMainThreadBridge {
|
||||
completion.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
if (state.compareAndSet(0, 2)) {
|
||||
ThreadOwnershipDiagnostics.handoff(level, "cancelled-interrupted-before-start");
|
||||
completion.cancel(false);
|
||||
} else {
|
||||
// A running main-thread phase must finish before world ownership is released.
|
||||
@@ -107,6 +116,7 @@ public final class SableMainThreadBridge {
|
||||
throw new IllegalStateException("Interrupted while waiting for Sable main-thread physics tick", interrupted);
|
||||
} catch (TimeoutException timeout) {
|
||||
if (state.compareAndSet(0, 2)) {
|
||||
ThreadOwnershipDiagnostics.handoff(level, "cancelled-timeout-before-start");
|
||||
completion.cancel(false);
|
||||
ModZuozhi.LOGGER.error(
|
||||
"[Compatibility] Sable 物理 Tick 排队超过 {} 秒且尚未开始,交由故障保护降级",
|
||||
@@ -119,6 +129,7 @@ public final class SableMainThreadBridge {
|
||||
ModZuozhi.LOGGER.warn(
|
||||
"[Compatibility] Sable 主线程物理 Tick 已运行超过 {} 秒;为避免世界并发写入,继续等待其安全结束",
|
||||
TIMEOUT_SECONDS);
|
||||
ThreadOwnershipDiagnostics.handoff(level, "running-over-10s");
|
||||
awaitRunningTick(completion);
|
||||
} catch (ExecutionException failed) {
|
||||
Throwable cause = failed.getCause();
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package dev.modzuozhi.core.compat;
|
||||
|
||||
import dev.modzuozhi.ModZuozhi;
|
||||
import dev.modzuozhi.core.dimthread.DimThreadCore;
|
||||
import dev.modzuozhi.core.dimthread.IMutableMainThread;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/** Bounded, read-only evidence: never load chunks or query live block-entity collections here. */
|
||||
public final class ThreadOwnershipDiagnostics {
|
||||
private static final ConcurrentHashMap<ServerLevel, Trace> TRACES = new ConcurrentHashMap<>();
|
||||
private static final AtomicLong NEXT_SLOW_WARNING = new AtomicLong();
|
||||
private static volatile String lastTiming = "no CC ownership phase sampled";
|
||||
private ThreadOwnershipDiagnostics() {}
|
||||
|
||||
private static final class Trace {
|
||||
String phase = "unobserved";
|
||||
String handoff = "none";
|
||||
final ArrayDeque<String> recent = new ArrayDeque<>();
|
||||
long rejected;
|
||||
long nextWarning;
|
||||
String lastRejected = "none";
|
||||
void add(String entry) {
|
||||
if (recent.size() == 16) recent.removeFirst();
|
||||
recent.addLast(System.nanoTime() + "ns " + entry);
|
||||
}
|
||||
}
|
||||
|
||||
public static void phase(ServerLevel level, String phase) {
|
||||
Trace trace = TRACES.computeIfAbsent(level, key -> new Trace());
|
||||
synchronized (trace) { trace.phase = phase; trace.add(phase + " caller=" + thread(Thread.currentThread())); }
|
||||
}
|
||||
|
||||
public static void handoff(ServerLevel level, String state) {
|
||||
Trace trace = TRACES.computeIfAbsent(level, key -> new Trace());
|
||||
synchronized (trace) { trace.handoff = state; trace.add("handoff=" + state + " caller=" + thread(Thread.currentThread())); }
|
||||
}
|
||||
|
||||
public static void rejectedLookup(ServerLevel level, BlockPos pos, Thread observedOwner) {
|
||||
Trace trace = TRACES.computeIfAbsent(level, key -> new Trace());
|
||||
long now = System.nanoTime();
|
||||
synchronized (trace) {
|
||||
trace.rejected++;
|
||||
if (trace.nextWarning != 0 && now - trace.nextWarning < 0) return;
|
||||
trace.nextWarning = now + TimeUnit.SECONDS.toNanos(30);
|
||||
String callers = StackWalker.getInstance().walk(frames -> frames
|
||||
.filter(frame -> !frame.getClassName().equals(ThreadOwnershipDiagnostics.class.getName()))
|
||||
.limit(8).map(Object::toString).collect(java.util.stream.Collectors.joining(" <- ")));
|
||||
trace.lastRejected = "pos=" + pos.toShortString() + " caller=" + thread(Thread.currentThread())
|
||||
+ " observedOwner=" + thread(observedOwner) + " at=" + callers;
|
||||
trace.add("rejected " + trace.lastRejected);
|
||||
ModZuozhi.LOGGER.warn("[ThreadOwnership] getBlockEntity owner mismatch at entry (guard may return null): {}", describe(level));
|
||||
}
|
||||
}
|
||||
|
||||
public static String describe(ServerLevel level) {
|
||||
Trace trace = TRACES.get(level);
|
||||
String owners = "dimension=" + level.dimension().location()
|
||||
+ " levelOwner=" + thread(((IMutableMainThread) level).dimThreads$getMainThread())
|
||||
+ " chunkOwner=" + thread(((IMutableMainThread) level.getChunkSource()).dimThreads$getMainThread());
|
||||
if (trace == null) return owners + " phase=unobserved";
|
||||
synchronized (trace) {
|
||||
return owners + " phase=" + trace.phase + " handoff=" + trace.handoff
|
||||
+ " rejected=" + trace.rejected + " lastRejected={" + trace.lastRejected + "} recent=" + trace.recent;
|
||||
}
|
||||
}
|
||||
|
||||
public static String report(MinecraftServer server) {
|
||||
StringBuilder out = new StringBuilder("Snapshot (not an atomic world view); observer=")
|
||||
.append(thread(Thread.currentThread())).append(';').append(lastTiming);
|
||||
DimThreadCore.MANAGER.loopsSnapshot().forEach((level, loop) -> {
|
||||
if (level.getServer() == server) out.append('\n').append(describe(level))
|
||||
.append(" ticking=").append(loop.isTicking()).append(" paused=").append(loop.isPaused())
|
||||
.append(" running=").append(loop.isRunning());
|
||||
});
|
||||
return out.append('\n').append(PayloadDiagnostics.snapshot(server)).toString();
|
||||
}
|
||||
|
||||
public static void slowPhase(MinecraftServer server, String reason, long began) {
|
||||
long now = System.nanoTime();
|
||||
if (now - began < TimeUnit.SECONDS.toNanos(1)) return;
|
||||
long next = NEXT_SLOW_WARNING.get();
|
||||
if ((next == 0 || now - next >= 0) && NEXT_SLOW_WARNING.compareAndSet(next, now + TimeUnit.SECONDS.toNanos(30)))
|
||||
ModZuozhi.LOGGER.warn("[ThreadOwnership] {} waiting {} ms; {}", reason, (now-began)/1_000_000, report(server));
|
||||
}
|
||||
|
||||
public static void phaseTiming(String reason, long began, long drained) {
|
||||
lastTiming = reason + " lastWaitMs=" + (drained-began)/1_000_000.0
|
||||
+ " lastActionMs=" + (System.nanoTime()-drained)/1_000_000.0;
|
||||
}
|
||||
public static String timing() { return lastTiming; }
|
||||
public static String thread(Thread thread) {
|
||||
return thread == null ? "null" : thread.getName() + "#" + thread.threadId() + '/' + thread.getState();
|
||||
}
|
||||
public static void clear() { TRACES.clear(); NEXT_SLOW_WARNING.set(0); lastTiming = "no CC ownership phase sampled"; }
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package dev.modzuozhi.core.dimthread;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import dev.modzuozhi.ModZuozhi;
|
||||
import dev.modzuozhi.core.compat.ThreadOwnershipDiagnostics;
|
||||
import net.minecraft.network.Connection;
|
||||
import net.minecraft.network.PacketSendListener;
|
||||
import net.minecraft.network.chat.Component;
|
||||
@@ -62,6 +63,7 @@ public final class LevelTickLoop implements Runnable, java.util.concurrent.Execu
|
||||
private volatile boolean ticking = false;
|
||||
/** 单机内嵌服务器暂停(ESC 菜单)时置 true:取消下一拍并不再自排,杜绝暂停/保存期间并行写区块。 */
|
||||
private volatile boolean paused = false;
|
||||
private volatile boolean mainThreadHeld;
|
||||
private long scheduleGeneration;
|
||||
private final dev.modzuozhi.core.compat.TickProgress progress = new dev.modzuozhi.core.compat.TickProgress();
|
||||
|
||||
@@ -106,7 +108,7 @@ public final class LevelTickLoop implements Runnable, java.util.concurrent.Execu
|
||||
|
||||
/** 首次启动(或外部主动重排)本循环。 */
|
||||
public synchronized void schedule() {
|
||||
if (!this.running || this.paused || this.ticking) return;
|
||||
if (!this.running || this.paused || this.mainThreadHeld || this.ticking) return;
|
||||
this.scheduleAfter(0L);
|
||||
}
|
||||
|
||||
@@ -128,7 +130,16 @@ public final class LevelTickLoop implements Runnable, java.util.concurrent.Execu
|
||||
}
|
||||
|
||||
public boolean isPaused() {
|
||||
return this.paused;
|
||||
return this.paused || this.mainThreadHeld;
|
||||
}
|
||||
|
||||
public synchronized void holdForMainThread(boolean held) {
|
||||
if (this.mainThreadHeld == held) return;
|
||||
this.mainThreadHeld = held;
|
||||
if (held) {
|
||||
this.scheduleGeneration++;
|
||||
if (this.currentTickTask != null) this.currentTickTask.cancel(false);
|
||||
} else if (this.scheduled) this.schedule();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,7 +192,7 @@ public final class LevelTickLoop implements Runnable, java.util.concurrent.Execu
|
||||
|
||||
private void runIfCurrent(long generation) {
|
||||
synchronized (this) {
|
||||
if (generation != this.scheduleGeneration || this.paused || !this.running || this.ticking) return;
|
||||
if (generation != this.scheduleGeneration || this.paused || this.mainThreadHeld || !this.running || this.ticking) return;
|
||||
this.ticking = true;
|
||||
}
|
||||
DimThreadCore.attach(Thread.currentThread(), this.level);
|
||||
@@ -199,11 +210,14 @@ public final class LevelTickLoop implements Runnable, java.util.concurrent.Execu
|
||||
try {
|
||||
DimThreadCore.swapThreadsAndRun(() -> {
|
||||
try {
|
||||
ThreadOwnershipDiagnostics.phase(this.level, "queued-tasks");
|
||||
this.processPendingTasks();
|
||||
ThreadOwnershipDiagnostics.phase(this.level, "level-tick");
|
||||
completed[0] = this.internalTick();
|
||||
} finally {
|
||||
workNanos[0] = System.nanoTime() - tickStart;
|
||||
}
|
||||
ThreadOwnershipDiagnostics.phase(this.level, "chunk-tasks");
|
||||
this.drainChunkTasks(deadline);
|
||||
}, this.level, this.level.getChunkSource());
|
||||
} catch (Throwable t) {
|
||||
@@ -211,10 +225,11 @@ public final class LevelTickLoop implements Runnable, java.util.concurrent.Execu
|
||||
ModZuozhi.LOGGER.error("[DimThread] 维度 {} tick 异常", this.level.dimension().location(), t);
|
||||
FaultGuard.onDimTickFailure(t);
|
||||
} finally {
|
||||
ThreadOwnershipDiagnostics.phase(this.level, "idle");
|
||||
progress.finish(System.nanoTime(), workNanos[0], completed[0]);
|
||||
synchronized (this) {
|
||||
this.ticking = false;
|
||||
if (this.running && !this.paused) {
|
||||
if (this.running && !this.paused && !this.mainThreadHeld) {
|
||||
this.scheduleAfter(Math.max(0L, deadline - System.nanoTime()));
|
||||
} else if (!this.running) {
|
||||
ModZuozhi.LOGGER.info("[DimThread] 维度 {} 独立循环已停止", this.level.dimension().location());
|
||||
@@ -250,7 +265,9 @@ public final class LevelTickLoop implements Runnable, java.util.concurrent.Execu
|
||||
// 区块不加载 → 实体/光照等待区块 → tick 更慢 → 更没有预算排空区块。因此给一个最低保证窗口。
|
||||
long effectiveDeadline = Math.max(deadline, now + 2_000_000L); // 最低 2ms
|
||||
boolean overBudget = now >= deadline;
|
||||
while (this.running && System.nanoTime() < effectiveDeadline) {
|
||||
// A compatibility phase needs completed work, not the remainder of an idle poll budget.
|
||||
// The in-flight level tick has already finished; leave queued work for its next owner.
|
||||
while (this.running && !this.paused && !this.mainThreadHeld && System.nanoTime() < effectiveDeadline) {
|
||||
if (chunkSource.pollTask()) {
|
||||
continue; // 有就绪任务,继续排空
|
||||
}
|
||||
@@ -296,6 +313,8 @@ public final class LevelTickLoop implements Runnable, java.util.concurrent.Execu
|
||||
}
|
||||
EventHooks.fireLevelTickPost(this.level, () -> true);
|
||||
|
||||
ThreadOwnershipDiagnostics.phase(this.level, "connections");
|
||||
|
||||
if (net.neoforged.fml.ModList.get().isLoaded("starlight_extra")) {
|
||||
// Farm inventory persistence and logout callbacks require the actual server thread.
|
||||
// Keep the whole connection phase together, including disconnection cleanup.
|
||||
|
||||
@@ -38,6 +38,8 @@ public class ServerManager {
|
||||
|
||||
/** 每维度独立 tick 循环(维度隔离模型,见 {@link LevelTickLoop})。key 为维度。 */
|
||||
private final Map<ServerLevel, LevelTickLoop> loops = new ConcurrentHashMap<>();
|
||||
private final java.util.Set<LevelTickLoop> retiringLoops = ConcurrentHashMap.newKeySet();
|
||||
private final Map<MinecraftServer,Boolean> serverPauses = new ConcurrentHashMap<>();
|
||||
/** 承载所有维度 loop 自排程的调度池({@code schedule(task, nspt)} 需要 Scheduled 能力)。 */
|
||||
private volatile ScheduledThreadPoolExecutor masterPool;
|
||||
/** 玩家连接当前所属维度(key 为 Connection,value 为维度),供跨维度/登录时幂等迁移连接。 */
|
||||
@@ -143,7 +145,26 @@ public class ServerManager {
|
||||
|
||||
/** 获取(懒创建)某维度的独立 tick 循环。 */
|
||||
public LevelTickLoop getLoop(ServerLevel level) {
|
||||
return this.loops.computeIfAbsent(level, l -> new LevelTickLoop(l, getMasterPool()));
|
||||
return dev.modzuozhi.core.compat.MainThreadPhaseCoordinator.register(level.getServer(),
|
||||
() -> {
|
||||
for(var retired:retiringLoops) {
|
||||
if(retired.getLevel()==level && retired.isTicking())
|
||||
throw new IllegalStateException("Cannot replace dimension loop before retired tick drains");
|
||||
}
|
||||
return this.loops.computeIfAbsent(level, l -> {
|
||||
var loop=new LevelTickLoop(l,getMasterPool());
|
||||
if(serverPauses.getOrDefault(l.getServer(),false))loop.pause();
|
||||
return loop;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Includes removed-but-still-running writers; removing a map entry does not return ownership. */
|
||||
public java.util.List<LevelTickLoop> ownershipLoopsSnapshot() {
|
||||
retiringLoops.removeIf(loop -> !loop.isTicking());
|
||||
var snapshot=new java.util.HashSet<>(this.loops.values());
|
||||
snapshot.addAll(retiringLoops);
|
||||
return java.util.List.copyOf(snapshot);
|
||||
}
|
||||
|
||||
/** Read-only inventory; observing status must never create or start a loop. */
|
||||
@@ -151,6 +172,15 @@ public class ServerManager {
|
||||
return Map.copyOf(this.loops);
|
||||
}
|
||||
|
||||
/**
|
||||
* Short main-thread compatibility phase. Never hold a mod lock while draining workers.
|
||||
* Call from the server tick, not from a synchronous worker-to-main handoff: that caller's
|
||||
* worker cannot drain until the callback returns. CC TickScheduler is a server-tick hook.
|
||||
*/
|
||||
public void runMainThreadPhase(MinecraftServer server, String reason, Runnable action) {
|
||||
dev.modzuozhi.core.compat.MainThreadPhaseCoordinator.run(server, reason, action);
|
||||
}
|
||||
|
||||
/**
|
||||
* 该维度 loop 是否已启动接管({@code tickChildren → scheduleIfNeeded} 之后才为 true)。
|
||||
* <p>
|
||||
@@ -159,6 +189,7 @@ public class ServerManager {
|
||||
* 来驱动出生点区块生成;直到 loop 接管后才由 {@code ServerChunkCachePollMixin} 跳过主线程 poll。
|
||||
*/
|
||||
public boolean isLoopScheduled(ServerLevel level) {
|
||||
if(retiringLoops.stream().anyMatch(loop -> loop.getLevel()==level && loop.isTicking()))return true;
|
||||
LevelTickLoop loop = this.loops.get(level);
|
||||
return loop != null && dev.modzuozhi.core.compat.PauseOwnership.workerOwns(
|
||||
loop.isScheduled(), loop.isPaused(), loop.isTicking());
|
||||
@@ -227,21 +258,25 @@ public class ServerManager {
|
||||
if (!server.isSameThread()) {
|
||||
throw new IllegalStateException("Pause ownership must be changed on the server thread");
|
||||
}
|
||||
dev.modzuozhi.core.compat.MainThreadPhaseCoordinator.updatePause(server,() -> {
|
||||
serverPauses.put(server,paused);
|
||||
ownershipLoopsSnapshot().stream().filter(loop -> loop.getLevel().getServer()==server)
|
||||
.forEach(loop -> {if(paused)loop.pause();else loop.resume();});
|
||||
});
|
||||
if (paused) {
|
||||
this.loops.values().forEach(LevelTickLoop::pause);
|
||||
// In-flight ticks may be waiting for Sable on this very thread. Pump callbacks
|
||||
// until they return ownership; a timeout must not authorize concurrent saving.
|
||||
server.managedBlock(() -> this.loops.values().stream().noneMatch(LevelTickLoop::isTicking));
|
||||
} else {
|
||||
this.loops.values().forEach(LevelTickLoop::resume);
|
||||
server.managedBlock(() -> ownershipLoopsSnapshot().stream().filter(loop -> loop.getLevel().getServer()==server).noneMatch(LevelTickLoop::isTicking));
|
||||
}
|
||||
}
|
||||
|
||||
/** 移除并停止某维度的 loop(维度卸载时调用)。 */
|
||||
public void removeLoop(ServerLevel level) {
|
||||
LevelTickLoop loop = this.loops.remove(level);
|
||||
LevelTickLoop loop = this.loops.get(level);
|
||||
if (loop != null) {
|
||||
loop.kill();
|
||||
retiringLoops.add(loop);
|
||||
this.loops.remove(level,loop);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,9 +295,10 @@ public class ServerManager {
|
||||
* 带 30s 看门狗防止异常场景下永久阻塞。
|
||||
*/
|
||||
public void stopLoopsAndWaitIdle() {
|
||||
this.loops.values().forEach(LevelTickLoop::kill);
|
||||
var stopping = ownershipLoopsSnapshot();
|
||||
stopping.forEach(LevelTickLoop::kill);
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
|
||||
for (LevelTickLoop loop : this.loops.values()) {
|
||||
for (LevelTickLoop loop : stopping) {
|
||||
while (loop.isTicking()) {
|
||||
if (System.nanoTime() > deadline) {
|
||||
ModZuozhi.LOGGER.warn("[DimThread] 等待维度 {} loop 收口超时(>30s),强制继续",
|
||||
@@ -277,6 +313,7 @@ public class ServerManager {
|
||||
/** 停止所有维度 loop 并关闭调度池(服务器停机 / 全局开关关闭时调用)。 */
|
||||
public void killAllLoops() {
|
||||
this.loops.values().forEach(LevelTickLoop::kill);
|
||||
this.loops.values().stream().filter(LevelTickLoop::isTicking).forEach(retiringLoops::add);
|
||||
this.loops.clear();
|
||||
// 清空连接路由:重新启用时 routeConnection 会把连接当作「首次」重新挂到新 loop。
|
||||
// 否则 old == level 会误判为 no-op,导致重启后玩家连接丢失、不再被任何 loop tick。
|
||||
@@ -289,8 +326,11 @@ public class ServerManager {
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
dev.modzuozhi.core.compat.ThreadOwnershipDiagnostics.clear();
|
||||
TpsTracker.reset();
|
||||
actives.clear();
|
||||
serverPauses.clear();
|
||||
retiringLoops.removeIf(loop -> !loop.isTicking());
|
||||
threadPools.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package dev.modzuozhi.mixin;
|
||||
|
||||
import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod;
|
||||
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
|
||||
import dev.modzuozhi.core.dimthread.DimThreadCore;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.neoforged.neoforge.server.ServerLifecycleHooks;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import java.util.Queue;
|
||||
|
||||
/** CC remains optional. Keep its identity check intact, but run it with exclusive ownership. */
|
||||
@Pseudo
|
||||
@Mixin(targets = "dan200.computercraft.shared.util.TickScheduler", remap = false)
|
||||
public abstract class ComputerCraftTickSchedulerMixin {
|
||||
@Shadow @Final private static Queue<?> toTick;
|
||||
@Unique private static final Object dimensionalripper$schedulerLock = new Object();
|
||||
|
||||
@WrapMethod(method = "tick()V")
|
||||
private static void dimensionalripper$safeTick(Operation<Void> original) {
|
||||
// No tokens -> no world access, no global barrier. New tokens can wait until next tick.
|
||||
if (toTick.isEmpty()) return;
|
||||
var server = ServerLifecycleHooks.getCurrentServer();
|
||||
if (server == null) throw new IllegalStateException("CC tick without a running server");
|
||||
DimThreadCore.MANAGER.runMainThreadPhase(server, "CC TickScheduler", () -> {
|
||||
synchronized (dimensionalripper$schedulerLock) { original.call(); }
|
||||
});
|
||||
}
|
||||
|
||||
// CC's delayed map is global across dimensions. These callbacks only manipulate tokens;
|
||||
// neither loads chunks nor waits on the main thread. Never take this lock before draining.
|
||||
@WrapMethod(method = "onChunkTicketChanged")
|
||||
private static void dimensionalripper$ticket(ServerLevel level, long pos, int oldTicket, int newTicket, Operation<Void> original) {
|
||||
synchronized (dimensionalripper$schedulerLock) { original.call(level, pos, oldTicket, newTicket); }
|
||||
}
|
||||
|
||||
@WrapMethod(method = "onChunkUnload")
|
||||
private static void dimensionalripper$unload(LevelChunk chunk, Operation<Void> original) {
|
||||
synchronized (dimensionalripper$schedulerLock) { original.call(chunk); }
|
||||
}
|
||||
}
|
||||
@@ -53,4 +53,14 @@ public abstract class LevelMixin {
|
||||
boolean ownerIsActive = DimThreadCore.owns(this.thread);
|
||||
return callerIsActive && ownerIsActive ? this.thread : t;
|
||||
}
|
||||
|
||||
@org.spongepowered.asm.mixin.injection.Inject(method = "getBlockEntity", at = @At("HEAD"))
|
||||
private void dimensionalripper$observeLookup(net.minecraft.core.BlockPos pos,
|
||||
org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable<net.minecraft.world.level.block.entity.BlockEntity> ci) {
|
||||
if (!((Object) this instanceof net.minecraft.server.level.ServerLevel level) || level.isOutsideBuildHeight(pos)) return;
|
||||
Thread caller = Thread.currentThread();
|
||||
Thread owner = this.thread;
|
||||
if (caller != owner && !((DimThreadCore.owns(caller) || FineGrainScheduler.inSubTask()) && DimThreadCore.owns(owner)))
|
||||
dev.modzuozhi.core.compat.ThreadOwnershipDiagnostics.rejectedLookup(level, pos, owner);
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ public abstract class MinecraftServerDimMixin {
|
||||
/** 服务器停止时:先等所有维度 tick 收口,再停 loop、关线程池,防止保存与 worker 并发卡死。 */
|
||||
@Inject(method = "stopServer", at = @At("HEAD"))
|
||||
public void modzuozhi_shutdownThreadpool(CallbackInfo ci) {
|
||||
dev.modzuozhi.core.compat.PayloadMainThreadDispatch.stop((MinecraftServer)(Object)this);
|
||||
// 必须先等正在执行的维度 tick 完全退出再进入保存:否则主线程 saveAllChunks 会与
|
||||
// 仍在飞的维度 worker(level.tick → processUnloads)并发修改 ChunkMap.toDrop
|
||||
// (非线程安全 fastutil 结构),导致「正在保存中」无限卡死(只能强杀进程)。
|
||||
@@ -90,4 +91,9 @@ public abstract class MinecraftServerDimMixin {
|
||||
DimThreadCore.MANAGER.threadPools.forEach((server, pool) -> pool.shutdown());
|
||||
DimThreadCore.MANAGER.clear();
|
||||
}
|
||||
|
||||
@Inject(method = "stopServer", at = @At("RETURN"))
|
||||
private void dimensionalripper$clearPayloads(CallbackInfo ci) {
|
||||
dev.modzuozhi.core.compat.PayloadMainThreadDispatch.clear((MinecraftServer)(Object)this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dev.modzuozhi.mixin;
|
||||
|
||||
import dev.modzuozhi.core.compat.PayloadMainThreadDispatch;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
@Mixin(MinecraftServer.class)
|
||||
public abstract class MinecraftServerPayloadMixin {
|
||||
@Inject(method = "tickServer", at = @At("HEAD"))
|
||||
private void dimensionalripper$drainPayloads(BooleanSupplier budget, CallbackInfo ci) {
|
||||
PayloadMainThreadDispatch.drain((MinecraftServer) (Object) this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package dev.modzuozhi.mixin;
|
||||
|
||||
import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod;
|
||||
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
|
||||
import dev.modzuozhi.core.compat.PayloadMainThreadDispatch;
|
||||
import net.minecraft.network.protocol.common.ServerCommonPacketListener;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||
import net.neoforged.neoforge.network.handling.ServerPayloadContext;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@Mixin(value = ServerPayloadContext.class, remap = false)
|
||||
public abstract class ServerPayloadContextMixin {
|
||||
@Shadow @Final private ServerCommonPacketListener listener;
|
||||
@Shadow @Final private ResourceLocation payloadId;
|
||||
|
||||
@WrapMethod(method = "enqueueWork(Ljava/lang/Runnable;)Ljava/util/concurrent/CompletableFuture;")
|
||||
private CompletableFuture<Void> dimensionalripper$runnable(Runnable task, Operation<CompletableFuture<Void>> original) {
|
||||
if (!(listener instanceof ServerGamePacketListenerImpl game)) return original.call(task);
|
||||
return dimensionalripper$submit(game, () -> { task.run(); return null; });
|
||||
}
|
||||
|
||||
@WrapMethod(method = "enqueueWork(Ljava/util/function/Supplier;)Ljava/util/concurrent/CompletableFuture;")
|
||||
private <T> CompletableFuture<T> dimensionalripper$supplier(Supplier<T> task, Operation<CompletableFuture<T>> original) {
|
||||
if (!(listener instanceof ServerGamePacketListenerImpl game)) return original.call(task);
|
||||
return dimensionalripper$submit(game, task);
|
||||
}
|
||||
|
||||
@Unique private <T> CompletableFuture<T> dimensionalripper$submit(ServerGamePacketListenerImpl game, Supplier<T> task) {
|
||||
if (game.player.connection != game)
|
||||
return CompletableFuture.failedFuture(new java.util.concurrent.CancellationException("Replaced payload listener"));
|
||||
boolean main = game.getMainThreadEventLoop().isSameThread();
|
||||
var result = PayloadMainThreadDispatch.submit(game.player, payloadId, task);
|
||||
return main ? result : PayloadMainThreadDispatch.guard(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package dev.modzuozhi.mixin;
|
||||
|
||||
import dev.modzuozhi.core.compat.ThreadOwnershipDiagnostics;
|
||||
import net.minecraft.SystemReport;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
@Mixin(MinecraftServer.class)
|
||||
public abstract class ThreadOwnershipReportMixin {
|
||||
@Inject(method = "fillSystemReport", at = @At("HEAD"))
|
||||
private void dimensionalripper$ownershipReport(SystemReport report, CallbackInfoReturnable<SystemReport> ci) {
|
||||
report.setDetail("DimensionalRipper Thread Ownership", () ->
|
||||
ThreadOwnershipDiagnostics.report((MinecraftServer) (Object) this));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@
|
||||
"ClassInstanceMultiMapMixin",
|
||||
"LithiumEntityGroupsMixin",
|
||||
"CollectingNeighborUpdaterMixin",
|
||||
"ComputerCraftTickSchedulerMixin",
|
||||
"ThreadOwnershipReportMixin",
|
||||
"DistanceManagerMixin",
|
||||
"EntityChangeDimensionMixin",
|
||||
"EntityGetEntityCollisionsMixin",
|
||||
@@ -38,6 +40,8 @@
|
||||
"LevelTicksMixin",
|
||||
"LivingEntityGetInBlockStateMixin",
|
||||
"MinecraftServerDimMixin",
|
||||
"MinecraftServerPayloadMixin",
|
||||
"ServerPayloadContextMixin",
|
||||
"MinecraftServerSaveMixin",
|
||||
"MinecraftServerTimeSyncAccessor",
|
||||
"NavigatingMobsMixin",
|
||||
|
||||
@@ -2,13 +2,11 @@ package dev.modzuozhi;
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import dev.modzuozhi.core.compat.SableAssemblyDispatch;
|
||||
import dev.modzuozhi.core.compat.SableMainThreadBridge;
|
||||
import dev.modzuozhi.core.compat.PayloadMainThreadDispatch;
|
||||
import dev.modzuozhi.core.dimthread.DimThreadCore;
|
||||
import dev.modzuozhi.core.dimthread.FineGrainScheduler;
|
||||
import dev.modzuozhi.core.dimthread.FaultGuard;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ClientInformation;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.material.Fluids;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
@@ -41,14 +39,14 @@ public final class InteractionFlowRegression {
|
||||
level.setBlockAndUpdate(new BlockPos(x, 100, z), Blocks.AIR.defaultBlockState());
|
||||
}
|
||||
level.setBlockAndUpdate(SOURCE.above(3), Blocks.CHEST.defaultBlockState());
|
||||
var player = new ServerPlayer(server, level, new GameProfile(UUID.randomUUID(), "Regression"),
|
||||
ClientInformation.createDefault()) {
|
||||
@Override public boolean hasDisconnected() { return false; }
|
||||
};
|
||||
// A real listener is now part of the shared dispatch's stale-connection contract.
|
||||
var player = new net.neoforged.neoforge.common.util.FakePlayer(level,
|
||||
new GameProfile(UUID.randomUUID(), "Regression"));
|
||||
startTime = level.getGameTime();
|
||||
DimThreadCore.MANAGER.setPaused(server, false);
|
||||
SableAssemblyDispatch.dispatch(player, () -> {
|
||||
if (!server.isSameThread() || !SableMainThreadBridge.ownsHandoff(level))
|
||||
if (!server.isSameThread() || !PayloadMainThreadDispatch.owns(server)
|
||||
|| DimThreadCore.MANAGER.ownershipLoopsSnapshot().stream().anyMatch(loop -> loop.isTicking()))
|
||||
throw new AssertionError("Interaction did not obtain exclusive server ownership");
|
||||
if (level.getBlockEntity(SOURCE.above(3)) == null)
|
||||
throw new AssertionError("Main-thread block entity lookup silently failed");
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package dev.modzuozhi;
|
||||
|
||||
import dev.modzuozhi.core.dimthread.DimThreadCore;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Local opt-in fixture. Removing the CC safe phase must fail the real identity check. */
|
||||
@EventBusSubscriber(modid = "dimensionalripper")
|
||||
public final class OwnershipRegression {
|
||||
private static boolean ran;
|
||||
private static boolean verified;
|
||||
private static long worldTime;
|
||||
@SubscribeEvent
|
||||
public static void tick(ServerTickEvent.Post event) throws Exception {
|
||||
var server = event.getServer();
|
||||
if (verified && server.getTickCount() >= 80) {
|
||||
DimThreadCore.MANAGER.setPaused(server, true);
|
||||
if (server.overworld().getGameTime() <= worldTime) throw new AssertionError("Parallel worlds did not resume");
|
||||
ModZuozhi.LOGGER.info("OWNERSHIP RESUME PASS: world continued ticking after compatibility phases");
|
||||
verified = false;
|
||||
server.halt(false);
|
||||
}
|
||||
if (ran || server.getTickCount() < 20) return;
|
||||
ran = true;
|
||||
var level = server.overworld();
|
||||
var manager = DimThreadCore.MANAGER;
|
||||
var release = new CountDownLatch(1);
|
||||
float originalRate = server.tickRateManager().tickrate();
|
||||
try {
|
||||
manager.setPaused(server, true);
|
||||
level.setChunkForced(0, 0, true);
|
||||
level.getChunk(0, 0);
|
||||
var pos = new BlockPos(8, 110, 8);
|
||||
var block = BuiltInRegistries.BLOCK.get(ResourceLocation.parse("computercraft:monitor_normal"));
|
||||
level.setBlockAndUpdate(pos, block.defaultBlockState());
|
||||
var monitor = level.getBlockEntity(pos);
|
||||
if (monitor == null) throw new AssertionError("Monitor fixture not installed");
|
||||
var scheduler = Class.forName("dan200.computercraft.shared.util.TickScheduler");
|
||||
var tokenClass = Class.forName("dan200.computercraft.shared.util.TickScheduler$Token");
|
||||
var token = tokenClass.getConstructor(net.minecraft.world.level.block.entity.BlockEntity.class).newInstance(monitor);
|
||||
scheduler.getMethod("schedule", tokenClass).invoke(null, token);
|
||||
var entered = new CountDownLatch(1);
|
||||
var bridgeFailure = new java.util.concurrent.atomic.AtomicReference<Throwable>();
|
||||
manager.getLoop(level).execute(() -> {
|
||||
// A Sable-like main-thread dependency: safe waiting must pump this callback.
|
||||
server.execute(release::countDown);
|
||||
entered.countDown();
|
||||
try {
|
||||
if (!release.await(10, TimeUnit.SECONDS)) throw new AssertionError("Main callback was not pumped");
|
||||
dev.modzuozhi.core.compat.SableMainThreadBridge.runForLevel(level, () -> {
|
||||
if (!server.isSameThread() || level.getBlockEntity(pos) != monitor)
|
||||
throw new AssertionError("Bridge did not obtain main-thread world ownership");
|
||||
});
|
||||
} catch (Throwable e) { bridgeFailure.set(e); }
|
||||
});
|
||||
// Amplify idle polling waste: stopping an idle poll must not wait a 1-second tick budget.
|
||||
server.tickRateManager().setTickRate(1.0F);
|
||||
manager.setPaused(server, false);
|
||||
if (!entered.await(10, TimeUnit.SECONDS)) throw new AssertionError("Worker did not start");
|
||||
if (level.getBlockEntity(pos) != null) throw new AssertionError("Did not reproduce guarded main-thread lookup");
|
||||
for (int i = 0; i < 1000; i++) level.getBlockEntity(pos);
|
||||
long start = System.nanoTime();
|
||||
scheduler.getMethod("tick").invoke(null);
|
||||
server.tickRateManager().setTickRate(originalRate);
|
||||
if (System.nanoTime() - start > TimeUnit.MILLISECONDS.toNanos(750))
|
||||
throw new AssertionError("Safe phase waited out idle polling budget after pause request");
|
||||
if (bridgeFailure.get() != null) throw new AssertionError("Sable bridge failed", bridgeFailure.get());
|
||||
ModZuozhi.LOGGER.info("CC PHASE MEASUREMENT: {}", dev.modzuozhi.core.compat.ThreadOwnershipDiagnostics.timing());
|
||||
manager.setPaused(server, true);
|
||||
if (level.getBlockEntity(pos) != monitor) throw new AssertionError("Monitor identity changed");
|
||||
var state = tokenClass.getDeclaredField("$state");
|
||||
state.setAccessible(true);
|
||||
if (!state.get(token).toString().equals("IDLE")) throw new AssertionError("CC token was not completed");
|
||||
// The same scope must preserve an existing pause, even when nested or throwing.
|
||||
var expected = new IllegalArgumentException("regression sentinel");
|
||||
try {
|
||||
manager.runMainThreadPhase(server, "outer-test", () -> manager.runMainThreadPhase(server, "inner-test", () -> { throw expected; }));
|
||||
throw new AssertionError("Phase swallowed exception");
|
||||
} catch (IllegalArgumentException failure) {
|
||||
if (failure != expected) throw failure;
|
||||
}
|
||||
if (!manager.getLoop(level).isPaused()) throw new AssertionError("Existing pause was lost");
|
||||
manager.setPaused(server, false);
|
||||
try {
|
||||
manager.runMainThreadPhase(server, "exception-resume-test", () -> { throw expected; });
|
||||
throw new AssertionError("Running phase swallowed exception");
|
||||
} catch (IllegalArgumentException failure) {
|
||||
if (failure != expected) throw failure;
|
||||
}
|
||||
if (manager.getLoop(level).isPaused()) throw new AssertionError("Failed phase did not resume running world");
|
||||
manager.setPaused(server, true);
|
||||
// Removed owners are valid stale tokens. Real identity mismatches must still throw.
|
||||
var removed = net.minecraft.world.level.block.entity.BlockEntityType.CHEST.create(pos, net.minecraft.world.level.block.Blocks.CHEST.defaultBlockState());
|
||||
removed.setLevel(level);
|
||||
var removedToken = tokenClass.getConstructor(net.minecraft.world.level.block.entity.BlockEntity.class).newInstance(removed);
|
||||
scheduler.getMethod("schedule", tokenClass).invoke(null, removedToken);
|
||||
removed.setRemoved();
|
||||
scheduler.getMethod("tick").invoke(null);
|
||||
if (!state.get(removedToken).toString().equals("IDLE")) throw new AssertionError("Removed token not discarded");
|
||||
removed.clearRemoved();
|
||||
scheduler.getMethod("schedule", tokenClass).invoke(null, removedToken);
|
||||
try {
|
||||
scheduler.getMethod("tick").invoke(null);
|
||||
throw new AssertionError("Real mismatch was hidden");
|
||||
} catch (java.lang.reflect.InvocationTargetException failure) {
|
||||
if (!(failure.getCause() instanceof IllegalStateException)) throw failure;
|
||||
}
|
||||
var far = new BlockPos(1600000, 100, 1600000);
|
||||
var unloaded = net.minecraft.world.level.block.entity.BlockEntityType.CHEST.create(far, net.minecraft.world.level.block.Blocks.CHEST.defaultBlockState());
|
||||
unloaded.setLevel(level);
|
||||
var unloadedToken = tokenClass.getConstructor(net.minecraft.world.level.block.entity.BlockEntity.class).newInstance(unloaded);
|
||||
scheduler.getMethod("schedule", tokenClass).invoke(null, unloadedToken);
|
||||
int chunks = level.getChunkSource().getLoadedChunksCount();
|
||||
scheduler.getMethod("tick").invoke(null);
|
||||
if (!state.get(unloadedToken).toString().equals("UNLOADED") || chunks != level.getChunkSource().getLoadedChunksCount())
|
||||
throw new AssertionError("Unloaded token forced chunk loading or was dropped");
|
||||
scheduler.getMethod("onChunkUnload", net.minecraft.world.level.chunk.LevelChunk.class).invoke(null,
|
||||
new net.minecraft.world.level.chunk.LevelChunk(level, new net.minecraft.world.level.ChunkPos(far)));
|
||||
if (!state.get(unloadedToken).toString().equals("IDLE")) throw new AssertionError("Unload callback lost delayed token");
|
||||
scheduler.getMethod("schedule", tokenClass).invoke(null, unloadedToken);
|
||||
scheduler.getMethod("tick").invoke(null);
|
||||
// Ticket promotion requeues a delayed token; unload discards it without masking errors.
|
||||
scheduler.getMethod("onChunkTicketChanged", net.minecraft.server.level.ServerLevel.class, long.class, int.class, int.class)
|
||||
.invoke(null, level, net.minecraft.world.level.ChunkPos.asLong(far), 40, 31);
|
||||
if (!state.get(unloadedToken).toString().equals("SCHEDULED")) throw new AssertionError("Ticket callback lost token");
|
||||
unloaded.setRemoved();
|
||||
scheduler.getMethod("tick").invoke(null);
|
||||
var report = server.fillSystemReport(new net.minecraft.SystemReport()).toLineSeparatedString();
|
||||
if (!report.contains("DimensionalRipper Thread Ownership") || !report.contains("observedOwner=")
|
||||
|| !report.contains("8, 110, 8") || !report.contains("rejected=1001"))
|
||||
throw new AssertionError("Crash report lacks ownership evidence: " + report);
|
||||
if (!report.contains("handoff=completed-owner-restored")) throw new AssertionError("Bridge ownership history missing");
|
||||
concurrentCallbacks(server, scheduler, tokenClass, state);
|
||||
ModZuozhi.LOGGER.info("OWNERSHIP EDGE PASS: nested/exception pauses, removed/mismatched/unloaded tokens, ticket callback, crash diagnostics");
|
||||
ModZuozhi.LOGGER.info("OWNERSHIP REGRESSION PASS: real CC monitor identity, callback pumping, scheduled tick; elapsed={}ms", (System.nanoTime()-start)/1_000_000.0);
|
||||
worldTime = level.getGameTime();
|
||||
level.setBlockAndUpdate(pos, net.minecraft.world.level.block.Blocks.AIR.defaultBlockState());
|
||||
verified = true;
|
||||
} finally {
|
||||
server.tickRateManager().setTickRate(originalRate);
|
||||
release.countDown();
|
||||
manager.setPaused(server, !verified);
|
||||
if (!verified) server.halt(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static void concurrentCallbacks(net.minecraft.server.MinecraftServer server, Class<?> scheduler,
|
||||
Class<?> tokenClass, java.lang.reflect.Field state) throws Exception {
|
||||
var levels = java.util.List.of(server.overworld(), server.getLevel(net.minecraft.world.level.Level.NETHER));
|
||||
var tokens = new Object[2][40];
|
||||
var owners = new net.minecraft.world.level.block.entity.BlockEntity[2][40];
|
||||
for (int d = 0; d < 2; d++) for (int i = 0; i < 40; i++) {
|
||||
var pos = new BlockPos(1600000 + i * 16, 100, 1600000);
|
||||
var owner = net.minecraft.world.level.block.entity.BlockEntityType.CHEST.create(pos, net.minecraft.world.level.block.Blocks.CHEST.defaultBlockState());
|
||||
owner.setLevel(levels.get(d));
|
||||
owners[d][i] = owner;
|
||||
tokens[d][i] = tokenClass.getConstructor(net.minecraft.world.level.block.entity.BlockEntity.class).newInstance(owner);
|
||||
scheduler.getMethod("schedule", tokenClass).invoke(null, tokens[d][i]);
|
||||
}
|
||||
scheduler.getMethod("tick").invoke(null); // all 80 are deferred, no chunks are loaded
|
||||
var gate = new CountDownLatch(2);
|
||||
var done = new CountDownLatch(2);
|
||||
var failure = new java.util.concurrent.atomic.AtomicReference<Throwable>();
|
||||
for (int d = 0; d < 2; d++) {
|
||||
final int dimension = d;
|
||||
var level = levels.get(d);
|
||||
DimThreadCore.MANAGER.getLoop(level).execute(() -> {
|
||||
try {
|
||||
gate.countDown();
|
||||
if (!gate.await(10, TimeUnit.SECONDS)) throw new AssertionError("Dimension callback overlap not reached");
|
||||
for (int i = 0; i < 40; i++) {
|
||||
var pos = owners[dimension][i].getBlockPos();
|
||||
if (i % 2 == 0) scheduler.getMethod("onChunkUnload", net.minecraft.world.level.chunk.LevelChunk.class)
|
||||
.invoke(null, new net.minecraft.world.level.chunk.LevelChunk(level, new net.minecraft.world.level.ChunkPos(pos)));
|
||||
else scheduler.getMethod("onChunkTicketChanged", net.minecraft.server.level.ServerLevel.class, long.class, int.class, int.class)
|
||||
.invoke(null, level, net.minecraft.world.level.ChunkPos.asLong(pos), 40, 31);
|
||||
}
|
||||
} catch (Throwable error) { failure.set(error); }
|
||||
finally { done.countDown(); }
|
||||
});
|
||||
}
|
||||
DimThreadCore.MANAGER.setPaused(server, false);
|
||||
server.managedBlock(() -> done.getCount() == 0);
|
||||
DimThreadCore.MANAGER.setPaused(server, true);
|
||||
if (failure.get() != null) throw new AssertionError("Concurrent callbacks failed", failure.get());
|
||||
for (int d = 0; d < 2; d++) for (int i = 0; i < 40; i++) {
|
||||
String expected = i % 2 == 0 ? "IDLE" : "SCHEDULED";
|
||||
if (!state.get(tokens[d][i]).toString().equals(expected)) throw new AssertionError("Delayed token lost across dimensions");
|
||||
owners[d][i].setRemoved();
|
||||
}
|
||||
scheduler.getMethod("tick").invoke(null);
|
||||
ModZuozhi.LOGGER.info("OWNERSHIP CALLBACK PASS: 80 deferred tokens, concurrent overworld/nether ticket/unload callbacks");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package dev.modzuozhi;
|
||||
|
||||
import net.minecraft.core.*;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.phys.*;
|
||||
import net.neoforged.neoforge.network.handling.ServerPayloadContext;
|
||||
|
||||
/** Actual optional mod handlers, invoked through the shared context. No production dependencies. */
|
||||
public final class ModInteractionRegression {
|
||||
public static void run(MinecraftServer server,ServerPayloadContext ctx) throws Exception {
|
||||
if(!net.neoforged.fml.ModList.get().isLoaded("create"))return;
|
||||
var level=server.overworld();var pos=new BlockPos(12,115,8);var player=ctx.player();
|
||||
player.setPos(12,114,8);
|
||||
var block=BuiltInRegistries.BLOCK.get(ResourceLocation.parse("create:rotation_speed_controller"));
|
||||
level.setBlockAndUpdate(pos,block.defaultBlockState());var be=level.getBlockEntity(pos);
|
||||
PayloadRegression.check(be!=null,"Create speed controller fixture");
|
||||
var speed=be.getClass().getField("targetSpeed").get(be);
|
||||
var valueType=Class.forName("com.simibubi.create.foundation.blockEntity.behaviour.ValueSettingsBehaviour");
|
||||
int netId=(int)SimulatedHandleRegression.call(valueType,speed,"netId",int.class,new Class<?>[]{});
|
||||
var packetType=Class.forName("com.simibubi.create.foundation.blockEntity.behaviour.ValueSettingsPacket");
|
||||
var hit=new BlockHitResult(Vec3.atCenterOf(pos),Direction.UP,pos,false);
|
||||
var packet=packetType.getConstructor(BlockPos.class,int.class,int.class,net.minecraft.world.InteractionHand.class,
|
||||
BlockHitResult.class,Direction.class,boolean.class,int.class).newInstance(pos,0,32,null,hit,Direction.UP,false,netId);
|
||||
var packetBase=Class.forName("com.simibubi.create.foundation.networking.BlockEntityConfigurationPacket");
|
||||
var speedType=Class.forName("com.simibubi.create.foundation.blockEntity.behaviour.scrollValue.ScrollValueBehaviour");
|
||||
try {
|
||||
ctx.enqueueWork(() -> {
|
||||
try {SimulatedHandleRegression.call(packetBase,packet,"handle",void.class,new Class<?>[]{ServerPlayer.class},player);}
|
||||
catch(Exception e){throw new RuntimeException(e);}
|
||||
}).join();
|
||||
int configured=(int)SimulatedHandleRegression.call(speedType,speed,"getValue",int.class,new Class<?>[]{});
|
||||
PayloadRegression.check(Math.abs(configured)==32,"actual Create speed packet failed: "+configured);
|
||||
player.setPos(10000,115,10000);
|
||||
var denied=packetType.getConstructor(BlockPos.class,int.class,int.class,net.minecraft.world.InteractionHand.class,
|
||||
BlockHitResult.class,Direction.class,boolean.class,int.class).newInstance(pos,0,64,null,hit,Direction.UP,false,netId);
|
||||
ctx.enqueueWork(() -> {
|
||||
try {SimulatedHandleRegression.call(packetBase,denied,"handle",void.class,new Class<?>[]{ServerPlayer.class},player);}
|
||||
catch(Exception e){throw new RuntimeException(e);}
|
||||
}).join();
|
||||
PayloadRegression.check((int)SimulatedHandleRegression.call(speedType,speed,"getValue",int.class,new Class<?>[]{})==configured,"Create distance check bypassed");
|
||||
ModZuozhi.LOGGER.info("CREATE CONFIG PASS: real speed packet and original distance denial");
|
||||
player.setPos(12,114,8);
|
||||
if(net.neoforged.fml.ModList.get().isLoaded("jade"))jade(ctx,pos,be,hit);
|
||||
} finally {level.setBlockAndUpdate(pos,net.minecraft.world.level.block.Blocks.AIR.defaultBlockState());}
|
||||
}
|
||||
static void jade(ServerPayloadContext ctx,BlockPos pos,Object be,BlockHitResult hit) throws Exception {
|
||||
var dataType=Class.forName("snownee.jade.impl.BlockAccessorImpl$SyncData");
|
||||
var data=dataType.getConstructor(boolean.class,BlockHitResult.class,net.minecraft.world.level.block.state.BlockState.class,net.minecraft.world.item.ItemStack.class)
|
||||
.newInstance(false,hit,ctx.player().level().getBlockState(pos),net.minecraft.world.item.ItemStack.EMPTY);
|
||||
var requestType=Class.forName("snownee.jade.network.RequestBlockPacket");
|
||||
var request=requestType.getConstructor(dataType,java.util.List.class).newInstance(data,java.util.List.of());
|
||||
var jadeContext=Class.forName("snownee.jade.network.ServerPayloadContext");
|
||||
var proxy=java.lang.reflect.Proxy.newProxyInstance(jadeContext.getClassLoader(),new Class<?>[]{jadeContext},(self,method,args) -> {
|
||||
if(method.getName().equals("player"))return ctx.player();
|
||||
return java.lang.reflect.InvocationHandler.invokeDefault(self,method,args);
|
||||
});
|
||||
var accessorType=Class.forName("snownee.jade.api.BlockAccessor");
|
||||
var accessorImpl=Class.forName("snownee.jade.impl.BlockAccessorImpl");
|
||||
var seen=new java.util.concurrent.atomic.AtomicInteger();
|
||||
java.util.function.Consumer<net.minecraft.nbt.CompoundTag> consumer=tag -> {
|
||||
PayloadRegression.check(tag.getInt("x")==pos.getX(),"Jade response coordinate");seen.incrementAndGet();
|
||||
};
|
||||
Runnable query=() -> {
|
||||
try {
|
||||
var accessor=SimulatedHandleRegression.call(dataType,data,"unpack",accessorType,new Class<?>[]{ServerPlayer.class},ctx.player());
|
||||
PayloadRegression.check(SimulatedHandleRegression.call(accessorType,accessor,"getBlockEntity",net.minecraft.world.level.block.entity.BlockEntity.class,new Class<?>[]{})==be,"Jade BE identity");
|
||||
SimulatedHandleRegression.call(accessorImpl,null,"handleRequest",void.class,new Class<?>[]{requestType,jadeContext,java.util.function.Consumer.class},request,proxy,consumer);
|
||||
} catch(Exception e){throw new RuntimeException(e);}
|
||||
};
|
||||
ctx.enqueueWork(query).join();PayloadRegression.check(seen.get()==1,"Jade query callback missing");
|
||||
ctx.player().setPos(10000,115,10000);ctx.enqueueWork(query).join();
|
||||
PayloadRegression.check(seen.get()==1,"Jade range guard bypassed");
|
||||
ModZuozhi.LOGGER.info("JADE REQUEST PASS: real lookup/execute/query and original range denial");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package dev.modzuozhi;
|
||||
|
||||
import dev.modzuozhi.core.dimthread.DimThreadCore;
|
||||
import dev.modzuozhi.core.compat.SableMainThreadBridge;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.*;
|
||||
|
||||
@EventBusSubscriber(modid="dimensionalripper")
|
||||
public final class PayloadRegression {
|
||||
static boolean ran;
|
||||
static void check(boolean value, String reason) { if(!value) throw new AssertionError(reason); }
|
||||
@SubscribeEvent
|
||||
public static void tick(ServerTickEvent.Post event) throws Exception {
|
||||
var server=event.getServer();
|
||||
if(ran || server.getTickCount()<20) return;
|
||||
ran=true;
|
||||
if(Boolean.getBoolean("dimensionalripper.reviewRegression")) {
|
||||
try {ReviewRegression.run(server);} finally {DimThreadCore.MANAGER.setPaused(server,true);server.halt(false);}
|
||||
return;
|
||||
}
|
||||
var manager=DimThreadCore.MANAGER; var level=server.overworld();
|
||||
var release=new CountDownLatch(1); var entered=new CountDownLatch(1);
|
||||
var failure=new AtomicReference<Throwable>();
|
||||
try {
|
||||
String idleStats=dev.modzuozhi.core.compat.PayloadDiagnostics.snapshot(server);
|
||||
for(int i=0;i<100;i++) dev.modzuozhi.core.compat.PayloadMainThreadDispatch.drain(server);
|
||||
check(idleStats.equals(dev.modzuozhi.core.compat.PayloadDiagnostics.snapshot(server)),"empty drain created payload barrier/statistics");
|
||||
ModZuozhi.LOGGER.info("PAYLOAD IDLE PASS: 100 empty drains created no payload batches");
|
||||
manager.setPaused(server,true); level.setChunkForced(0,0,true); level.getChunk(0,0);
|
||||
var pos=new BlockPos(8,115,8); level.setBlockAndUpdate(pos,Blocks.CHEST.defaultBlockState());
|
||||
var chest=level.getBlockEntity(pos); check(chest!=null,"fixture chest");
|
||||
manager.getLoop(level).execute(() -> {
|
||||
server.execute(release::countDown); entered.countDown();
|
||||
try {
|
||||
check(release.await(5,TimeUnit.SECONDS),"callback not pumped");
|
||||
SableMainThreadBridge.runForLevel(level, () -> check(level.getBlockEntity(pos)==chest,"handoff lookup"));
|
||||
} catch(Throwable e) { failure.set(e); }
|
||||
});
|
||||
manager.setPaused(server,false); check(entered.await(5,TimeUnit.SECONDS),"worker start");
|
||||
check(level.getBlockEntity(pos)==null,"must reproduce guarded null before phase");
|
||||
manager.runMainThreadPhase(server,"payload-test",() -> {
|
||||
check(server.isSameThread() && level.getBlockEntity(pos)==chest,"exclusive lookup");
|
||||
manager.runMainThreadPhase(server,"nested",() -> check(level.getBlockEntity(pos)==chest,"nested lookup"));
|
||||
// Regression: a normal resume during exclusive handling must not drop its lease.
|
||||
manager.setPaused(server,false);
|
||||
manager.getLoop(level).scheduleIfNeeded();
|
||||
try { Thread.sleep(100); } catch(InterruptedException e) { throw new RuntimeException(e); }
|
||||
check(!manager.getLoop(level).isTicking() && level.getBlockEntity(pos)==chest,"menu resume broke exclusive lease");
|
||||
});
|
||||
check(failure.get()==null,"Sable callback failed: "+failure.get());
|
||||
manager.setPaused(server,true);
|
||||
manager.runMainThreadPhase(server,"pre-paused",() -> {});
|
||||
check(manager.getLoop(level).isPaused(),"pre-pause lost");
|
||||
var expected=new IllegalStateException("sentinel");
|
||||
try { manager.runMainThreadPhase(server,"failure",() -> {throw expected;}); throw new AssertionError("swallowed"); }
|
||||
catch(IllegalStateException e) {check(e==expected,"exception identity");}
|
||||
check(manager.getLoop(level).isPaused(),"exception lost pre-pause");
|
||||
ModZuozhi.LOGGER.info("PAYLOAD PHASE PASS");
|
||||
entry(server, pos, chest);
|
||||
} finally { release.countDown(); manager.setPaused(server,true); server.halt(false); }
|
||||
}
|
||||
static void entry(net.minecraft.server.MinecraftServer server, BlockPos pos,
|
||||
net.minecraft.world.level.block.entity.BlockEntity chest) throws Exception {
|
||||
var manager=DimThreadCore.MANAGER; var level=server.overworld();
|
||||
var player=new net.neoforged.neoforge.common.util.FakePlayer(level,
|
||||
new com.mojang.authlib.GameProfile(java.util.UUID.randomUUID(), "PayloadTest"));
|
||||
var ctx=new net.neoforged.neoforge.network.handling.ServerPayloadContext(player.connection,
|
||||
net.minecraft.resources.ResourceLocation.parse("dimensionalripper:test"));
|
||||
var entered=new CountDownLatch(1); var release=new CountDownLatch(1);
|
||||
manager.getLoop(level).execute(() -> {
|
||||
server.execute(release::countDown); entered.countDown();
|
||||
try { check(release.await(5,TimeUnit.SECONDS),"entry callback pump"); }
|
||||
catch(InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); }
|
||||
});
|
||||
manager.setPaused(server,false); check(entered.await(5,TimeUnit.SECONDS),"entry worker start");
|
||||
try {
|
||||
var future=ctx.enqueueWork(() -> {
|
||||
check(server.isSameThread(),"payload ran off main");
|
||||
check(level.getBlockEntity(pos)==chest,"public payload entry returned guarded null");
|
||||
return ctx.enqueueWork(() -> 42).join();
|
||||
});
|
||||
check(future.join()==42,"nested supplier result");
|
||||
var expected=new IllegalArgumentException("payload sentinel");
|
||||
try { ctx.enqueueWork((Runnable) () -> { throw expected; }); throw new AssertionError("main exception hidden"); }
|
||||
catch(IllegalArgumentException e) { check(e==expected,"main exception identity"); }
|
||||
ModZuozhi.LOGGER.info("PAYLOAD ENTRY PASS");
|
||||
asyncEntry(server,ctx,player);
|
||||
manager.setPaused(server,false);
|
||||
var assembled=new AtomicInteger();
|
||||
dev.modzuozhi.core.compat.SableAssemblyDispatch.dispatch(player,() -> {
|
||||
check(server.isSameThread(),"legacy assembly not main");
|
||||
check(manager.loopsSnapshot().values().stream().noneMatch(loop -> loop.isTicking()),"legacy assembly lacks global ownership");
|
||||
assembled.incrementAndGet();
|
||||
});
|
||||
check(assembled.get()==1,"legacy dispatch did not use common phase");
|
||||
manager.setPaused(server,true);
|
||||
ModInteractionRegression.run(server,ctx);
|
||||
SimulatedHandleRegression.run(server,ctx);
|
||||
var lifecyclePlayer=new net.neoforged.neoforge.common.util.FakePlayer(level,
|
||||
new com.mojang.authlib.GameProfile(java.util.UUID.randomUUID(),"LifecycleTest"));
|
||||
lifecycle(server,new net.neoforged.neoforge.network.handling.ServerPayloadContext(lifecyclePlayer.connection,ctx.payloadId()),lifecyclePlayer);
|
||||
} finally { release.countDown(); manager.setPaused(server,true); }
|
||||
}
|
||||
static void asyncEntry(net.minecraft.server.MinecraftServer server,
|
||||
net.neoforged.neoforge.network.handling.ServerPayloadContext ctx,
|
||||
net.minecraft.server.level.ServerPlayer player) throws Exception {
|
||||
var seen=new java.util.ArrayList<Integer>();
|
||||
var fs=new java.util.ArrayList<CompletableFuture<Integer>>();
|
||||
Thread producer=new Thread(() -> {
|
||||
for(int i=0;i<100;i++) {int n=i; fs.add(ctx.enqueueWork(() -> {check(server.isSameThread(),"async handler main");seen.add(n);return n;}));}
|
||||
},"payload-network-fixture");
|
||||
producer.start(); producer.join(5000); check(!producer.isAlive(),"network submit blocks");
|
||||
check(seen.isEmpty(),"off-thread handler executed inline");
|
||||
while(fs.stream().anyMatch(f -> !f.isDone())) dev.modzuozhi.core.compat.PayloadMainThreadDispatch.drain(server);
|
||||
check(seen.size()==100,"async handler count");
|
||||
for(int i=0;i<100;i++) check(seen.get(i)==i && fs.get(i).join()==i,"async FIFO/result");
|
||||
var cancelled=new AtomicReference<CompletableFuture<Integer>>();var changed=new AtomicInteger();
|
||||
Thread cancelProducer=new Thread(() -> cancelled.set(ctx.enqueueWork(changed::incrementAndGet)));
|
||||
cancelProducer.start();cancelProducer.join(5000);cancelled.get().cancel(false);
|
||||
dev.modzuozhi.core.compat.PayloadMainThreadDispatch.drain(server);
|
||||
check(changed.get()==0,"cancelled public future still mutated world");
|
||||
var configListener=new net.minecraft.server.network.ServerConfigurationPacketListenerImpl(server,
|
||||
new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND),
|
||||
net.minecraft.server.network.CommonListenerCookie.createInitial(player.getGameProfile(),false));
|
||||
var configCtx=new net.neoforged.neoforge.network.handling.ServerPayloadContext(configListener,ctx.payloadId());
|
||||
check(configCtx.enqueueWork(() -> 17).join()==17,"configuration phase not preserved");
|
||||
var guarded=new AtomicReference<CompletableFuture<Integer>>();
|
||||
Thread failing=new Thread(() -> guarded.set(ctx.enqueueWork(() -> {throw new IllegalArgumentException("expected test failure");})));
|
||||
failing.start();failing.join(5000);dev.modzuozhi.core.compat.PayloadMainThreadDispatch.drain(server);
|
||||
check(guarded.get().join()==null,"NeoForge async handler failure contract changed");
|
||||
var raw=new AtomicReference<CompletableFuture<Integer>>();
|
||||
Thread stale=new Thread(() -> raw.set(dev.modzuozhi.core.compat.PayloadMainThreadDispatch.submit(player,ctx.payloadId(),() -> {throw new AssertionError("stale handler executed");})));
|
||||
stale.start();stale.join(5000);
|
||||
var old=player.serverLevel(); player.setServerLevel(server.getLevel(net.minecraft.world.level.Level.NETHER));
|
||||
dev.modzuozhi.core.compat.PayloadMainThreadDispatch.drain(server);
|
||||
check(raw.get().isCompletedExceptionally(),"stale future pending"); player.setServerLevel(old);
|
||||
check(dev.modzuozhi.core.compat.PayloadDiagnostics.snapshot(server).contains("expired=1"),"stale work not counted separately");
|
||||
var nested=new AtomicReference<Throwable>(); var entered=new CountDownLatch(1);
|
||||
var manager=DimThreadCore.MANAGER;
|
||||
manager.setPaused(server,true);
|
||||
manager.getLoop(old).execute(() -> {
|
||||
entered.countDown();
|
||||
SableMainThreadBridge.runForLevel(old,() -> {
|
||||
try {ctx.enqueueWork(() -> 1); nested.set(new AssertionError("unsafe handoff reentry accepted"));}
|
||||
catch(IllegalStateException expected) {}
|
||||
});
|
||||
});
|
||||
manager.setPaused(server,false);check(entered.await(5,TimeUnit.SECONDS),"handoff fixture start");
|
||||
manager.runMainThreadPhase(server,"handoff-test",() -> {});
|
||||
check(nested.get()==null,"handoff reentry result");
|
||||
manager.setPaused(server,true);
|
||||
var blockedFuture=new AtomicReference<CompletableFuture<Integer>>();var waitDetected=new AtomicBoolean();
|
||||
var waiting=new CountDownLatch(1);
|
||||
manager.getLoop(old).execute(() -> {
|
||||
CompletableFuture<Integer> f=dev.modzuozhi.core.compat.PayloadMainThreadDispatch.submit(player,ctx.payloadId(),() -> {throw new AssertionError("cancelled worker wait action ran");});
|
||||
blockedFuture.set(f);waiting.countDown();
|
||||
try { f.get(250,TimeUnit.MILLISECONDS); }
|
||||
catch(TimeoutException expected) {waitDetected.set(true);f.cancel(false);}
|
||||
catch(Exception e) {throw new RuntimeException(e);}
|
||||
});
|
||||
manager.setPaused(server,false);check(waiting.await(5,TimeUnit.SECONDS),"worker wait fixture start");
|
||||
manager.runMainThreadPhase(server,"unsupported-worker-wait",() -> {});
|
||||
check(waitDetected.get() && blockedFuture.get().isCancelled(),"worker blocking cycle not detected");
|
||||
manager.setPaused(server,true);
|
||||
ModZuozhi.LOGGER.info("PAYLOAD ASYNC PASS");
|
||||
}
|
||||
static void lifecycle(net.minecraft.server.MinecraftServer server,
|
||||
net.neoforged.neoforge.network.handling.ServerPayloadContext ctx,
|
||||
net.minecraft.server.level.ServerPlayer player) throws Exception {
|
||||
var manager=DimThreadCore.MANAGER; var other=server.getLevel(net.minecraft.world.level.Level.NETHER);
|
||||
manager.setPaused(server,true); manager.removeLoop(other);
|
||||
manager.runMainThreadPhase(server,"new-loop",() -> {
|
||||
var created=manager.getLoop(other);created.scheduleIfNeeded();
|
||||
check(created.isPaused() && !created.isTicking(),"new loop escaped phase");
|
||||
});
|
||||
manager.setPaused(server,true);
|
||||
// Bounded ID labels, not an unbounded record for every packet.
|
||||
for(int i=0;i<1000;i++) {
|
||||
dev.modzuozhi.core.compat.PayloadMainThreadDispatch.submit(player,
|
||||
net.minecraft.resources.ResourceLocation.parse("dimensionalripper:test_"+i),() -> 1).join();
|
||||
}
|
||||
String stats=dev.modzuozhi.core.compat.PayloadDiagnostics.snapshot(server);
|
||||
check(stats.contains("ids=128") && stats.contains("other="),"diagnostics ID cap: "+stats);
|
||||
check(stats.contains("pending=0"),"diagnostic pending count: "+stats);
|
||||
var pending=new AtomicReference<CompletableFuture<Integer>>();
|
||||
Thread t=new Thread(() -> pending.set(dev.modzuozhi.core.compat.PayloadMainThreadDispatch.submit(player,ctx.payloadId(),() -> {throw new AssertionError("work after stop");})));
|
||||
t.start();t.join(5000);
|
||||
manager.runMainThreadPhase(server,"stop-inside-phase",() -> dev.modzuozhi.core.compat.PayloadMainThreadDispatch.stop(server));
|
||||
check(pending.get().isDone() && pending.get().isCompletedExceptionally(),"stop leaves future pending");
|
||||
check(manager.getLoop(other).isPaused(),"stop restored worker");
|
||||
check(dev.modzuozhi.core.compat.PayloadMainThreadDispatch.submit(player,ctx.payloadId(),() -> 1).isCompletedExceptionally(),"stop accepts new work");
|
||||
ModZuozhi.LOGGER.info("PAYLOAD LIFECYCLE PASS");
|
||||
ModZuozhi.LOGGER.info("PAYLOAD DIAGNOSTICS PASS");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package dev.modzuozhi;
|
||||
|
||||
import dev.modzuozhi.core.compat.*;
|
||||
import dev.modzuozhi.core.dimthread.*;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.*;
|
||||
import java.util.*;
|
||||
|
||||
public final class ReviewRegression {
|
||||
static final List<String> failures=new ArrayList<>();
|
||||
static void expect(boolean ok,String name){if(!ok){failures.add(name);ModZuozhi.LOGGER.error("REVIEW RED: {}",name);}}
|
||||
public static void run(MinecraftServer server) throws Exception {
|
||||
var manager=DimThreadCore.MANAGER;manager.setPaused(server,true);
|
||||
var player=new net.neoforged.neoforge.common.util.FakePlayer(server.overworld(),
|
||||
new com.mojang.authlib.GameProfile(UUID.randomUUID(),"ReviewTest"));
|
||||
var disconnects=new AtomicInteger();
|
||||
new net.minecraft.server.network.ServerGamePacketListenerImpl(server,
|
||||
new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND),player,
|
||||
net.minecraft.server.network.CommonListenerCookie.createInitial(player.getGameProfile(),false)) {
|
||||
@Override public void disconnect(net.minecraft.network.chat.Component reason){disconnects.incrementAndGet();}
|
||||
};
|
||||
var ctx=new net.neoforged.neoforge.network.handling.ServerPayloadContext(player.connection,ResourceLocation.parse("dimensionalripper:review"));
|
||||
var result=new AtomicReference<CompletableFuture<Integer>>();
|
||||
Thread rejected=new Thread(() -> result.set(ctx.enqueueWork(() -> {throw new RejectedExecutionException("handler executor closed");})));
|
||||
rejected.start();rejected.join(5000);PayloadMainThreadDispatch.drain(server);PayloadMainThreadDispatch.drain(server);
|
||||
expect(disconnects.get()==0,"handler rejection must not disconnect player");
|
||||
try{expect(result.get().join()==null,"guard preserves handler rejection result");}
|
||||
catch(CompletionException e){expect(false,"guard preserves handler rejection result");}
|
||||
Thread cancelledHandler=new Thread(() -> result.set(ctx.enqueueWork(() -> {throw new CancellationException("handler cancelled its own computation");})));
|
||||
cancelledHandler.start();cancelledHandler.join(5000);PayloadMainThreadDispatch.drain(server);
|
||||
expect(result.get().join()==null,"guard preserves handler cancellation result");
|
||||
var overflow=new AtomicReference<CompletableFuture<Integer>>();
|
||||
Thread flood=new Thread(() -> {for(int i=0;i<257;i++)overflow.set(ctx.enqueueWork(() -> 1));});
|
||||
flood.start();flood.join(5000);
|
||||
expect(overflow.get().isCompletedExceptionally(),"capacity rejection remains exceptional");
|
||||
PayloadMainThreadDispatch.drain(server);
|
||||
expect(disconnects.get()==1,"genuine capacity rejection disconnects once");
|
||||
for(int i=0;i<10;i++)PayloadMainThreadDispatch.drain(server);
|
||||
String counters=PayloadDiagnostics.snapshot(server);
|
||||
expect(counters.contains("failed=2") && counters.contains("rejected=1"),
|
||||
"diagnostics distinguish handler failures from capacity rejection");
|
||||
|
||||
var other=server.getLevel(net.minecraft.world.level.Level.NETHER);
|
||||
manager.removeLoop(other);
|
||||
manager.runMainThreadPhase(server,"review-new-loop",() -> manager.getLoop(other).scheduleIfNeeded());
|
||||
expect(manager.getLoop(other).isPaused(),"new loop inherits menu pause after phase exit");
|
||||
manager.setPaused(server,true);
|
||||
|
||||
var loop=manager.getLoop(other);var entered=new CountDownLatch(1);var release=new CountDownLatch(1);
|
||||
var finished=new AtomicBoolean();
|
||||
loop.execute(() -> {
|
||||
server.execute(() -> manager.removeLoop(other));entered.countDown();
|
||||
while(release.getCount()!=0) try{release.await();}catch(InterruptedException ignored){}
|
||||
finished.set(true);
|
||||
});
|
||||
manager.setPaused(server,false);PayloadRegression.check(entered.await(5,TimeUnit.SECONDS),"retiring loop fixture start");
|
||||
Thread releaser=new Thread(() -> {try{Thread.sleep(250);}catch(InterruptedException ignored){}release.countDown();});
|
||||
releaser.start();
|
||||
try{manager.runMainThreadPhase(server,"review-retire",() -> expect(finished.get() && !loop.isTicking(),"retired loop must drain before exclusive phase"));}
|
||||
finally{release.countDown();releaser.join(5000);}
|
||||
manager.setPaused(server,true);
|
||||
|
||||
var labelled=new CountDownLatch(1);var labelRelease=new CountDownLatch(1);
|
||||
var late=new AtomicReference<CompletableFuture<Integer>>();
|
||||
Thread submitter=new Thread(() -> {
|
||||
labelled.countDown();try{labelRelease.await(5,TimeUnit.SECONDS);}catch(InterruptedException e){throw new RuntimeException(e);}
|
||||
// Exact late-registration boundary after the submitter had obtained its queue.
|
||||
var ticket=PayloadDiagnostics.begin(server,"dimensionalripper:shutdown_race");
|
||||
PayloadDiagnostics.end(server,ticket,new CancellationException("stopped"));
|
||||
late.set(PayloadMainThreadDispatch.submit(player,ctx.payloadId(),() -> {throw new AssertionError("late action");}));
|
||||
});
|
||||
submitter.start();PayloadRegression.check(labelled.await(5,TimeUnit.SECONDS),"late submit fixture pause");
|
||||
PayloadMainThreadDispatch.stop(server);PayloadMainThreadDispatch.clear(server);labelRelease.countDown();submitter.join(5000);
|
||||
expect(late.get()!=null && late.get().isCompletedExceptionally(),"late future completed");
|
||||
expect(PayloadDiagnostics.snapshot(server).equals("payloads: pending=0 batches=0"),"late submission must not recreate diagnostics after clear");
|
||||
if(!failures.isEmpty())throw new AssertionError(String.join("; ",failures));
|
||||
ModZuozhi.LOGGER.info("PAYLOAD REVIEW PASS");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package dev.modzuozhi;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.neoforged.neoforge.network.handling.ServerPayloadContext;
|
||||
|
||||
/** Isolated real packet, plot block entity and native constraint lifecycle fixture. */
|
||||
public final class SimulatedHandleRegression {
|
||||
public static void run(MinecraftServer server, ServerPayloadContext ctx) throws Exception {
|
||||
if (!net.neoforged.fml.ModList.get().isLoaded("simulated")) return;
|
||||
var level=server.overworld();
|
||||
var handleId=ResourceLocation.parse("simulated:iron_handle");
|
||||
ModZuozhi.LOGGER.info("Handle fixture block: {}",handleId);
|
||||
var block=BuiltInRegistries.BLOCK.get(handleId);
|
||||
var packet=Class.forName("dev.simulated_team.simulated.network.packets.UpdatePlayerUsingHandlePacket");
|
||||
var ctor=packet.getConstructor(float.class,boolean.class,BlockPos.class);
|
||||
var veilType=Class.forName("foundry.veil.api.network.handler.ServerPacketContext");
|
||||
var veil=Class.forName("foundry.veil.forge.network.NeoForgeServerPacketContext")
|
||||
.getConstructor(net.neoforged.neoforge.network.handling.IPayloadContext.class).newInstance(ctx);
|
||||
var handle=packet.getMethod("handle",veilType);
|
||||
var containerType=Class.forName("dev.ryanhcode.sable.api.sublevel.SubLevelContainer");
|
||||
var subType=Class.forName("dev.ryanhcode.sable.sublevel.SubLevel");
|
||||
var serverSubType=Class.forName("dev.ryanhcode.sable.sublevel.ServerSubLevel");
|
||||
var poseType=Class.forName("dev.ryanhcode.sable.companion.math.Pose3d");
|
||||
var container=call(containerType,null,"getContainer",Class.forName("dev.ryanhcode.sable.api.sublevel.ServerSubLevelContainer"),
|
||||
new Class<?>[]{net.minecraft.server.level.ServerLevel.class},level);
|
||||
var sub=call(containerType,container,"allocateNewSubLevel",subType,new Class<?>[]{poseType},poseType.getConstructor().newInstance());
|
||||
var plot=subType.getMethod("getPlot").invoke(sub);
|
||||
var plotType=Class.forName("dev.ryanhcode.sable.sublevel.plot.LevelPlot");
|
||||
var pos=((BlockPos)plotType.getMethod("getCenterBlock").invoke(plot)).atY(115);
|
||||
plotType.getMethod("newEmptyChunk",net.minecraft.world.level.ChunkPos.class).invoke(plot,new net.minecraft.world.level.ChunkPos(pos));
|
||||
level.setBlockAndUpdate(pos,block.defaultBlockState());
|
||||
var be=level.getBlockEntity(pos);
|
||||
PayloadRegression.check(be!=null && be.getClass().getName().endsWith("HandleBlockEntity"),"real handle plot fixture");
|
||||
var players=be.getClass().getDeclaredField("players");players.setAccessible(true);
|
||||
var systemType=Class.forName("dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem");
|
||||
var system=systemType.getMethod("get",net.minecraft.world.level.Level.class).invoke(null,level);
|
||||
var body=systemType.getMethod("getPhysicsHandle",serverSubType).invoke(system,sub);
|
||||
var bodyType=Class.forName("dev.ryanhcode.sable.api.physics.handle.RigidBodyHandle");
|
||||
var jointType=Class.forName("dev.ryanhcode.sable.api.physics.constraint.PhysicsConstraintHandle");
|
||||
Object joint=null;
|
||||
var helper=Class.forName("dev.ryanhcode.sable.Sable").getField("HELPER").get(null);
|
||||
var global=(net.minecraft.world.phys.Vec3)call(helper.getClass(),helper,"projectOutOfSubLevel",net.minecraft.world.phys.Vec3.class,
|
||||
new Class<?>[]{net.minecraft.world.level.Level.class,net.minecraft.world.phys.Vec3.class},level,net.minecraft.world.phys.Vec3.atCenterOf(pos));
|
||||
var player=ctx.player();player.setPos(global.x,global.y-1,global.z);player.getAbilities().flying=true;
|
||||
level.addNewPlayer(player);
|
||||
var release=new java.util.concurrent.CountDownLatch(1);
|
||||
try {
|
||||
for(boolean remove:new boolean[]{false,false,true}) {
|
||||
var request=ctor.newInstance(4.0f,remove,pos);
|
||||
if(remove) {
|
||||
var entered=new java.util.concurrent.CountDownLatch(1);
|
||||
var manager=dev.modzuozhi.core.dimthread.DimThreadCore.MANAGER;
|
||||
manager.getLoop(level).execute(() -> {
|
||||
server.execute(release::countDown);entered.countDown();
|
||||
try {PayloadRegression.check(release.await(5,java.util.concurrent.TimeUnit.SECONDS),"native release callback pump");}
|
||||
catch(InterruptedException e){throw new RuntimeException(e);}
|
||||
});
|
||||
manager.setPaused(server,false);
|
||||
PayloadRegression.check(entered.await(5,java.util.concurrent.TimeUnit.SECONDS),"native release worker start");
|
||||
// The old unprotected path clears player holding state but leaves the joint.
|
||||
handle.invoke(request,veil);
|
||||
PayloadRegression.check((boolean)jointType.getMethod("isValid").invoke(joint),"unprotected release did not reproduce stranded joint");
|
||||
ModZuozhi.LOGGER.info("SIMULATED NATIVE RED: unprotected release left joint valid while worker owned plot");
|
||||
}
|
||||
ctx.enqueueWork(() -> {
|
||||
try {handle.invoke(request,veil);} catch(ReflectiveOperationException e) {throw new RuntimeException(e);}
|
||||
}).join();
|
||||
var map=(java.util.Map<?,?>)players.get(be);
|
||||
PayloadRegression.check(remove ? map.isEmpty() : map.containsKey(ctx.player().getUUID()),"real handle grab/release state");
|
||||
if(!remove) {
|
||||
be.getClass().getMethod("sable$physicsTick",serverSubType,bodyType,double.class).invoke(be,sub,body,0.05d);
|
||||
var holding=map.get(player.getUUID());
|
||||
var jointField=holding.getClass().getDeclaredField("constraintHandle");jointField.setAccessible(true);
|
||||
joint=jointField.get(holding);
|
||||
PayloadRegression.check(joint!=null && (boolean)jointType.getMethod("isValid").invoke(joint),"native handle joint created");
|
||||
} else {
|
||||
PayloadRegression.check(joint!=null && !(boolean)jointType.getMethod("isValid").invoke(joint),"native joint survived release packet");
|
||||
dev.modzuozhi.core.dimthread.DimThreadCore.MANAGER.setPaused(server,true);
|
||||
}
|
||||
}
|
||||
ModZuozhi.LOGGER.info("SIMULATED HANDLE PACKET PASS: real packet, BE and native joint invalidated on release");
|
||||
} finally {
|
||||
release.countDown();
|
||||
dev.modzuozhi.core.dimthread.DimThreadCore.MANAGER.setPaused(server,true);
|
||||
level.setBlockAndUpdate(pos,net.minecraft.world.level.block.Blocks.AIR.defaultBlockState());
|
||||
level.removePlayerImmediately(player,net.minecraft.world.entity.Entity.RemovalReason.DISCARDED);
|
||||
var reasonType=Class.forName("dev.ryanhcode.sable.sublevel.storage.SubLevelRemovalReason");
|
||||
call(containerType,container,"removeSubLevel",void.class,new Class<?>[]{subType,reasonType},sub,reasonType.getField("REMOVED").get(null));
|
||||
}
|
||||
}
|
||||
static Object call(Class<?> owner,Object target,String name,Class<?> result,Class<?>[] types,Object...args) throws Exception {
|
||||
var signature=java.lang.invoke.MethodType.methodType(result,types);
|
||||
var lookup=java.lang.invoke.MethodHandles.publicLookup();
|
||||
var method=target==null?lookup.findStatic(owner,name,signature):lookup.findVirtual(owner,name,signature);
|
||||
var values=new java.util.ArrayList<Object>();if(target!=null)values.add(target);java.util.Collections.addAll(values,args);
|
||||
try{return method.invokeWithArguments(values);}catch(Throwable failure){throw new RuntimeException(failure);}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user