From ea421b23d3153e79ee40bf05f6248fcc2ac32775 Mon Sep 17 00:00:00 2001 From: U9G Date: Sun, 30 Aug 2026 19:41:05 -0400 Subject: [PATCH 1/4] Confirm creative slot sets with a stats round trip instead of a fixed wait On noAckOnCreateSetSlotPacket versions (1.21.9+) setInventorySlot resolved success by sleeping a flat 400ms and treating silence as acceptance. That wait is pure latency on every successful call: the 26.1 test suite alone spends 24.8s in it (62 calls), and any creative bot pays it per slot write. A rejection is a set_slot correction the server sends while processing our packet on its main thread, in receive order. A client_command request_stats round trip on the same connection is therefore proof the rejection window has passed: the statistics reply is generated after our set_creative_slot was processed, and any correction was already delivered before it. Statistics replies carry no transaction id, so in-flight confirms resolve FIFO, which matches the server answering requests in packet order. The old waitTimeout now caps how long we wait for the reply before assuming success, preserving the previous worst-case behavior on a server that never answers. Measured on 26.1: ping_request is answered on the netty thread in 0-2ms before earlier queued packets (10/10 runs), so it proves nothing about processing; request_stats consistently arrives after a just-sent chat echo (10/10) in 7-53ms. The creative test drops from 6.5s to 1.4s. --- lib/plugins/creative.js | 32 +++++++++++++++++++++++++++++--- test/externalTests/creative.js | 11 +++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lib/plugins/creative.js b/lib/plugins/creative.js index 03f288078b..e5880d2cdc 100644 --- a/lib/plugins/creative.js +++ b/lib/plugins/creative.js @@ -20,6 +20,30 @@ function inject (bot) { const creativeSlotsUpdates = [] + // FIFO: the server answers stats requests in packet-arrival order, so each + // statistics packet settles the oldest waiter, and everything written + // before that waiter's request is processed by then. + const statsConfirms = [] + bot._client.on('statistics', () => statsConfirms.shift()?.()) + + function confirmServerProcessed (timeoutMs) { + return new Promise((resolve) => { + // Timing out resolves as success: a server that never answers stats + // degrades to the previous fixed-wait behavior, never a hang. + const timer = setTimeout(() => { + const i = statsConfirms.indexOf(settle) + if (i !== -1) statsConfirms.splice(i, 1) + resolve() + }, timeoutMs) + function settle () { + clearTimeout(timer) + resolve() + } + statsConfirms.push(settle) + bot._client.write('client_command', bot.supportFeature('respawnIsPayload') ? { payload: 1 } : { actionId: 1 }) + }) + } + // WARN: This method should not be called twice on the same slot before first promise succeeds async function setInventorySlot (slot, item, waitTimeout = 400) { assert(slot >= 0 && slot <= 44) @@ -38,7 +62,9 @@ function inject (bot) { // No ack bot._setSlot(slot, item) if (waitTimeout === 0) return // no wait - // allow some time to see if server rejects + // A rejection is a set_slot correction the server sends while + // processing our packet on its main thread, so a stats round trip on + // the same ordered connection is proof the rejection window has passed. return new Promise((resolve, reject) => { function updateSlot (oldItem, newItem) { if (newItem.itemId !== item.itemId) { @@ -47,11 +73,11 @@ function inject (bot) { } } bot.inventory.once(`updateSlot:${slot}`, updateSlot) - setTimeout(() => { + confirmServerProcessed(waitTimeout).then(() => { bot.inventory.off(`updateSlot:${slot}`, updateSlot) creativeSlotsUpdates[slot] = false resolve() - }, waitTimeout) + }) }) } diff --git a/test/externalTests/creative.js b/test/externalTests/creative.js index cb4c5f0e9c..038853eb2d 100644 --- a/test/externalTests/creative.js +++ b/test/externalTests/creative.js @@ -53,4 +53,15 @@ module.exports = () => async (bot) => { assert.strictEqual(bot.inventory.slots.filter(item => item).length, 9) await bot.creative.clearInventory() assert.strictEqual(bot.inventory.slots.filter(item => item).length, 0) + // Sets are server-confirmed, not fixed 400ms/call silence windows: three + // sequential sets must finish in well under 3x400ms. + if (bot.supportFeature('noAckOnCreateSetSlotPacket')) { + const before = Date.now() + for (let i = 0; i < 3; i++) { + await bot.creative.setInventorySlot(SLOT, new Item(5 + i, 1, 0)) + } + const elapsed = Date.now() - before + assert.ok(elapsed < 1000, `3 sequential slot sets took ${elapsed}ms`) + await bot.creative.clearSlot(SLOT) + } } From 7d4e439b94797bc7c5320f4af3397f2049dc2553 Mon Sep 17 00:00:00 2001 From: U9G Date: Sun, 30 Aug 2026 20:58:04 -0400 Subject: [PATCH 2/4] test: wait for reset fills to be visible client-side The marker echo after resetBlocksToSuperflat's fills only proves the commands executed: the server sends command feedback immediately but flushes block changes at tick end, so the client can still hold pre-fill blocks when the next test starts. The fixed 400ms setInventorySlot waits at the top of most tests were absorbing that tick; with server-confirmed slot sets the race surfaces as tests reading stale blocks (placeBlock seeing a previous test's dirt). After the echo, wait until every block in the reset region matches its superflat layer, driven by blockUpdate/chunkColumnLoad events (past 64 changed blocks per section the correction arrives as a chunk resend, not block updates), with a hard 5s deadline that fails loudly naming the stale coordinate. --- test/externalTests/plugins/testCommon.js | 32 +++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/test/externalTests/plugins/testCommon.js b/test/externalTests/plugins/testCommon.js index 46a1de4e1b..1d8d4b75b2 100644 --- a/test/externalTests/plugins/testCommon.js +++ b/test/externalTests/plugins/testCommon.js @@ -85,13 +85,14 @@ function inject (bot, wrap) { async function resetBlocksToSuperflat () { const groundY = 4 + const center = bot.entity.position.floored() for (let y = groundY + 4; y >= groundY - 1; y--) { const realY = y + bot.test.groundY - 4 bot.chat(`/fill ~-5 ${realY} ~-5 ~5 ${realY} ~5 ` + layerNames[y]) } - // The fills are fire-and-forget; a marker chat message on the same - // ordered connection confirms they have executed and their block updates - // have already arrived, without assuming how long a server tick takes. + // The marker echo only proves the fills executed: command feedback is + // sent immediately while block changes flush at tick end, so the client + // can still hold pre-fill blocks after the echo. const marker = 'superflat-reset-done' const echo = onceWithCleanup(bot, 'messagestr', { timeout: 5000, @@ -99,6 +100,31 @@ function inject (bot, wrap) { }) bot.chat(marker) await echo + const staleBlock = () => { + for (let y = groundY + 4; y >= groundY - 1; y--) { + const realY = y + bot.test.groundY - 4 + const want = layerNames[y] + if (!want) continue + for (let dx = -5; dx <= 5; dx++) { + for (let dz = -5; dz <= 5; dz++) { + const block = bot.blockAt(new Vec3(center.x + dx, realY, center.z + dz)) + if (!block || block.name !== want) return `${center.x + dx},${realY},${center.z + dz} is ${block?.name ?? 'unloaded'}, expected ${want}` + } + } + } + return null + } + const deadline = Date.now() + 5000 + let stale + while ((stale = staleBlock()) !== null) { + if (Date.now() > deadline) throw new Error(`world not reset: ${stale}`) + // Corrections arrive as block updates or, past 64 changed blocks per + // section, as a chunk resend, so wait on whichever comes first. + await Promise.race([ + onceWithCleanup(bot.world, 'blockUpdate', { timeout: 1000 }), + onceWithCleanup(bot.world, 'chunkColumnLoad', { timeout: 1000 }) + ]).catch(() => {}) + } } async function placeBlock (slot, position) { From fcc0eadade4620e33ed76d429265cec3b75905e9 Mon Sep 17 00:00:00 2001 From: U9G Date: Sun, 30 Aug 2026 21:15:24 -0400 Subject: [PATCH 3/4] test: finish the sign interact in-test and let the reset self-heal desyncs The sign test fired bot.activateBlock unawaited from a setTimeout and resolved immediately, so the use_item_on could land in the same server tick as the next reset's fills. On 1.20+ (editable signs) destroying a sign in the tick of its own editor interact makes the editor's out-of-band sign re-broadcast race the fill's air change: the client keeps the sign, the server has air, and no correction ever comes - packet traces show the sign re-asserted after the fills with no air update for that position. The strict reset check then fails the next test with 'world not reset ... warped_sign'. Await the interact, and teach the reset to recover from any such client/server desync: when a block stays stale past 1.5s, two forced /setblock changes (bedrock, then the expected block) make the server rebroadcast it regardless of which side is wrong. Waiting for an editor-reopen ack instead is not an option - only 1.20+ reopens the editor on interact, so that wait times out on every older version. --- test/externalTests/plugins/testCommon.js | 20 ++++++++++++++++---- test/externalTests/sign.js | 11 ++++++++--- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/test/externalTests/plugins/testCommon.js b/test/externalTests/plugins/testCommon.js index 1d8d4b75b2..dfd108ecf5 100644 --- a/test/externalTests/plugins/testCommon.js +++ b/test/externalTests/plugins/testCommon.js @@ -108,21 +108,33 @@ function inject (bot, wrap) { for (let dx = -5; dx <= 5; dx++) { for (let dz = -5; dz <= 5; dz++) { const block = bot.blockAt(new Vec3(center.x + dx, realY, center.z + dz)) - if (!block || block.name !== want) return `${center.x + dx},${realY},${center.z + dz} is ${block?.name ?? 'unloaded'}, expected ${want}` + if (!block || block.name !== want) { + return { pos: `${center.x + dx} ${realY} ${center.z + dz}`, want, desc: `${center.x + dx},${realY},${center.z + dz} is ${block?.name ?? 'unloaded'}, expected ${want}` } + } } } } return null } const deadline = Date.now() + 5000 + let resyncAt = Date.now() + 1500 let stale while ((stale = staleBlock()) !== null) { - if (Date.now() > deadline) throw new Error(`world not reset: ${stale}`) + if (Date.now() > deadline) throw new Error(`world not reset: ${stale.desc}`) + if (Date.now() > resyncAt) { + // A test can leave the client desynced on a block the server no + // longer has (e.g. a sign destroyed in the tick of its own editor + // interact), and then no correction ever comes. Two real changes + // force the server to rebroadcast the block either way. + bot.chat(`/setblock ${stale.pos} bedrock`) + bot.chat(`/setblock ${stale.pos} ${stale.want}`) + resyncAt = Date.now() + 1500 + } // Corrections arrive as block updates or, past 64 changed blocks per // section, as a chunk resend, so wait on whichever comes first. await Promise.race([ - onceWithCleanup(bot.world, 'blockUpdate', { timeout: 1000 }), - onceWithCleanup(bot.world, 'chunkColumnLoad', { timeout: 1000 }) + onceWithCleanup(bot.world, 'blockUpdate', { timeout: 500 }), + onceWithCleanup(bot.world, 'chunkColumnLoad', { timeout: 500 }) ]).catch(() => {}) } } diff --git a/test/externalTests/sign.js b/test/externalTests/sign.js index 27c35d24f1..dc705e4d4a 100644 --- a/test/externalTests/sign.js +++ b/test/externalTests/sign.js @@ -23,9 +23,14 @@ module.exports = () => async (bot) => { assert.strictEqual(sign.signText.trimEnd(), '1\n2\n3') if (sign.blockEntity) { - // Check block update - bot.activateBlock(sign) - assert.notStrictEqual(sign.blockEntity, undefined) + // Awaited so the interact is at least on the wire before the next + // test's reset; on 1.20+ (editable signs) it can still share a tick + // with the reset fills and desync the client, which the reset's + // resync path repairs. + resolve(bot.activateBlock(sign).then(() => { + assert.notStrictEqual(sign.blockEntity, undefined) + })) + return } resolve() From 3481b11229c2b4e4d0e6a2e488847de3a853433c Mon Sep 17 00:00:00 2001 From: u9g Date: Sun, 6 Sep 2026 09:09:34 -0400 Subject: [PATCH 4/4] creative: make the stats confirm queue explicit --- lib/plugins/creative.js | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/lib/plugins/creative.js b/lib/plugins/creative.js index e5880d2cdc..2b894e4c6b 100644 --- a/lib/plugins/creative.js +++ b/lib/plugins/creative.js @@ -20,26 +20,32 @@ function inject (bot) { const creativeSlotsUpdates = [] - // FIFO: the server answers stats requests in packet-arrival order, so each - // statistics packet settles the oldest waiter, and everything written - // before that waiter's request is processed by then. - const statsConfirms = [] - bot._client.on('statistics', () => statsConfirms.shift()?.()) + // The server answers client_command stats requests in the order it received + // them, so each statistics packet belongs to the oldest pending request. + // Anything written before that request has been processed by then. + const pendingStatsRequests = [] + + bot._client.on('statistics', () => { + const oldest = pendingStatsRequests.shift() + if (oldest) oldest.answered() + }) function confirmServerProcessed (timeoutMs) { return new Promise((resolve) => { + const request = { + answered () { + clearTimeout(timer) + resolve() + } + } // Timing out resolves as success: a server that never answers stats // degrades to the previous fixed-wait behavior, never a hang. const timer = setTimeout(() => { - const i = statsConfirms.indexOf(settle) - if (i !== -1) statsConfirms.splice(i, 1) + const i = pendingStatsRequests.indexOf(request) + if (i !== -1) pendingStatsRequests.splice(i, 1) resolve() }, timeoutMs) - function settle () { - clearTimeout(timer) - resolve() - } - statsConfirms.push(settle) + pendingStatsRequests.push(request) bot._client.write('client_command', bot.supportFeature('respawnIsPayload') ? { payload: 1 } : { actionId: 1 }) }) }