Gateway: don't let one malformed WS frame kill the process

A single unauthenticated frame could take down the whole gateway:

- `parsed.type?.startsWith('sdk_')` guarded an undefined/null *type value*,
  but not `parsed` itself being null, and not a non-string type. So `null`,
  `{"type":1}` and `{"type":{}}` all parsed fine then threw TypeError in the
  router.
- The throw landed in async handleMessage(), whose promise the websocket
  message callback neither awaited nor caught -> unhandled rejection, which
  Bun treats as fatal (exit 1).

Result: any frame above dropped every bot and SDK session and left /status
unreachable. The engine runs as PID 1 and the gateway was backgrounded
unsupervised, so the container stayed "healthy" with SDK connectivity dead
until a redeploy.

Fixes:
- Validate the envelope before dispatch (non-null object, string `type`);
  reject and close the offending socket.
- Catch the handleMessage promise so a throw can only affect its own socket.
- Supervise the gateway in the entrypoint: respawn with a 2s backoff.
- Add process-level rejection/exception guards as a last-resort net.

Verified against an isolated local gateway: all malformed frames now leave
the gateway up and /status reachable, only the sending socket is closed, and
a concurrent bot+SDK pair keeps routing state.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude committed 2026-07-17 09:03:30 -04:00
1 parent 6013a96218
commit 180b4001d8
2 files changed
+62 -5

No files matched your search

+11 -1
View File
@@ -101,8 +101,18 @@ if [ ! -L /opt/server/engine/data/players ]; then
fi
# === START GATEWAY SERVICE ===
# Supervised: the engine runs as PID 1, so if the gateway dies the container stays
# "healthy" while SDK connectivity is silently dead until a redeploy. Respawn it
# instead, with a short backoff so a crash-on-boot can't spin the CPU.
echo "Starting gateway service on port 7780..."
cd /opt/server/gateway && bun run gateway.ts &
(
cd /opt/server/gateway
while true; do
bun run gateway.ts
echo "[entrypoint] Gateway exited (code $?), restarting in 2s..." >&2
sleep 2
done
) &
# === START GAME SERVER ===
cd /opt/server/engine
+51 -4
View File
@@ -598,12 +598,37 @@ const SyncModule = {
// ============ Message Router ============
/**
* Reject a frame we can't route. The socket is closed rather than ignored: every
* legitimate client sends well-formed envelopes, so a malformed one is either a
* broken client or a probe, and neither should keep an unauthenticated socket open.
*/
function rejectFrame(ws: any, reason: string) {
console.error(`[Gateway] Rejecting frame: ${reason}`);
try {
ws.send(JSON.stringify({ type: 'error', error: `Invalid message: ${reason}` }));
} catch {}
try { ws.close(); } catch {}
}
async function handleMessage(ws: any, data: string) {
let parsed: any;
try {
parsed = JSON.parse(data);
} catch {
console.error('[Gateway] Invalid JSON');
rejectFrame(ws, 'malformed JSON');
return;
}
// Shape-check before dispatch. `null` and `[]` are valid JSON, and a non-string
// `type` survives JSON.parse — both used to reach the router and throw on
// `parsed.type.startsWith(...)`, taking the whole gateway down with them.
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
rejectFrame(ws, 'expected a JSON object');
return;
}
if (typeof parsed.type !== 'string') {
rejectFrame(ws, 'missing or non-string "type"');
return;
}
@@ -619,7 +644,7 @@ async function handleMessage(ws: any, data: string) {
}
// Route based on message type for new connections
if (parsed.type?.startsWith('sdk_')) {
if (parsed.type.startsWith('sdk_')) {
await SyncModule.handleSDKMessage(ws, parsed);
} else if (parsed.type === 'connected' || parsed.type === 'state' || parsed.type === 'actionResult') {
await SyncModule.handleBotMessage(ws, parsed);
@@ -632,6 +657,17 @@ function handleClose(ws: any) {
// ============ Server Setup ============
// Last-resort net. Bun exits(1) on an unhandled rejection by default, so a single
// missed `.catch()` anywhere would drop every bot and SDK session. Individual call
// sites still catch their own errors — this only keeps the router alive if one is
// ever added without one.
process.on('unhandledRejection', (reason) => {
console.error('[Gateway] Unhandled rejection (ignored, gateway stays up):', reason);
});
process.on('uncaughtException', (error) => {
console.error('[Gateway] Uncaught exception (ignored, gateway stays up):', error);
});
console.log(`[Gateway] Starting Gateway Service on port ${GATEWAY_PORT}...`);
const server = Bun.serve({
@@ -743,11 +779,22 @@ Bots: ${botSessions.size} | SDKs: ${sdkSessions.size}
},
message(ws: any, message: string | Buffer) {
handleMessage(ws, message.toString());
// handleMessage is async: without this catch a throw anywhere in a handler
// becomes an unhandled rejection, which Bun treats as fatal (exit 1) and
// takes down every other session with it. One bad frame must only ever
// affect its own socket.
handleMessage(ws, message.toString()).catch((error) => {
console.error('[Gateway] Unhandled error while handling message:', error);
try { ws.close(); } catch {}
});
},
close(ws: any) {
handleClose(ws);
try {
handleClose(ws);
} catch (error) {
console.error('[Gateway] Unhandled error while closing socket:', error);
}
}
}
});