Skip to content

fix(ping): reject when the connection closes before a status response - #1514

Open
u9g wants to merge 1 commit into
masterfrom
fix-ping-closed-connection
Open

fix(ping): reject when the connection closes before a status response#1514
u9g wants to merge 1 commit into
masterfrom
fix-ping-closed-connection

Conversation

@u9g

@u9g u9g commented Aug 22, 2026

Copy link
Copy Markdown
Member

Problem

ping() settles on exactly three things: server_info, ping, or a client error. A server that accepts the connection and then closes it without answering is invisible to all three — the promise sits until closeTimeout (120 s by default) and then rejects with ETIMEDOUT, blaming a timeout that never actually happened, long after the server already told us it wasn't going to answer.

This isn't hypothetical: vanilla servers do it on every startup. A status request is closed without a reply — no packet, just a FIN — until the server has built the status snapshot it serves, and that doesn't happen until startup has fully finished, slightly after the Done (…)! line is logged.

So pinging a server the moment it reports being up can land in that gap. Probing a real server every 25 ms across a boot: 26.1 closes status connections until 52 ms after the Done line and first answers at 77 ms (it saves every dimension in between), while 1.21.8 is already answering 11 ms before it.

This surfaces downstream as mineflayer's external tests intermittently failing on 26.1 with a bare mocha hook timeout (PrismarineJS/mineflayer#3976).

Fix

Reject on end when no status has arrived yet:

client.on('end', function () {
  if (gotStatus) return
  clearTimeout(closeTimer)
  reject(new Error('Connection closed before the server sent a status response'))
})

If the status did arrive and the server closes before the pong, the existing noPongTimeout path already resolves with the status — that case is deliberately left alone, so this is only ever converting a 120 s misleading timeout into an immediate, accurate error.

Test

test/pingTest.js points a ping at a socket server that accepts the handshake and hangs up. Without the fix it fails after the full closeTimeout with ETIMEDOUT; with it, it fails in ~400 ms with a message that says what happened.

✔ fails as soon as the server closes the connection without answering (408ms)

u9g added a commit to PrismarineJS/mineflayer that referenced this pull request Aug 22, 2026
…wers

The 26.1 external tests fail intermittently in CI with a bare
`Timeout of 120000ms exceeded` in the `before all` hook, after printing
`pinging 26.1 port : N` and never printing `pong`. It is the only version
this happens on: over the last 40 failed CI runs the signature appears 4
times, all on 26.1, never on the other 27 versions.

The server is not answering status yet. `MinecraftServer.runServer()` only
builds its status snapshot after `initServer()` returns, and until that
snapshot exists `ServerHandshakePacketListenerImpl` closes status
connections without replying. 26.1 widened that window: its `initServer()`
logs `Done (...)!` and then calls `saveEverything(false, true, true)`, a full
save of every dimension, before returning — 1.21.8's returns right after the
log. That is the `Saving chunks for level ...` line only 26.1 prints after
`Done`. Since minecraft-wrap calls back on the `Done` line, we ping inside
the window: probing every 25ms across a boot, 26.1 hangs up on status until
52ms after `Done` and first answers at 77ms, while 1.21.8 already answers
11ms before it.

Retry the ping instead of trusting the first one, with a closeTimeout short
enough that a failed attempt is worth retrying. A fixed sleep would be tuned
to today's `saveEverything` call and would break the next time a version adds
work after `Done`.

mc.ping reports this as ETIMEDOUT after its whole closeTimeout, because it
never notices the connection closing; PrismarineJS/node-minecraft-protocol#1514
fixes that upstream and makes each failed attempt immediate.
@u9g
u9g force-pushed the fix-ping-closed-connection branch from c261f43 to 79bae6f Compare August 22, 2026 19:53
u9g added a commit to PrismarineJS/mineflayer that referenced this pull request Aug 22, 2026
…wers

The 26.1 external tests fail intermittently in CI with a bare
`Timeout of 120000ms exceeded` in the `before all` hook, after printing
`pinging 26.1 port : N` and never printing `pong`. It is the only version
this happens on: over the last 40 failed CI runs the signature appears 4
times, all on 26.1, never on the other 27 versions.

The server is not answering status yet. `MinecraftServer.runServer()` only
builds its status snapshot after `initServer()` returns, and until that
snapshot exists `ServerHandshakePacketListenerImpl` closes status
connections without replying. 26.1 widened that window: its `initServer()`
logs `Done (...)!` and then calls `saveEverything(false, true, true)`, a full
save of every dimension, before returning — 1.21.8's returns right after the
log. That is the `Saving chunks for level ...` line only 26.1 prints after
`Done`. Since minecraft-wrap calls back on the `Done` line, we ping inside
the window: probing every 25ms across a boot, 26.1 hangs up on status until
52ms after `Done` and first answers at 77ms, while 1.21.8 already answers
11ms before it.

Retry the ping instead of trusting the first one, with a closeTimeout short
enough that a failed attempt is worth retrying. A fixed sleep would be tuned
to today's `saveEverything` call and would break the next time a version adds
work after `Done`.

mc.ping reports this as ETIMEDOUT after its whole closeTimeout, because it
never notices the connection closing; PrismarineJS/node-minecraft-protocol#1514
fixes that upstream and makes each failed attempt immediate.
ping() settles on exactly three things: server_info, ping, or a client
error. A server that accepts the connection and then closes it without
answering is invisible to all three, so the promise sits until closeTimeout
(120s by default) and then rejects with ETIMEDOUT — a timeout that never
actually happened, reported long after the server already told us.

Vanilla servers do this on every startup: a status request is closed without
a reply until the server has built the status snapshot it serves, which does
not happen until startup has finished — slightly after the "Done" line is
logged. Probing a server every 25ms across a boot, 26.1 closes status
connections until 52ms past that line and first answers at 77ms, while
1.21.8 already answers 11ms before it. So pinging a server as soon as it
reports being up lands in that gap often enough to matter.

Reject on 'end' when no status has arrived yet. If the status did arrive and
the server closes before the pong, the existing noPongTimeout path already
resolves with it, so that case is left alone.
@u9g
u9g force-pushed the fix-ping-closed-connection branch from 79bae6f to 66bcc35 Compare August 22, 2026 19:58
u9g added a commit to PrismarineJS/mineflayer that referenced this pull request Aug 22, 2026
…wers

The 26.1 external tests fail intermittently in CI with a bare
`Timeout of 120000ms exceeded` in the `before all` hook, after printing
`pinging 26.1 port : N` and never printing `pong`. It is the only version
this happens on: over the last 40 failed CI runs the signature appears 4
times, all on 26.1, never on the other 27 versions.

The server is not answering status yet. A server closes status connections
without replying until it has built the status snapshot it serves, which
does not happen until startup has fully finished — slightly after the "Done"
line is logged. 26.1 widened that gap: it saves every dimension after
logging "Done" and before finishing startup, which is the
`Saving chunks for level ...` line only 26.1 prints after `Done`. Since
minecraft-wrap calls back on that line, we ping inside the gap: probing
every 25ms across a boot, 26.1 closes status connections until 52ms past
`Done` and first answers at 77ms, while 1.21.8 already answers 11ms before
it.

Retry the ping instead of trusting the first one, with a closeTimeout short
enough that a failed attempt is worth retrying. A fixed sleep would be tuned
to today's startup sequence and would break the next time a version adds
work after `Done`.

mc.ping reports this as ETIMEDOUT after its whole closeTimeout, because it
never notices the connection closing; PrismarineJS/node-minecraft-protocol#1514
fixes that upstream and makes each failed attempt immediate.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant