Compare commits
4
Commits
32b11aee87
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cef4e20ad | ||
|
|
763c015103 | ||
|
|
691e949691 | ||
|
|
2619f0936d |
No files matched your search
@@ -2,6 +2,32 @@
|
|||||||
|
|
||||||
Dimensional Ripper 是一个基于 **NeoForge 1.21.1** 的多线程维度并行优化模组。它通过独立维度 tick 循环与细粒度并行调度,充分利用多核 CPU 提升服务端性能,同时保持稳定的 20 TPS。
|
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 碰撞查询期间反复创建实心占位区块的问题:
|
1.1.15 针对 Sable 碰撞查询期间反复创建实心占位区块的问题:
|
||||||
|
|
||||||
|
|||||||
+62
-2
@@ -77,9 +77,68 @@ dependencies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Opt-in isolated runtime fixture; excluded from normal builds.
|
// 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()) {
|
if (providers.gradleProperty('interactionRegression').isPresent()) {
|
||||||
sourceSets.main.java.srcDir('tests/integration')
|
sourceSets.main.java.srcDir('tests/integration')
|
||||||
sourceSets.main.java.exclude('**/ChunkHandoffRegression.java')
|
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()) {
|
||||||
|
throw new GradleException('Run ownershipRegression and interactionRegression separately.')
|
||||||
|
}
|
||||||
|
sourceSets.main.java.srcDir('tests/ownership')
|
||||||
|
dependencies { runtimeOnly files(providers.gradleProperty('ccTestJar').get()) }
|
||||||
|
tasks.matching { it.name == 'runServer' }.configureEach {
|
||||||
|
doLast {
|
||||||
|
def output = file('runs/server/logs/latest.log').getText('UTF-8')
|
||||||
|
['OWNERSHIP REGRESSION PASS', 'OWNERSHIP EDGE PASS', 'OWNERSHIP CALLBACK PASS', 'OWNERSHIP RESUME PASS'].each { marker ->
|
||||||
|
if (!output.contains(marker)) throw new GradleException("Missing runtime test result: ${marker}")
|
||||||
|
}
|
||||||
|
if (output.contains('Encountered an unexpected exception')) throw new GradleException('Runtime regression server crashed.')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType(ProcessResources).configureEach {
|
tasks.withType(ProcessResources).configureEach {
|
||||||
@@ -121,11 +180,12 @@ tasks.register('compileConcurrencyRegression', JavaCompile) {
|
|||||||
'src/main/java/dev/modzuozhi/core/compat/TaskBarrier.java',
|
'src/main/java/dev/modzuozhi/core/compat/TaskBarrier.java',
|
||||||
'src/main/java/dev/modzuozhi/core/compat/ManagedFutureAwait.java',
|
'src/main/java/dev/modzuozhi/core/compat/ManagedFutureAwait.java',
|
||||||
'src/main/java/dev/modzuozhi/core/compat/PauseOwnership.java', 'tests/PauseOwnershipRegression.java',
|
'src/main/java/dev/modzuozhi/core/compat/PauseOwnership.java', 'tests/PauseOwnershipRegression.java',
|
||||||
'src/main/java/dev/modzuozhi/core/compat/TickProgress.java', 'tests/TickProgressRegression.java')
|
'src/main/java/dev/modzuozhi/core/compat/TickProgress.java', 'tests/TickProgressRegression.java',
|
||||||
|
'src/main/java/dev/modzuozhi/core/compat/PayloadWorkQueue.java', 'tests/PayloadWorkQueueRegression.java')
|
||||||
classpath = files()
|
classpath = files()
|
||||||
destinationDirectory = regressionClasses
|
destinationDirectory = regressionClasses
|
||||||
}
|
}
|
||||||
['TaskBarrierRegression', 'ManagedFutureAwaitRegression', 'PauseOwnershipRegression', 'TickProgressRegression'].each { regressionMain ->
|
['TaskBarrierRegression', 'ManagedFutureAwaitRegression', 'PauseOwnershipRegression', 'TickProgressRegression', 'PayloadWorkQueueRegression'].each { regressionMain ->
|
||||||
def regressionTask = tasks.register("run${regressionMain}", JavaExec) {
|
def regressionTask = tasks.register("run${regressionMain}", JavaExec) {
|
||||||
dependsOn 'compileConcurrencyRegression'
|
dependsOn 'compileConcurrencyRegression'
|
||||||
classpath = files(regressionClasses)
|
classpath = files(regressionClasses)
|
||||||
|
|||||||
@@ -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 明确作为独立后续工作,不以当前方案替代采样栈修复。
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# 模组网络包的线程归属与安全交接设计
|
||||||
|
|
||||||
|
日期:2026-10-06(Asia/Shanghai)
|
||||||
|
|
||||||
|
状态:用户于 2026-10-06 批准直接实施;公共入口已实现并进行候选验证。完整验收尚有未测项,见 `docs/payload-ownership-1.1.17-validation.md`,不得把本文的目标清单当作全部通过。
|
||||||
|
|
||||||
|
## 1. 用户目标与边界
|
||||||
|
|
||||||
|
修复 Simulated 普通抓握把手无法松手,并覆盖走相同网络基础设施的更多模组交互,而非继续逐个包补丁。维度更新、实体等负载继续并行,要求主线程的处理器仍在真正的服务器主线程执行。允许为了正确性增加可观测的同步等待,但不把并行优化整体关闭。
|
||||||
|
|
||||||
|
首轮目标是 NeoForge 1.21.1 的服务端游戏阶段自定义包及其受支持的同步后续任务。登录、握手、配置阶段、客户端接收和纯网络线程处理不被无条件改派。不能据公共入口适配便宣称全部模组兼容。
|
||||||
|
|
||||||
|
坐服童子 ServerWarashi 的采样崩溃仍是待修复项,但属于独立的采样状态隔离工作,不混入本设计的包调度实现。不能声称网络修复会顺带解决它。
|
||||||
|
|
||||||
|
## 2. 已有证据
|
||||||
|
|
||||||
|
- 1.1.16 在隔离测试中修复了 CC 主线程读取被维度线程接管的世界而得到假空值的路径,已有暂停、收口、恢复和主线程回调处理机制。
|
||||||
|
- 当前仅针对 Create `BlockEntityConfigurationPacket` 和 Simulated `AssemblePacket` 增加了 `SableAssemblyDispatch`,没有覆盖抓握把手更新包。
|
||||||
|
- 本机 Simulated 1.3.0 的 `UpdatePlayerUsingHandlePacket.handle` 先读取方块实体。松手时会先停止玩家握持状态,但只有读到 `HandleBlockEntity` 才调用 `stopGrabbingServer`、释放约束。因此假空值存在“玩家状态清了,物理约束没清”的路径,现场仍须复现验证。
|
||||||
|
- NeoForge `MainThreadPayloadHandler` 通过 `IPayloadContext.enqueueWork` 调用原处理器;`ServerPayloadContext` 同时存在 Runnable 和 Supplier 两种返回 future 的重载。
|
||||||
|
- 本机 Veil 4.2.0 通过 NeoForge 的注册接口注册服务端处理器,公共入口有机会覆盖 Simulated 等使用 Veil 的模组。
|
||||||
|
- 23:43 崩溃报告的新增诊断记录了 Jade 主线程查询与 Create 后台光照查询的归属不匹配。后者不属于网络包入口,必须保留为范围外问题。
|
||||||
|
- ServerWarashi 2.1.1.2 的直接异常是 `PerfWorkTracker.claimSlot` 下标 -1。其共享采样栈不隔离主线程、维度线程,这是另一条需要复现的兼容路径。
|
||||||
|
|
||||||
|
## 3. 选定方案与取舍
|
||||||
|
|
||||||
|
采用公共入口适配、保守的全维度安全阶段,以及可审计的单维度例外机制。
|
||||||
|
|
||||||
|
对比:逐包白名单同步开销较小,但容易漏掉新的模组;直接允许任意线程读取世界仅消除检查,不能消除数据竞争。两者都不作为默认方案。
|
||||||
|
|
||||||
|
第一版新公共入口默认全维度隔离,不凭“玩家当前维度”或包内坐标猜测整个处理器的访问范围。已有单维度补丁必须审计其调用链后决定保留或归入公共阶段,不能因已存在就视为线程安全。以后缩小隔离范围须增加对应测试,不自动学习白名单。
|
||||||
|
|
||||||
|
代价:有交互负载时,主线程可能等待最慢的在途维度更新;批处理只能摊薄交接开销,不能保证忙维度绝不拖累交互。无待处理包时不建立新的安全屏障。
|
||||||
|
|
||||||
|
## 4. 组件职责
|
||||||
|
|
||||||
|
### 4.1 入口适配层
|
||||||
|
|
||||||
|
识别服务端、游戏阶段、有效玩家连接和原始 payload ID。拦截 NeoForge 主线程处理器的调度边界,保留原处理器及其上下文,不重写权限、距离、游戏规则和业务逻辑。审计 `ServerPayloadContext.enqueueWork` 两个重载,明确区分首次投递、阶段内嵌套任务和网络处理器主动提交的主线程工作。
|
||||||
|
|
||||||
|
每项任务携带服务器实例、连接身份、玩家身份、接收时世界身份、payload ID、入队时间和完成结果。禁止保存或输出包正文、聊天文本及认证数据作为诊断。
|
||||||
|
|
||||||
|
普通网络线程处理器的计算仍在原线程;它显式提交的世界主线程工作才进入交接。注册/注销通道、登录任务完成等路径照旧,不能在配置阶段调用只能用于游戏玩家的接口。
|
||||||
|
|
||||||
|
### 4.2 待处理队列
|
||||||
|
|
||||||
|
每个服务器独立托管,连接内按公共入口观察到的投递顺序执行,单项任务至多执行一次。不得按 payload 类型重排或合并抓取、松手和配置操作。不同来源的 vanilla 与 mod 包不承诺原本不存在的全局顺序,必须测试握持流程实际依赖的顺序。
|
||||||
|
|
||||||
|
初始批次上限为 64 项或 2 ms 的处理时间,达到任一条件后不启动下一项;不强行中断已开始的处理器。服务器队列上限 4096 项、单连接上限 256 项。满载时拒绝新任务、结束其 future 并限频报告,不能静默丢弃,不能无限堆积。连接过载应明确断开并走原有清理逻辑;把手清理须纳入验收,不能留下永久约束。
|
||||||
|
|
||||||
|
### 4.3 安全阶段执行器
|
||||||
|
|
||||||
|
由真正的服务器线程在不位于同步 worker→main 回调内的外层调度点取批次。请求暂停维度续排,等待已开始的维度工作及其子任务完全收口;等待期间继续服务使这些工作完成所需的主线程回调。禁止持有包队列锁、模组锁或世界锁等待。
|
||||||
|
|
||||||
|
取得全部所需世界的独占执行权后执行本批次。退出时只恢复本次暂停的循环,保留菜单暂停、停服和外层阶段的状态。异常也必须释放阶段,完成 future,不重复执行已经产生副作用的处理器。
|
||||||
|
|
||||||
|
阶段状态至少区分空闲、等待收口、独占执行、恢复。等待期间收到的普通包只排队,不能递归建立另一个全局阶段。独占执行期间的同服务器嵌套同步任务复用现有阶段。
|
||||||
|
|
||||||
|
服务器启动的未接管世界沿用原版执行权;动态维度创建/移除及停服必须与阶段生命周期协调,不能在独占阶段中偷偷启动新的维度写入者。超时用于诊断,不是允许并发执行的理由。
|
||||||
|
|
||||||
|
### 4.4 Future、异常与重入契约
|
||||||
|
|
||||||
|
保留 Runnable/Supplier 的结果、异常、取消语义;审计并保留 NeoForge 原有 `NetworkRegistry.guard` 对外语义,同时内部保留真实失败原因用于诊断。不能把未执行任务报告为成功,也不能统一把失败值伪造为 null。
|
||||||
|
|
||||||
|
阶段内同步调用应立即完成,避免原处理器 join 自己的排队任务。普通主线程外层调用可进入安全阶段后同步执行;同步 Sable 回调内尚未持有全局执行权的任务不得阻塞等待自己的维度归还所有权。此类不支持的同步重入必须明确失败并记录调用路径,不能以泛化修复之名引入死锁;对应模组须使用经验证的专门适配。
|
||||||
|
|
||||||
|
阶段外 worker/网络线程投递立即返回 future,不等待主线程。若上游工作线程本身阻塞等待这个 future,会与全局收口形成环路:不得宣称该路径自动兼容,测试必须检出并作为未支持调用契约报告。不可通过放开读取守卫掩盖。
|
||||||
|
|
||||||
|
## 5. 模组覆盖与旧补丁迁移
|
||||||
|
|
||||||
|
优先验收以下路径,但每条分别记录“已审计入口、已模拟验证、已实际模组验证”,不混为一项结论:
|
||||||
|
|
||||||
|
| 路径 | 验收目标 |
|
||||||
|
| --- | --- |
|
||||||
|
| NeoForge MAIN 自定义包 | 真主线程、世界独占、原处理器至多一次 |
|
||||||
|
| Veil / Simulated | 抓取、滚轮距离、松手、组装与拆解 |
|
||||||
|
| Create 配置 | 转速设置与权限/距离检查保留 |
|
||||||
|
| Jade 查询 | 返回正确方块实体数据,追踪再次提交任务的路径 |
|
||||||
|
| 非 MAIN 处理器的 enqueueWork | 原网络计算不迁移,显式同步工作安全交接 |
|
||||||
|
|
||||||
|
公共阶段已持有执行权时,旧 Create/组装适配必须直接复用它,不能再次排到已暂停的维度队列。旧补丁的移除需有相应通过测试;不存在的可选模组不得导致启动注入失败。
|
||||||
|
|
||||||
|
模组通过裸 `server.execute`、自建线程或其他框架再次提交的工作,不因首次包进入公共阶段就自动受保护。首轮对以上已知目标审计这些逃逸点,必要时增加范围明确的适配。禁止全局替换所有服务器任务的执行语义。
|
||||||
|
|
||||||
|
## 6. 玩家与世界生命周期
|
||||||
|
|
||||||
|
执行前重新核对连接仍有效、玩家未被替换、世界与接收时一致。过期的普通交互不对新世界重放,其 future 明确结束,原因计入诊断。
|
||||||
|
|
||||||
|
抓握释放不能只靠取消过期包:需要验证原模组在断线、换维度、死亡、把手拆除及子世界卸载时的约束清理。若原清理不完整,增加针对握持资源的可信生命周期适配,在旧世界安全执行;不能将过期客户端包改写坐标后重新执行。业务清理与通用排队失效策略分别测试。
|
||||||
|
|
||||||
|
服务器停止时停止接收新任务,尚未执行的 future 以取消/失败结束,正在执行者完成安全收尾;清空服务器持有的连接与世界引用。不能在保存开始后重启循环或继续应用包。
|
||||||
|
|
||||||
|
## 7. 诊断与性能验收
|
||||||
|
|
||||||
|
新增按 payload ID 的有界聚合:接收数、完成数、失败/过期/拒绝数、待处理数、最老等待时间、阶段等待/执行耗时、批次数。记录上限 128 个 ID,超出归入 other,避免恶意 ID 撑大统计。异常日志全局限频,保留最近有限条事件,绝不按包持续打印堆栈。
|
||||||
|
|
||||||
|
状态输出与崩溃报告要能区分:网络队列积压、等待维度收口、处理器慢、Sable 回调等待。沿用 1.1.16 的归属快照,不将快照中的 queued 单独称为死锁。
|
||||||
|
|
||||||
|
比较空载、普通交互、密集抓取/松手、多人查询和一个慢维度时的主线程 TPS、各维度实际完成 TPS、排队延迟及阶段耗时。报告回退代价,不以低 Carpet MSPT 判断成功。
|
||||||
|
|
||||||
|
## 8. 必须通过的验证
|
||||||
|
|
||||||
|
1. 先复现普通把手的失败路径:证明松手包到达、读到的对象和约束释放结果;测试不能只证明假包调用成功。
|
||||||
|
2. 公共入口在维度工作进行中执行 handler 时仍取得正确对象、真实主线程和独占权;修复前应失败。
|
||||||
|
3. 真实的抓取→距离修改→松手严格按投递顺序且仅一次执行。包含高频交互、不同玩家、掉线/换维度和子世界卸载。
|
||||||
|
4. 权限拒绝、超距、错误坐标仍按原处理器拒绝,不造成强加载或越权操作。
|
||||||
|
5. Runnable/Supplier 结果、异常、取消、嵌套 enqueue、等待中回调和非法同步重入都有测试;任何失败不能让 future 永远悬挂。
|
||||||
|
6. 多维度、预先暂停、异常恢复、动态维度生命周期及停服时不发生并发写入、错误恢复或残留任务。
|
||||||
|
7. 队列容量、批次预算和限频实际生效;松手不会被合并或被静默丢弃。
|
||||||
|
8. 未安装 Create/Veil/Jade 等可选模组可启动;现有 CC、区块读取、水流、交互和纯 Java 调度回归全部运行。
|
||||||
|
9. 验证发布 jar 不包含夹具和测试模组。完整整合包只使用存档副本,不修改正式服务器、启动器和模组目录。
|
||||||
|
|
||||||
|
真实航空学依赖无法加载或无法可靠自动化时,明确保留该验收项未通过,不将公共入口夹具等同于真实物理验收。
|
||||||
|
|
||||||
|
## 9. 本轮交付与后续独立工作
|
||||||
|
|
||||||
|
本文批准后编写实现计划;计划审阅后实施入口、执行器、适配及测试。交付候选 jar、覆盖清单、验证证据与未解决项。不自动上传远端或部署正式服务器。
|
||||||
|
|
||||||
|
ServerWarashi 随后的独立修复必须从其真实采样调用链建立失败测试,决定线程过滤或按线程分离状态时说明报告覆盖范围;不能只夹紧数组下标,也不能把仅主线程采样伪称全维度采样。
|
||||||
|
|
||||||
|
C2ME 随机源注入冲突、Create 后台光照读取、通用异步世界访问及既有停服超时行为不因本设计而宣称解决。
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# 随机刻与服务器 tick 回调归属修复
|
||||||
|
|
||||||
|
日期:2026-10-13(Asia/Shanghai)
|
||||||
|
|
||||||
|
状态:用户已选择先修复随机刻/邻居通知链和服务器全局 tick 回调。本文为待确认的书面设计;尚未实施代码,也未生成新 jar。
|
||||||
|
|
||||||
|
基线:`763c015`,DimensionalRipper 1.1.17,工作分支 `codex/payload-ownership`。
|
||||||
|
|
||||||
|
## 1. 目标与范围
|
||||||
|
|
||||||
|
修复日志中两条已经定位的错误路径,同时保留其他现有并行能力:
|
||||||
|
|
||||||
|
1. 同一世界的不同区块并行随机刻,经同步邻居通知进入 DriveByWire 共享信号网络,触发 `WireNetworkManager.setSource:240` 的 `HashMap.computeIfAbsent` CME。
|
||||||
|
2. Lootr 在主线程 `ServerTickEvent.Post` 扫描/转换容器,而维度 worker 仍持有世界归属,导致方块实体读取被守卫拒绝。
|
||||||
|
|
||||||
|
用户选择的是以上两项,不包括坐服童子采样修复。不修改用户正式服务器、启动器 mods、原存档,不自动推送远端。
|
||||||
|
|
||||||
|
验收目标是正确执行而非仅不抛异常:随机刻、信号、箱子转换没有丢失或重复;等待期间 Sable 交接能结束;安全阶段不能错误恢复暂停或停止中的循环。
|
||||||
|
|
||||||
|
## 2. 已核对的证据
|
||||||
|
|
||||||
|
- 用户 2026-10-13 日志加载 DimensionalRipper 1.1.17、NeoForge 21.1.233、DriveByWire 0.3.0、Lootr 1.21.1-1.11.37.120。
|
||||||
|
- 三次 CME 分别在 11:02:10、11:02:17、11:02:23;事件总线和 ChunkEnv 各打印一份,不把六份堆栈计作六次独立异常。
|
||||||
|
- 当前 `ServerChunkCacheMixin` 将 `level.tickChunk` 收集成分片,使用按区块锁后调用自定义 `IChunkEnvTick`。这些锁不保护同一世界的 DriveByWire 网络管理器。
|
||||||
|
- 本机同版本 DriveByWire 字节码显示 `sourceValues` 是普通 HashMap,`setSource` 的计算函数只新建内部 HashMap。并发写入是强证据支持的原因;尚未捕获用户环境中另一条写线程,不能宣称已有完整双线程现场复现。
|
||||||
|
- 本机 Lootr 的 `HandleTick.onServerTick(ServerTickEvent.Post)` 直接调用 `BlockEntityTicker.onServerTick(server)`,遍历不同维度的 ticker;不经过公共收包入口。
|
||||||
|
- 本地 NeoForge 21.1.244 的 `MinecraftServer.tickServer` 在 `tickChildren` 前调用 `fireServerTickPre`,在方法末尾调用 `fireServerTickPost`。用户 21.1.233 必须另外核对注入及运行兼容,不能仅凭版本范围宣称已测试。
|
||||||
|
|
||||||
|
## 3. 选择的方案
|
||||||
|
|
||||||
|
不选仅替换 DriveByWire 集合:嵌套集合及“读取—更新—通知”仍是复合操作,且无法覆盖其他邻居监听器。
|
||||||
|
|
||||||
|
不选关闭所有并行:这是应急措施,会扩大性能回退范围。
|
||||||
|
|
||||||
|
采用两项边界调整,共用已有世界执行权协调器。第一项减少维度内不安全并行;第二项让明确的主线程事件边界与维度写入互斥。它不是任意模组线程安全的通用保证。
|
||||||
|
|
||||||
|
### 3.1 区块环境 tick 恢复所属维度内串行
|
||||||
|
|
||||||
|
- 当前 `tickChunks` 中每次 `level.tickChunk(chunk, randomTickSpeed)` 恢复直接调用原方法,不再投递区块环境子任务或分片。
|
||||||
|
- 调用在该世界合法 owner 上执行:维度并行启用时是所属维度 worker,原版路径则为服务器主线程。不是把每个区块同步投递到主线程。
|
||||||
|
- 沿用原 tickChunks 选中的区块、顺序和随机刻速度;同一已选区块只执行一次。由原方法及其模组注入处理随机源、降水、闪电、方块和流体随机刻,不继续绕过原方法手工复刻。
|
||||||
|
- 方块更新与同步邻居通知保留在同一调用链,不独立排队、不合并、不延后一拍,不调整 `randomTickSpeed`。
|
||||||
|
- 不允许 `fine on` 或 `fine sharded on` 重新开启旧区块环境并行。第一版不保留“默认关闭但可误开启”的不安全开关;现有实体/方块实体等其他并行开关保持原功能。
|
||||||
|
- 既有实体/方块实体子任务必须先按原屏障归还执行权,不能与本阶段重叠;实施测试需验证,不以线程名称替代归属检查。
|
||||||
|
- 修改 README/status 的区块环境并行说明,不能继续宣称此阶段仍分片并行。历史累计指标如保留,必须明确不是当前并行工作量。
|
||||||
|
|
||||||
|
范围限制:这一改动消除已知的同世界并行随机刻来源,不代表所有实体、方块实体、异步模组或跨世界共享状态已安全。若真实 DriveByWire 回归暴露其他写入来源,先报告证据并重新确认范围,不能仅压住 CME。
|
||||||
|
|
||||||
|
### 3.2 服务器全局 Pre/Post 回调取得执行权
|
||||||
|
|
||||||
|
- 只包装 `MinecraftServer.tickServer` 对 `EventHooks.fireServerTickPre` 与 `fireServerTickPost` 的两个原调用点,保留同一 server、BooleanSupplier、原位置及原事件总线顺序,原调用恰好执行一次。
|
||||||
|
- 复用 `MainThreadPhaseCoordinator.run`:禁止维度续排,等待全部在途及退出中循环完成;期间继续处理使 Sable 交接结束所需的主线程工作;随后进入真实主线程独占阶段执行原事件回调。
|
||||||
|
- 不包住整个 tickServer,不把两个事件之间全部主线程工作都纳入长期独占,也不移动事件的顺序。
|
||||||
|
- 不修改全局 `EventBus.post`、通用 `MinecraftServer.execute` 或 `pollTask`;不把 `LevelTickEvent.Pre/Post` 搬到主线程。因而坐服童子的维度事件采样异常仍可能出现。
|
||||||
|
- 不能只看总开关判断“无并行”;存在尚未归还执行权的循环时仍须等待。没有并行循环的原版路径应保留直接原调用,不创建或启动新循环。
|
||||||
|
- 独占内 CC 或 payload 嵌套使用既有租约,不递归建立屏障。禁止从正在被维度 worker 同步等待的 Sable 回调创建全局阶段,避免主线程与该 worker 互等。
|
||||||
|
- 等待不能持有第三方模组锁;超时只做限频诊断,不能授权与 worker 并发写世界。
|
||||||
|
- 异常保持原对象和传播语义,不吞异常、不重试整个事件、不重放已执行监听器。finally 正确释放本次租约,但不能解除菜单暂停或复活停止状态;阶段内新建循环仍受约束。
|
||||||
|
|
||||||
|
全局事件每次进入都需要安全条件,不能根据猜测“这 tick Lootr 没任务”跳过保护;也不为单个模组过滤事件监听器。
|
||||||
|
|
||||||
|
## 4. 性能与可观测性
|
||||||
|
|
||||||
|
- 第一项取消区块环境细粒度并行;第二项在服务器每 tick 的 Pre/Post 两个边界收口,主线程可能等待最慢维度。因此不再承诺“一个慢维度绝不影响主线程和其他维度”。两次安全阶段之外仍允许原有维度及其他阶段并行。
|
||||||
|
- 不承诺固定开销比例、20 TPS 或性能提升。先保证正确性,再测量收益;如果安全回退代价不可接受,应基于实测重新设计,而不是放松互斥。
|
||||||
|
- Pre 与 Post 分开记录次数、最近等待/执行时间、累计或最大耗时;使用有界数据,不按监听器或每 tick 打堆栈。
|
||||||
|
- 本地原版实现先记录部分 tick 耗时再执行 Post,故 Carpet/原版 MSPT 可能未完整涵盖新增 Post 等待。必须同时查看主线程实际 TPS、维度完成 TPS 和 Pre/Post 独立耗时,不能用低 MSPT 证明不卡。
|
||||||
|
- 不把本次扩展成通用采样器或故障保护重写。
|
||||||
|
|
||||||
|
## 5. 必须验证的项目
|
||||||
|
|
||||||
|
先建立能失败的回归,再改变生产行为:
|
||||||
|
|
||||||
|
1. 两个以上区块的真实随机刻及邻居监听器,在旧版本记录并行重叠,新版本无同世界重叠且不运行在细粒度子任务上。核对原 `tickChunk` 注入仍被调用、选中区块至多一次、邻居通知同步完成。
|
||||||
|
2. 实际 DriveByWire 0.3.0 对多个位置的信号更新测试,核对最终信号值及连接状态,不能仅测一个自建 HashMap 或等待偶然 CME。
|
||||||
|
3. 注册真实 ServerTickEvent.Pre/Post 测试监听器;旧版本在 worker 持有世界时不能合法读取,新版本看到原 BE、真正主线程、全部循环不在途。核对事件顺序和执行次数。
|
||||||
|
4. 真实 Lootr 扫描/转换的测试容器,核对预期转换结果及内容/战利品表,而非仅注册假监听器。检查未加载位置不会被额外强加载。
|
||||||
|
5. 等待中的 Sable 回调、嵌套 CC/payload、异常、菜单暂停、动态/退出中的循环、停服状态均不引入死锁、重复回调或错误恢复。
|
||||||
|
6. 无 DriveByWire/Lootr/Sable 时可启动;分别验证细粒度 on/off、分片 on/off 都不会重开区块环境并行。重复单人世界进入/退出,检查静态诊断引用释放。
|
||||||
|
7. 重跑已有队列、屏障、CC、payload、实际航空学把手、Create/Jade、水流、区块与实体路由回归。
|
||||||
|
8. 比较空载、随机刻密集、Lootr 转换负载及一个人为慢维度:报告实际 TPS、阶段等待/执行耗时及随机刻进度,不只看 Carpet MSPT。
|
||||||
|
9. Java 21 干净构建;校验 jar 元数据、无测试夹具及可选依赖打包,记录 SHA256。运行日志必须具备 PASS 标记且没有非预期服务器异常,不能只看 Gradle 退出码。
|
||||||
|
|
||||||
|
真实模组无法运行或完整测试矩阵未通过时,明确保留未验收项目,只能交付对应范围的测试候选,不能宣称两项已全面解决。坐服童子测试应独立,不能让其已知异常混淆这两项因果验证。
|
||||||
|
|
||||||
|
## 6. 不在本次范围与交付流程
|
||||||
|
|
||||||
|
不修 ServerWarashi 采样栈,不宣称修复任意异步世界访问、其他实体/方块实体并行、跨维度共享集合、C2ME、既有停服超时或所有主线程世界访问。保护两个 tick 事件不等于保护所有主线程指令、函数和第三方后台任务。
|
||||||
|
|
||||||
|
用户确认本文后再编写具体实施计划及失败测试拆分,确认计划和执行方式后实施。交付本地候选 jar、测试证据、回退代价及未解决项;不部署或推送。
|
||||||
|
|
||||||
|
## 自审
|
||||||
|
|
||||||
|
设计与“先修 1 和 2”的范围一致。随机刻选择维度 owner 串行而非 worker 同步请求全局阶段,避免自等待;全局事件仅保护两个明确入口。性能描述明确承认两次全局等待破坏完全维度隔离;验收区分假监听器与真实模组。未将坐服童子或其他已有问题纳入修复承诺。
|
||||||
+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.
|
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
|
||||||
mod_license=MIT
|
mod_license=MIT
|
||||||
# The mod version. See https://semver.org/
|
# 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.
|
# The group ID for the mod. It is only important when publishing build artifacts to Maven.
|
||||||
mod_group_id=dev.modzuozhi
|
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.
|
# 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),
|
dimensionName(dimension),
|
||||||
EntityTickParallel.missedShards(), ShardGate.teMissed(), ShardGate.chunkMissed());
|
EntityTickParallel.missedShards(), ShardGate.teMissed(), ShardGate.chunkMissed());
|
||||||
ctx.getSource().sendSuccess(() -> Component.literal(msg), false);
|
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();
|
var loops = DimThreadCore.MANAGER.loopsSnapshot();
|
||||||
if (loops.isEmpty()) {
|
if (loops.isEmpty()) {
|
||||||
ctx.getSource().sendSuccess(() -> Component.literal("维度循环:无数据(尚未启动或已停止),不按 20 TPS 显示。"), false);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package dev.modzuozhi.core.compat;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.RejectedExecutionException;
|
||||||
|
import java.util.function.LongSupplier;
|
||||||
|
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<>();
|
||||||
|
private Throwable closed;
|
||||||
|
private boolean draining;
|
||||||
|
|
||||||
|
public PayloadWorkQueue(LongSupplier nanoTime) { clock = Objects.requireNonNull(nanoTime); }
|
||||||
|
|
||||||
|
public <T> CompletableFuture<T> submit(K connection, Supplier<T> action) {
|
||||||
|
Objects.requireNonNull(connection);
|
||||||
|
Objects.requireNonNull(action);
|
||||||
|
Entry<T> entry = new Entry<>(connection, action);
|
||||||
|
Throwable rejected;
|
||||||
|
synchronized (this) {
|
||||||
|
rejected = closed;
|
||||||
|
if (rejected == null && (pending.size() >= 4096 || counts.getOrDefault(connection, 0) >= 256))
|
||||||
|
rejected = new CapacityExceeded();
|
||||||
|
if (rejected == null) {
|
||||||
|
pending.add(entry);
|
||||||
|
counts.merge(connection, 1, Integer::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rejected != null) entry.result.completeExceptionally(rejected);
|
||||||
|
else entry.result.whenComplete((value, error) -> {
|
||||||
|
if (entry.result.isCancelled()) synchronized (this) { remove(entry); }
|
||||||
|
});
|
||||||
|
return entry.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void remove(Entry<?> entry) {
|
||||||
|
if (pending.remove(entry)) counts.compute(entry.connection, (key, count) -> count == 1 ? null : count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single consumer; recursive/concurrent drains are no-ops. Already running work is never interrupted. */
|
||||||
|
public int drainBatch() {
|
||||||
|
synchronized (this) {
|
||||||
|
if (draining || closed != null) return 0;
|
||||||
|
draining = true;
|
||||||
|
}
|
||||||
|
int ran = 0;
|
||||||
|
long start = clock.getAsLong();
|
||||||
|
try {
|
||||||
|
while (ran < 64 && clock.getAsLong() - start < 2_000_000L) {
|
||||||
|
Entry<?> entry;
|
||||||
|
synchronized (this) {
|
||||||
|
if (closed != null || pending.isEmpty()) break;
|
||||||
|
entry = pending.iterator().next();
|
||||||
|
remove(entry);
|
||||||
|
}
|
||||||
|
entry.run();
|
||||||
|
ran++;
|
||||||
|
}
|
||||||
|
return ran;
|
||||||
|
} finally { synchronized (this) { draining = false; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void close(Throwable reason) {
|
||||||
|
Objects.requireNonNull(reason);
|
||||||
|
List<Entry<?>> abandoned;
|
||||||
|
synchronized (this) {
|
||||||
|
if (closed != null) return;
|
||||||
|
closed = reason;
|
||||||
|
abandoned = new ArrayList<>(pending);
|
||||||
|
pending.clear(); counts.clear();
|
||||||
|
}
|
||||||
|
abandoned.forEach(entry -> entry.result.completeExceptionally(reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized int size() { return pending.size(); }
|
||||||
|
|
||||||
|
private final class Entry<T> {
|
||||||
|
final K connection;
|
||||||
|
final Supplier<T> action;
|
||||||
|
final CompletableFuture<T> result = new CompletableFuture<>();
|
||||||
|
Entry(K connection, Supplier<T> action) { this.connection = connection; this.action = action; }
|
||||||
|
void run() {
|
||||||
|
if (result.isCancelled()) return;
|
||||||
|
try { result.complete(action.get()); }
|
||||||
|
catch (Throwable error) { result.completeExceptionally(error); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,38 +1,15 @@
|
|||||||
package dev.modzuozhi.core.compat;
|
package dev.modzuozhi.core.compat;
|
||||||
|
|
||||||
import dev.modzuozhi.ModZuozhi;
|
import net.minecraft.resources.ResourceLocation;
|
||||||
import dev.modzuozhi.core.dimthread.DimThreadCore;
|
|
||||||
import dev.modzuozhi.core.dimthread.IPlayerPacketQueue;
|
|
||||||
import net.minecraft.server.level.ServerLevel;
|
|
||||||
import net.minecraft.server.level.ServerPlayer;
|
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 {
|
public final class SableAssemblyDispatch {
|
||||||
private SableAssemblyDispatch() {}
|
private SableAssemblyDispatch() {}
|
||||||
|
|
||||||
public static void dispatch(ServerPlayer player, Runnable action) {
|
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 (player.hasDisconnected() || player.isRemoved()) return;
|
||||||
if (!DimThreadCore.MANAGER.isLoopScheduled(level) || SableMainThreadBridge.ownsHandoff(level)) {
|
PayloadMainThreadDispatch.submit(player, ResourceLocation.fromNamespaceAndPath("dimensionalripper", "legacy_interaction"),
|
||||||
action.run();
|
() -> { action.run(); return null; });
|
||||||
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;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,10 @@ public final class SableMainThreadBridge {
|
|||||||
public static boolean ownsHandoff(ServerLevel level) {
|
public static boolean ownsHandoff(ServerLevel level) {
|
||||||
return HANDED_OFF_LEVEL.get() == 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 static final ConcurrentHashMap<Class<?>, Method> GET_LEVEL_METHODS = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
private SableMainThreadBridge() {
|
private SableMainThreadBridge() {
|
||||||
@@ -72,12 +76,14 @@ public final class SableMainThreadBridge {
|
|||||||
CompletableFuture<Void> completion = new CompletableFuture<>();
|
CompletableFuture<Void> completion = new CompletableFuture<>();
|
||||||
// 0 = queued, 1 = running, 2 = cancelled before start.
|
// 0 = queued, 1 = running, 2 = cancelled before start.
|
||||||
AtomicInteger state = new AtomicInteger();
|
AtomicInteger state = new AtomicInteger();
|
||||||
|
ThreadOwnershipDiagnostics.handoff(level, "queued");
|
||||||
server.execute(() -> {
|
server.execute(() -> {
|
||||||
// A timed-out queued task must not execute later, detached from its waiting level tick.
|
// A timed-out queued task must not execute later, detached from its waiting level tick.
|
||||||
if (!state.compareAndSet(0, 1)) {
|
if (!state.compareAndSet(0, 1)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
ThreadOwnershipDiagnostics.handoff(level, "running");
|
||||||
// The parent dimension worker temporarily owns both vanilla thread fields while
|
// The parent dimension worker temporarily owns both vanilla thread fields while
|
||||||
// it is paused here. Restore real server-thread ownership for the complete Sable
|
// it is paused here. Restore real server-thread ownership for the complete Sable
|
||||||
// segment, then hand ownership back before waking the worker.
|
// segment, then hand ownership back before waking the worker.
|
||||||
@@ -88,8 +94,10 @@ public final class SableMainThreadBridge {
|
|||||||
} finally {
|
} finally {
|
||||||
if (previous == null) HANDED_OFF_LEVEL.remove(); else HANDED_OFF_LEVEL.set(previous);
|
if (previous == null) HANDED_OFF_LEVEL.remove(); else HANDED_OFF_LEVEL.set(previous);
|
||||||
}
|
}
|
||||||
|
ThreadOwnershipDiagnostics.handoff(level, "completed-owner-restored");
|
||||||
completion.complete(null);
|
completion.complete(null);
|
||||||
} catch (Throwable throwable) {
|
} catch (Throwable throwable) {
|
||||||
|
ThreadOwnershipDiagnostics.handoff(level, "failed-owner-restored: " + throwable.getClass().getSimpleName());
|
||||||
completion.completeExceptionally(throwable);
|
completion.completeExceptionally(throwable);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -98,6 +106,7 @@ public final class SableMainThreadBridge {
|
|||||||
completion.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
completion.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
} catch (InterruptedException interrupted) {
|
} catch (InterruptedException interrupted) {
|
||||||
if (state.compareAndSet(0, 2)) {
|
if (state.compareAndSet(0, 2)) {
|
||||||
|
ThreadOwnershipDiagnostics.handoff(level, "cancelled-interrupted-before-start");
|
||||||
completion.cancel(false);
|
completion.cancel(false);
|
||||||
} else {
|
} else {
|
||||||
// A running main-thread phase must finish before world ownership is released.
|
// 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);
|
throw new IllegalStateException("Interrupted while waiting for Sable main-thread physics tick", interrupted);
|
||||||
} catch (TimeoutException timeout) {
|
} catch (TimeoutException timeout) {
|
||||||
if (state.compareAndSet(0, 2)) {
|
if (state.compareAndSet(0, 2)) {
|
||||||
|
ThreadOwnershipDiagnostics.handoff(level, "cancelled-timeout-before-start");
|
||||||
completion.cancel(false);
|
completion.cancel(false);
|
||||||
ModZuozhi.LOGGER.error(
|
ModZuozhi.LOGGER.error(
|
||||||
"[Compatibility] Sable 物理 Tick 排队超过 {} 秒且尚未开始,交由故障保护降级",
|
"[Compatibility] Sable 物理 Tick 排队超过 {} 秒且尚未开始,交由故障保护降级",
|
||||||
@@ -119,6 +129,7 @@ public final class SableMainThreadBridge {
|
|||||||
ModZuozhi.LOGGER.warn(
|
ModZuozhi.LOGGER.warn(
|
||||||
"[Compatibility] Sable 主线程物理 Tick 已运行超过 {} 秒;为避免世界并发写入,继续等待其安全结束",
|
"[Compatibility] Sable 主线程物理 Tick 已运行超过 {} 秒;为避免世界并发写入,继续等待其安全结束",
|
||||||
TIMEOUT_SECONDS);
|
TIMEOUT_SECONDS);
|
||||||
|
ThreadOwnershipDiagnostics.handoff(level, "running-over-10s");
|
||||||
awaitRunningTick(completion);
|
awaitRunningTick(completion);
|
||||||
} catch (ExecutionException failed) {
|
} catch (ExecutionException failed) {
|
||||||
Throwable cause = failed.getCause();
|
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 com.mojang.logging.LogUtils;
|
||||||
import dev.modzuozhi.ModZuozhi;
|
import dev.modzuozhi.ModZuozhi;
|
||||||
|
import dev.modzuozhi.core.compat.ThreadOwnershipDiagnostics;
|
||||||
import net.minecraft.network.Connection;
|
import net.minecraft.network.Connection;
|
||||||
import net.minecraft.network.PacketSendListener;
|
import net.minecraft.network.PacketSendListener;
|
||||||
import net.minecraft.network.chat.Component;
|
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;
|
private volatile boolean ticking = false;
|
||||||
/** 单机内嵌服务器暂停(ESC 菜单)时置 true:取消下一拍并不再自排,杜绝暂停/保存期间并行写区块。 */
|
/** 单机内嵌服务器暂停(ESC 菜单)时置 true:取消下一拍并不再自排,杜绝暂停/保存期间并行写区块。 */
|
||||||
private volatile boolean paused = false;
|
private volatile boolean paused = false;
|
||||||
|
private volatile boolean mainThreadHeld;
|
||||||
private long scheduleGeneration;
|
private long scheduleGeneration;
|
||||||
private final dev.modzuozhi.core.compat.TickProgress progress = new dev.modzuozhi.core.compat.TickProgress();
|
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() {
|
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);
|
this.scheduleAfter(0L);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +130,16 @@ public final class LevelTickLoop implements Runnable, java.util.concurrent.Execu
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean isPaused() {
|
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) {
|
private void runIfCurrent(long generation) {
|
||||||
synchronized (this) {
|
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;
|
this.ticking = true;
|
||||||
}
|
}
|
||||||
DimThreadCore.attach(Thread.currentThread(), this.level);
|
DimThreadCore.attach(Thread.currentThread(), this.level);
|
||||||
@@ -199,11 +210,14 @@ public final class LevelTickLoop implements Runnable, java.util.concurrent.Execu
|
|||||||
try {
|
try {
|
||||||
DimThreadCore.swapThreadsAndRun(() -> {
|
DimThreadCore.swapThreadsAndRun(() -> {
|
||||||
try {
|
try {
|
||||||
|
ThreadOwnershipDiagnostics.phase(this.level, "queued-tasks");
|
||||||
this.processPendingTasks();
|
this.processPendingTasks();
|
||||||
|
ThreadOwnershipDiagnostics.phase(this.level, "level-tick");
|
||||||
completed[0] = this.internalTick();
|
completed[0] = this.internalTick();
|
||||||
} finally {
|
} finally {
|
||||||
workNanos[0] = System.nanoTime() - tickStart;
|
workNanos[0] = System.nanoTime() - tickStart;
|
||||||
}
|
}
|
||||||
|
ThreadOwnershipDiagnostics.phase(this.level, "chunk-tasks");
|
||||||
this.drainChunkTasks(deadline);
|
this.drainChunkTasks(deadline);
|
||||||
}, this.level, this.level.getChunkSource());
|
}, this.level, this.level.getChunkSource());
|
||||||
} catch (Throwable t) {
|
} 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);
|
ModZuozhi.LOGGER.error("[DimThread] 维度 {} tick 异常", this.level.dimension().location(), t);
|
||||||
FaultGuard.onDimTickFailure(t);
|
FaultGuard.onDimTickFailure(t);
|
||||||
} finally {
|
} finally {
|
||||||
|
ThreadOwnershipDiagnostics.phase(this.level, "idle");
|
||||||
progress.finish(System.nanoTime(), workNanos[0], completed[0]);
|
progress.finish(System.nanoTime(), workNanos[0], completed[0]);
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
this.ticking = false;
|
this.ticking = false;
|
||||||
if (this.running && !this.paused) {
|
if (this.running && !this.paused && !this.mainThreadHeld) {
|
||||||
this.scheduleAfter(Math.max(0L, deadline - System.nanoTime()));
|
this.scheduleAfter(Math.max(0L, deadline - System.nanoTime()));
|
||||||
} else if (!this.running) {
|
} else if (!this.running) {
|
||||||
ModZuozhi.LOGGER.info("[DimThread] 维度 {} 独立循环已停止", this.level.dimension().location());
|
ModZuozhi.LOGGER.info("[DimThread] 维度 {} 独立循环已停止", this.level.dimension().location());
|
||||||
@@ -250,7 +265,9 @@ public final class LevelTickLoop implements Runnable, java.util.concurrent.Execu
|
|||||||
// 区块不加载 → 实体/光照等待区块 → tick 更慢 → 更没有预算排空区块。因此给一个最低保证窗口。
|
// 区块不加载 → 实体/光照等待区块 → tick 更慢 → 更没有预算排空区块。因此给一个最低保证窗口。
|
||||||
long effectiveDeadline = Math.max(deadline, now + 2_000_000L); // 最低 2ms
|
long effectiveDeadline = Math.max(deadline, now + 2_000_000L); // 最低 2ms
|
||||||
boolean overBudget = now >= deadline;
|
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()) {
|
if (chunkSource.pollTask()) {
|
||||||
continue; // 有就绪任务,继续排空
|
continue; // 有就绪任务,继续排空
|
||||||
}
|
}
|
||||||
@@ -296,6 +313,8 @@ public final class LevelTickLoop implements Runnable, java.util.concurrent.Execu
|
|||||||
}
|
}
|
||||||
EventHooks.fireLevelTickPost(this.level, () -> true);
|
EventHooks.fireLevelTickPost(this.level, () -> true);
|
||||||
|
|
||||||
|
ThreadOwnershipDiagnostics.phase(this.level, "connections");
|
||||||
|
|
||||||
if (net.neoforged.fml.ModList.get().isLoaded("starlight_extra")) {
|
if (net.neoforged.fml.ModList.get().isLoaded("starlight_extra")) {
|
||||||
// Farm inventory persistence and logout callbacks require the actual server thread.
|
// Farm inventory persistence and logout callbacks require the actual server thread.
|
||||||
// Keep the whole connection phase together, including disconnection cleanup.
|
// Keep the whole connection phase together, including disconnection cleanup.
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ public class ServerManager {
|
|||||||
|
|
||||||
/** 每维度独立 tick 循环(维度隔离模型,见 {@link LevelTickLoop})。key 为维度。 */
|
/** 每维度独立 tick 循环(维度隔离模型,见 {@link LevelTickLoop})。key 为维度。 */
|
||||||
private final Map<ServerLevel, LevelTickLoop> loops = new ConcurrentHashMap<>();
|
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 能力)。 */
|
/** 承载所有维度 loop 自排程的调度池({@code schedule(task, nspt)} 需要 Scheduled 能力)。 */
|
||||||
private volatile ScheduledThreadPoolExecutor masterPool;
|
private volatile ScheduledThreadPoolExecutor masterPool;
|
||||||
/** 玩家连接当前所属维度(key 为 Connection,value 为维度),供跨维度/登录时幂等迁移连接。 */
|
/** 玩家连接当前所属维度(key 为 Connection,value 为维度),供跨维度/登录时幂等迁移连接。 */
|
||||||
@@ -143,7 +145,26 @@ public class ServerManager {
|
|||||||
|
|
||||||
/** 获取(懒创建)某维度的独立 tick 循环。 */
|
/** 获取(懒创建)某维度的独立 tick 循环。 */
|
||||||
public LevelTickLoop getLoop(ServerLevel level) {
|
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. */
|
/** Read-only inventory; observing status must never create or start a loop. */
|
||||||
@@ -151,6 +172,15 @@ public class ServerManager {
|
|||||||
return Map.copyOf(this.loops);
|
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)。
|
* 该维度 loop 是否已启动接管({@code tickChildren → scheduleIfNeeded} 之后才为 true)。
|
||||||
* <p>
|
* <p>
|
||||||
@@ -159,6 +189,7 @@ public class ServerManager {
|
|||||||
* 来驱动出生点区块生成;直到 loop 接管后才由 {@code ServerChunkCachePollMixin} 跳过主线程 poll。
|
* 来驱动出生点区块生成;直到 loop 接管后才由 {@code ServerChunkCachePollMixin} 跳过主线程 poll。
|
||||||
*/
|
*/
|
||||||
public boolean isLoopScheduled(ServerLevel level) {
|
public boolean isLoopScheduled(ServerLevel level) {
|
||||||
|
if(retiringLoops.stream().anyMatch(loop -> loop.getLevel()==level && loop.isTicking()))return true;
|
||||||
LevelTickLoop loop = this.loops.get(level);
|
LevelTickLoop loop = this.loops.get(level);
|
||||||
return loop != null && dev.modzuozhi.core.compat.PauseOwnership.workerOwns(
|
return loop != null && dev.modzuozhi.core.compat.PauseOwnership.workerOwns(
|
||||||
loop.isScheduled(), loop.isPaused(), loop.isTicking());
|
loop.isScheduled(), loop.isPaused(), loop.isTicking());
|
||||||
@@ -227,21 +258,25 @@ public class ServerManager {
|
|||||||
if (!server.isSameThread()) {
|
if (!server.isSameThread()) {
|
||||||
throw new IllegalStateException("Pause ownership must be changed on the server thread");
|
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) {
|
if (paused) {
|
||||||
this.loops.values().forEach(LevelTickLoop::pause);
|
|
||||||
// In-flight ticks may be waiting for Sable on this very thread. Pump callbacks
|
// 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.
|
// until they return ownership; a timeout must not authorize concurrent saving.
|
||||||
server.managedBlock(() -> this.loops.values().stream().noneMatch(LevelTickLoop::isTicking));
|
server.managedBlock(() -> ownershipLoopsSnapshot().stream().filter(loop -> loop.getLevel().getServer()==server).noneMatch(LevelTickLoop::isTicking));
|
||||||
} else {
|
|
||||||
this.loops.values().forEach(LevelTickLoop::resume);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 移除并停止某维度的 loop(维度卸载时调用)。 */
|
/** 移除并停止某维度的 loop(维度卸载时调用)。 */
|
||||||
public void removeLoop(ServerLevel level) {
|
public void removeLoop(ServerLevel level) {
|
||||||
LevelTickLoop loop = this.loops.remove(level);
|
LevelTickLoop loop = this.loops.get(level);
|
||||||
if (loop != null) {
|
if (loop != null) {
|
||||||
loop.kill();
|
loop.kill();
|
||||||
|
retiringLoops.add(loop);
|
||||||
|
this.loops.remove(level,loop);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,9 +295,10 @@ public class ServerManager {
|
|||||||
* 带 30s 看门狗防止异常场景下永久阻塞。
|
* 带 30s 看门狗防止异常场景下永久阻塞。
|
||||||
*/
|
*/
|
||||||
public void stopLoopsAndWaitIdle() {
|
public void stopLoopsAndWaitIdle() {
|
||||||
this.loops.values().forEach(LevelTickLoop::kill);
|
var stopping = ownershipLoopsSnapshot();
|
||||||
|
stopping.forEach(LevelTickLoop::kill);
|
||||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
|
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
|
||||||
for (LevelTickLoop loop : this.loops.values()) {
|
for (LevelTickLoop loop : stopping) {
|
||||||
while (loop.isTicking()) {
|
while (loop.isTicking()) {
|
||||||
if (System.nanoTime() > deadline) {
|
if (System.nanoTime() > deadline) {
|
||||||
ModZuozhi.LOGGER.warn("[DimThread] 等待维度 {} loop 收口超时(>30s),强制继续",
|
ModZuozhi.LOGGER.warn("[DimThread] 等待维度 {} loop 收口超时(>30s),强制继续",
|
||||||
@@ -277,6 +313,7 @@ public class ServerManager {
|
|||||||
/** 停止所有维度 loop 并关闭调度池(服务器停机 / 全局开关关闭时调用)。 */
|
/** 停止所有维度 loop 并关闭调度池(服务器停机 / 全局开关关闭时调用)。 */
|
||||||
public void killAllLoops() {
|
public void killAllLoops() {
|
||||||
this.loops.values().forEach(LevelTickLoop::kill);
|
this.loops.values().forEach(LevelTickLoop::kill);
|
||||||
|
this.loops.values().stream().filter(LevelTickLoop::isTicking).forEach(retiringLoops::add);
|
||||||
this.loops.clear();
|
this.loops.clear();
|
||||||
// 清空连接路由:重新启用时 routeConnection 会把连接当作「首次」重新挂到新 loop。
|
// 清空连接路由:重新启用时 routeConnection 会把连接当作「首次」重新挂到新 loop。
|
||||||
// 否则 old == level 会误判为 no-op,导致重启后玩家连接丢失、不再被任何 loop tick。
|
// 否则 old == level 会误判为 no-op,导致重启后玩家连接丢失、不再被任何 loop tick。
|
||||||
@@ -289,8 +326,11 @@ public class ServerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void clear() {
|
public void clear() {
|
||||||
|
dev.modzuozhi.core.compat.ThreadOwnershipDiagnostics.clear();
|
||||||
TpsTracker.reset();
|
TpsTracker.reset();
|
||||||
actives.clear();
|
actives.clear();
|
||||||
|
serverPauses.clear();
|
||||||
|
retiringLoops.removeIf(loop -> !loop.isTicking());
|
||||||
threadPools.clear();
|
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);
|
boolean ownerIsActive = DimThreadCore.owns(this.thread);
|
||||||
return callerIsActive && ownerIsActive ? this.thread : t;
|
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 并发卡死。 */
|
/** 服务器停止时:先等所有维度 tick 收口,再停 loop、关线程池,防止保存与 worker 并发卡死。 */
|
||||||
@Inject(method = "stopServer", at = @At("HEAD"))
|
@Inject(method = "stopServer", at = @At("HEAD"))
|
||||||
public void modzuozhi_shutdownThreadpool(CallbackInfo ci) {
|
public void modzuozhi_shutdownThreadpool(CallbackInfo ci) {
|
||||||
|
dev.modzuozhi.core.compat.PayloadMainThreadDispatch.stop((MinecraftServer)(Object)this);
|
||||||
// 必须先等正在执行的维度 tick 完全退出再进入保存:否则主线程 saveAllChunks 会与
|
// 必须先等正在执行的维度 tick 完全退出再进入保存:否则主线程 saveAllChunks 会与
|
||||||
// 仍在飞的维度 worker(level.tick → processUnloads)并发修改 ChunkMap.toDrop
|
// 仍在飞的维度 worker(level.tick → processUnloads)并发修改 ChunkMap.toDrop
|
||||||
// (非线程安全 fastutil 结构),导致「正在保存中」无限卡死(只能强杀进程)。
|
// (非线程安全 fastutil 结构),导致「正在保存中」无限卡死(只能强杀进程)。
|
||||||
@@ -90,4 +91,9 @@ public abstract class MinecraftServerDimMixin {
|
|||||||
DimThreadCore.MANAGER.threadPools.forEach((server, pool) -> pool.shutdown());
|
DimThreadCore.MANAGER.threadPools.forEach((server, pool) -> pool.shutdown());
|
||||||
DimThreadCore.MANAGER.clear();
|
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",
|
"ClassInstanceMultiMapMixin",
|
||||||
"LithiumEntityGroupsMixin",
|
"LithiumEntityGroupsMixin",
|
||||||
"CollectingNeighborUpdaterMixin",
|
"CollectingNeighborUpdaterMixin",
|
||||||
|
"ComputerCraftTickSchedulerMixin",
|
||||||
|
"ThreadOwnershipReportMixin",
|
||||||
"DistanceManagerMixin",
|
"DistanceManagerMixin",
|
||||||
"EntityChangeDimensionMixin",
|
"EntityChangeDimensionMixin",
|
||||||
"EntityGetEntityCollisionsMixin",
|
"EntityGetEntityCollisionsMixin",
|
||||||
@@ -38,6 +40,8 @@
|
|||||||
"LevelTicksMixin",
|
"LevelTicksMixin",
|
||||||
"LivingEntityGetInBlockStateMixin",
|
"LivingEntityGetInBlockStateMixin",
|
||||||
"MinecraftServerDimMixin",
|
"MinecraftServerDimMixin",
|
||||||
|
"MinecraftServerPayloadMixin",
|
||||||
|
"ServerPayloadContextMixin",
|
||||||
"MinecraftServerSaveMixin",
|
"MinecraftServerSaveMixin",
|
||||||
"MinecraftServerTimeSyncAccessor",
|
"MinecraftServerTimeSyncAccessor",
|
||||||
"NavigatingMobsMixin",
|
"NavigatingMobsMixin",
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import dev.modzuozhi.core.compat.PayloadWorkQueue;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import java.util.concurrent.atomic.*;
|
||||||
|
|
||||||
|
public class PayloadWorkQueueRegression {
|
||||||
|
static void check(boolean ok, String why) { if (!ok) throw new AssertionError(why); }
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
fifoAndExactlyOnce(); capacityAndCancellation(); batchCountAndTime();
|
||||||
|
closeAndReentrantCompletion(); concurrentProducers();
|
||||||
|
System.out.println("PAYLOAD QUEUE PASS");
|
||||||
|
}
|
||||||
|
static void fifoAndExactlyOnce() {
|
||||||
|
var q = new PayloadWorkQueue<String>(() -> 0L);
|
||||||
|
var seen = new ArrayList<Integer>();
|
||||||
|
var futures = new ArrayList<CompletableFuture<Integer>>();
|
||||||
|
for (int i=0;i<256;i++) { int n=i; futures.add(q.submit("a", () -> { seen.add(n); return n; })); }
|
||||||
|
while(q.size()>0) q.drainBatch();
|
||||||
|
check(seen.size()==256, "exactly once");
|
||||||
|
for(int i=0;i<256;i++) check(seen.get(i)==i && futures.get(i).join()==i, "FIFO/results");
|
||||||
|
check(q.drainBatch()==0, "no replay");
|
||||||
|
}
|
||||||
|
static void capacityAndCancellation() {
|
||||||
|
var q = new PayloadWorkQueue<Integer>(() -> 0L);
|
||||||
|
var fs = new ArrayList<CompletableFuture<Integer>>();
|
||||||
|
for(int c=0;c<16;c++) for(int i=0;i<256;i++) fs.add(q.submit(c, () -> 1));
|
||||||
|
check(q.size()==4096, "server capacity");
|
||||||
|
check(q.submit(99, () -> 2).isCompletedExceptionally(), "reject server overflow");
|
||||||
|
fs.getFirst().cancel(false);
|
||||||
|
check(q.size()==4095, "cancel returns capacity immediately");
|
||||||
|
check(!q.submit(0, () -> 3).isDone(), "cancel frees connection slot");
|
||||||
|
while(q.size()>0) q.drainBatch();
|
||||||
|
for(int i=0;i<256;i++) q.submit(7, () -> 1);
|
||||||
|
check(q.submit(7, () -> 9).isCompletedExceptionally(), "reject connection overflow");
|
||||||
|
check(!q.submit(8, () -> 1).isDone(), "other connection still allowed");
|
||||||
|
q.close(new IllegalStateException("stop"));
|
||||||
|
}
|
||||||
|
static void batchCountAndTime() {
|
||||||
|
var clock = new AtomicLong(); var q = new PayloadWorkQueue<String>(clock::get);
|
||||||
|
for(int i=0;i<65;i++) q.submit("a", () -> 1);
|
||||||
|
check(q.drainBatch()==64 && q.size()==1, "64 budget"); q.drainBatch();
|
||||||
|
var done = new AtomicInteger();
|
||||||
|
q.submit("a", () -> { clock.addAndGet(2_000_000); return done.incrementAndGet(); });
|
||||||
|
q.submit("a", done::incrementAndGet);
|
||||||
|
check(q.drainBatch()==1 && done.get()==1 && q.size()==1, "2ms boundary");
|
||||||
|
q.drainBatch();
|
||||||
|
var slow = q.submit("a", () -> { clock.addAndGet(100_000_000); return 42; });
|
||||||
|
check(q.drainBatch()==1 && slow.join()==42, "slow task not interrupted");
|
||||||
|
}
|
||||||
|
static void closeAndReentrantCompletion() {
|
||||||
|
var q = new PayloadWorkQueue<String>(() -> 0L);
|
||||||
|
var count = new AtomicInteger(); var f=q.submit("a", count::incrementAndGet);
|
||||||
|
f.thenRun(() -> q.submit("a", count::incrementAndGet)); q.drainBatch();
|
||||||
|
check(count.get()==2, "completion may enqueue");
|
||||||
|
var boom = new IllegalArgumentException("handler failure");
|
||||||
|
var bad=q.submit("a", () -> { throw boom; }); var good=q.submit("a", () -> 42);
|
||||||
|
q.drainBatch(); check(bad.isCompletedExceptionally() && good.join()==42, "isolate exceptions");
|
||||||
|
var pending=q.submit("a", count::incrementAndGet);
|
||||||
|
q.close(boom); check(pending.isCompletedExceptionally() && q.size()==0, "close settles pending");
|
||||||
|
check(q.submit("a", count::incrementAndGet).isCompletedExceptionally(), "close terminal");
|
||||||
|
q.drainBatch(); check(count.get()==2, "closed work never executes");
|
||||||
|
var q2=new PayloadWorkQueue<String>(() -> 0L);
|
||||||
|
var cancel=q2.submit("a", count::incrementAndGet); cancel.cancel(false); q2.drainBatch();
|
||||||
|
check(count.get()==2, "cancelled action skipped");
|
||||||
|
}
|
||||||
|
static void concurrentProducers() throws Exception {
|
||||||
|
var q = new PayloadWorkQueue<Integer>(() -> 0L);
|
||||||
|
var executed=new AtomicInteger(); var futures=new ConcurrentLinkedQueue<CompletableFuture<Integer>>();
|
||||||
|
var pool=Executors.newFixedThreadPool(8);
|
||||||
|
try {
|
||||||
|
var jobs=new ArrayList<Future<?>>();
|
||||||
|
for(int c=0;c<8;c++) { int key=c; jobs.add(pool.submit(() -> {
|
||||||
|
for(int i=0;i<250;i++) { var f=q.submit(key, executed::incrementAndGet); futures.add(f); if(i%2==0) f.cancel(false); }
|
||||||
|
})); }
|
||||||
|
for(var job:jobs) job.get(5,TimeUnit.SECONDS);
|
||||||
|
while(q.size()>0) q.drainBatch();
|
||||||
|
check(executed.get()==1000 && q.size()==0, "concurrent cancel/submit accounting");
|
||||||
|
check(futures.stream().allMatch(CompletableFuture::isDone), "no pending futures");
|
||||||
|
} finally { pool.shutdownNow(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,11 @@ package dev.modzuozhi;
|
|||||||
|
|
||||||
import com.mojang.authlib.GameProfile;
|
import com.mojang.authlib.GameProfile;
|
||||||
import dev.modzuozhi.core.compat.SableAssemblyDispatch;
|
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.DimThreadCore;
|
||||||
import dev.modzuozhi.core.dimthread.FineGrainScheduler;
|
import dev.modzuozhi.core.dimthread.FineGrainScheduler;
|
||||||
import dev.modzuozhi.core.dimthread.FaultGuard;
|
import dev.modzuozhi.core.dimthread.FaultGuard;
|
||||||
import net.minecraft.core.BlockPos;
|
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.block.Blocks;
|
||||||
import net.minecraft.world.level.material.Fluids;
|
import net.minecraft.world.level.material.Fluids;
|
||||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
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(new BlockPos(x, 100, z), Blocks.AIR.defaultBlockState());
|
||||||
}
|
}
|
||||||
level.setBlockAndUpdate(SOURCE.above(3), Blocks.CHEST.defaultBlockState());
|
level.setBlockAndUpdate(SOURCE.above(3), Blocks.CHEST.defaultBlockState());
|
||||||
var player = new ServerPlayer(server, level, new GameProfile(UUID.randomUUID(), "Regression"),
|
// A real listener is now part of the shared dispatch's stale-connection contract.
|
||||||
ClientInformation.createDefault()) {
|
var player = new net.neoforged.neoforge.common.util.FakePlayer(level,
|
||||||
@Override public boolean hasDisconnected() { return false; }
|
new GameProfile(UUID.randomUUID(), "Regression"));
|
||||||
};
|
|
||||||
startTime = level.getGameTime();
|
startTime = level.getGameTime();
|
||||||
DimThreadCore.MANAGER.setPaused(server, false);
|
DimThreadCore.MANAGER.setPaused(server, false);
|
||||||
SableAssemblyDispatch.dispatch(player, () -> {
|
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");
|
throw new AssertionError("Interaction did not obtain exclusive server ownership");
|
||||||
if (level.getBlockEntity(SOURCE.above(3)) == null)
|
if (level.getBlockEntity(SOURCE.above(3)) == null)
|
||||||
throw new AssertionError("Main-thread block entity lookup silently failed");
|
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