From c43845356a87fcd00a0d7bfb959ca29b088cd951 Mon Sep 17 00:00:00 2001 From: Frederik Spirgi Date: Wed, 9 Sep 2026 10:59:30 +0200 Subject: [PATCH 1/3] try to fix this spaghetti of a code --- lib/core.js | 9 +++++++-- lib/lobbies.js | 4 ++-- lib/repository.js | 7 +++++-- public/client.js | 7 ++++--- public/frontend_poker.js | 5 +++++ 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/lib/core.js b/lib/core.js index 04d1459..912bccf 100644 --- a/lib/core.js +++ b/lib/core.js @@ -1,4 +1,4 @@ -const crypto = require('node:crypto'); +const crypto = require('node:crypto'); class AppError extends Error { constructor(code, message, status = 400) { super(message); this.code = code; this.status = status; } } @@ -16,4 +16,9 @@ class KeyedQueue { async drain() { await Promise.all([...this.tails.values()]); } } const hash = value => crypto.createHash('sha256').update(value).digest('hex'); -module.exports = {AppError, KeyedQueue, hash}; +function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])); + return value; +} +module.exports = {AppError, KeyedQueue, hash, canonical}; diff --git a/lib/lobbies.js b/lib/lobbies.js index e889b0b..4e50c52 100644 --- a/lib/lobbies.js +++ b/lib/lobbies.js @@ -1,6 +1,6 @@ const crypto = require('node:crypto'); const {EventEmitter} = require('node:events'); -const {KeyedQueue, AppError, hash} = require('./core'); +const {KeyedQueue, AppError, hash, canonical} = require('./core'); const engine = require('./engine'); function createLobbyService(repository, config) { @@ -38,7 +38,7 @@ function createLobbyService(repository, config) { return queue.run([...(membershipMutation ? ['membership'] : []), ...(username ? [`account:${username}`] : []), ...(message.lobbyId ? [`lobby:${message.lobbyId}`] : [])], async () => { const id = `${username || 'system'}:${message.requestId}`; - const fingerprint = hash(JSON.stringify(message)); + const fingerprint = hash(JSON.stringify(canonical(message))); const prior = await repository.command(id); if (prior) { if (prior.fingerprint !== fingerprint) throw new AppError('IDEMPOTENCY_CONFLICT', 'Request ID was reused', 409); diff --git a/lib/repository.js b/lib/repository.js index 4d915e1..52b52f5 100644 --- a/lib/repository.js +++ b/lib/repository.js @@ -47,7 +47,7 @@ async function createRepository(filename, {beforeCommit = async () => {}} = {}) `); if (old) { // Fails and rolls back rather than silently rounding or losing invalid balances. - await tx.exec('INSERT INTO users(username, password_hash, money) SELECT username, password_hash, money FROM legacy_users; DROP TABLE legacy_users;'); + await tx.exec('INSERT INTO users(username, password_hash, money) SELECT username, password_hash, money FROM legacy_users; INSERT INTO ledger(command_id,username,delta,reason) SELECT 'schema-v1:' || username, username, money, 'Opening balance migration' FROM users; DROP TABLE legacy_users;'); } await tx.exec('PRAGMA user_version = 1'); }); @@ -58,7 +58,10 @@ async function createRepository(filename, {beforeCommit = async () => {}} = {}) user: username => read(tx => tx.get('SELECT username, password_hash AS passwordHash, session_hash AS sessionHash, money FROM users WHERE username = ?', username)), users: () => read(tx => tx.all('SELECT username, money FROM users ORDER BY money DESC, username ASC')), async register(username, passwordHash, sessionHash) { - try { await transaction(tx => tx.run('INSERT INTO users(username,password_hash,session_hash) VALUES(?,?,?)', username, passwordHash, sessionHash)); } + try { await transaction(async tx => { + await tx.run('INSERT INTO users(username,password_hash,session_hash) VALUES(?,?,?)', username, passwordHash, sessionHash); + await tx.run('INSERT INTO ledger(command_id,username,delta,reason) VALUES(?,?,200,?)', `register:${username}`, username, 'Starting chips'); + }); } catch (error) { if (error.code === 'SQLITE_CONSTRAINT') throw new AppError('USERNAME_EXISTS', 'Username already exists', 409); throw error; } }, setSession: (username, sessionHash) => transaction(tx => tx.run('UPDATE users SET session_hash = ? WHERE username = ?', sessionHash, username)), diff --git a/public/client.js b/public/client.js index c1f1d62..6133001 100644 --- a/public/client.js +++ b/public/client.js @@ -1,6 +1,6 @@ 'use strict'; window.apiRequest = async function (url, options = {}) { - const response = await fetch(url, {credentials: 'same-origin', ...options}); + const response = await fetch(url, {credentials: 'same-origin', signal: AbortSignal.timeout(5000), ...options}); const data = await response.json().catch(() => ({})); if (response.status === 401) { localStorage.removeItem('activeLobbyId'); @@ -45,15 +45,16 @@ window.RealtimeClient = class extends EventTarget { } this.dispatchEvent(new CustomEvent('message', {detail: message})); }); - socket.addEventListener('error', () => this.status('Connection interrupted. Reconnecting…')); + socket.addEventListener('error', () => this.status('Connection interrupted. Reconnecting…')); socket.addEventListener('close', async event => { clearTimeout(timeout); this.ready = false; if (this.stopped) return; - this.status('Connection interrupted. Reconnecting…'); + this.status('Connection interrupted. Reconnecting…'); if (event.code === 1008 && /Session/.test(event.reason)) { this.close(); localStorage.removeItem('activeLobbyId'); location.assign('/login'); return; } try { await apiRequest('/verify', {method: 'POST'}); } catch { /* apiRequest redirects expired sessions; network outages are retried. */ } + if (this.stopped) return; if (++this.attempts > 5) { this.close(); this.status('Could not reconnect. Reload to try again.'); return; } this.reconnectTimer = setTimeout(() => this.open(), Math.min(500 * 2 ** (this.attempts - 1), 5000)); }); diff --git a/public/frontend_poker.js b/public/frontend_poker.js index 2d2b95a..d29c69d 100644 --- a/public/frontend_poker.js +++ b/public/frontend_poker.js @@ -248,6 +248,11 @@ document.addEventListener("DOMContentLoaded", () => { } if (message.type === "auth:error" || message.type === "poker:error" || message.type === "error") { + if (["NOT_SEATED", "LOBBY_NOT_FOUND"].includes(message.code)) { + localStorage.removeItem("activeLobbyId"); + location.assign("/"); + return; + } showError(message.message || "Poker connection failed"); } }); From c35dd9bc45f944537b1c1f63d52e16980f4acba8 Mon Sep 17 00:00:00 2001 From: Frederik Spirgi Date: Wed, 9 Sep 2026 11:00:17 +0200 Subject: [PATCH 2/3] try to fix this spaghetti of a code --- lib/repository.js | 2 +- test/transactions.test.js | 30 +++++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/repository.js b/lib/repository.js index 52b52f5..5c294b8 100644 --- a/lib/repository.js +++ b/lib/repository.js @@ -47,7 +47,7 @@ async function createRepository(filename, {beforeCommit = async () => {}} = {}) `); if (old) { // Fails and rolls back rather than silently rounding or losing invalid balances. - await tx.exec('INSERT INTO users(username, password_hash, money) SELECT username, password_hash, money FROM legacy_users; INSERT INTO ledger(command_id,username,delta,reason) SELECT 'schema-v1:' || username, username, money, 'Opening balance migration' FROM users; DROP TABLE legacy_users;'); + await tx.exec(`INSERT INTO users(username, password_hash, money) SELECT username, password_hash, money FROM legacy_users; INSERT INTO ledger(command_id,username,delta,reason) SELECT 'schema-v1:' || username, username, money, 'Opening balance migration' FROM users; DROP TABLE legacy_users;`); } await tx.exec('PRAGMA user_version = 1'); }); diff --git a/test/transactions.test.js b/test/transactions.test.js index 9c1c10f..c6715fd 100644 --- a/test/transactions.test.js +++ b/test/transactions.test.js @@ -32,7 +32,8 @@ test('accepted actions persist once; failed commits leave both memory and databa await assert.rejects(api.lobbies.command('b', message), /Injected/); assert.equal(JSON.stringify(api.lobbies.get(lobbyId)), before); assert.equal((await api.repository.user('b')).money, 190); - await Promise.all([api.lobbies.command('b', message), api.lobbies.command('b', message)]); + const reordered = {payload: message.payload, action: message.action, requestId: message.requestId, lobbyId: message.lobbyId, type: message.type}; + await Promise.all([api.lobbies.command('b', message), api.lobbies.command('b', reordered)]); assert.equal((await api.repository.user('b')).money, 170); assert.equal(api.lobbies.get(lobbyId).game.pot, 50); await assert.rejects(api.lobbies.command('b', {...message, action: 'fold'}), error => error.code === 'IDEMPOTENCY_CONFLICT'); @@ -97,3 +98,30 @@ test('reopening a database refunds interrupted escrow exactly once', async t => assert.equal((await restarted.user('a')).money, 200); assert.equal((await restarted.user('b')).money, 200); } finally {await restarted.close();} }); +test('account membership and host starts serialize even when requests arrive together', async t => { + const api = await createApplication({secret,databasePath:':memory:',bcryptRounds:4,intermissionMs:60000}); + t.after(()=>api.close()); + for(const username of ['a','b']) await api.sessions.register({username,password:'password'}); + const creates=await Promise.allSettled([command(api.lobbies,'a','lobby:create'),command(api.lobbies,'a','lobby:create')]); + assert.equal(creates.filter(item=>item.status==='fulfilled').length,1); + const {lobbyId}=creates.find(item=>item.status==='fulfilled').value; + await command(api.lobbies,'b','lobby:join',{lobbyId}); + const starts=await Promise.allSettled([command(api.lobbies,'a','poker:start',{lobbyId}),command(api.lobbies,'a','poker:start',{lobbyId})]); + assert.equal(starts.filter(item=>item.status==='fulfilled').length,1); +}); +test('a departure waits for an in-flight action commit and cannot overwrite its balance', async t => { + let pause=null; + const {api,lobbyId}=await setup(t,{repositoryHooks:{beforeCommit:async()=>{if(pause) await pause();}}}); + let reached,release; + const entered=new Promise(resolve=>{reached=resolve;}); + const gate=new Promise(resolve=>{release=resolve;}); + pause=async()=>{reached();await gate;}; + const action=command(api.lobbies,'b','poker:action',{lobbyId,action:'bet',payload:{amount:20}}); + await entered; + const departure=command(api.lobbies,'b','lobby:leave',{lobbyId}); + pause=null;release(); + await Promise.all([action,departure]); + assert.equal((await api.repository.user('b')).money,170); + assert.equal(api.lobbies.get(lobbyId).game.contributions.b,30); + const totals=await api.repository.totals();assert.equal(totals.users+totals.escrow,600); +}); From 45eef9ef5c9bdd366d138db0d25e53185b909527 Mon Sep 17 00:00:00 2001 From: Frederik Spirgi Date: Wed, 9 Sep 2026 11:02:31 +0200 Subject: [PATCH 3/3] try to fix this spaghetti of a code --- gambling.js | 5 +++-- lib/lobbies.js | 2 +- public/client.js | 4 ++-- public/frontend_poker.js | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/gambling.js b/gambling.js index dbd41f4..d65444c 100644 --- a/gambling.js +++ b/gambling.js @@ -1,4 +1,4 @@ -const http = require('node:http'); +const http = require('node:http'); const {configuration} = require('./lib/config'); const {createRepository} = require('./lib/repository'); const {createSessionService} = require('./lib/sessions'); @@ -33,7 +33,8 @@ async function createApplication(options = {}) { if (require.main === module) { require('dotenv').config(); createApplication().then(async application => { - const address = await application.listen(); + let address; + try {address = await application.listen();} catch (error) {await application.close(); throw error;} console.log(`Server listening at http://${address.address}:${address.port}`); for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => application.close().catch(error => {console.error(error); process.exitCode = 1;})); }).catch(error => {console.error(error.message); process.exitCode = 1;}); diff --git a/lib/lobbies.js b/lib/lobbies.js index 4e50c52..f4e49fa 100644 --- a/lib/lobbies.js +++ b/lib/lobbies.js @@ -37,7 +37,7 @@ function createLobbyService(repository, config) { const membershipMutation = ['lobby:create', 'lobby:join', 'lobby:leave'].includes(message.type); return queue.run([...(membershipMutation ? ['membership'] : []), ...(username ? [`account:${username}`] : []), ...(message.lobbyId ? [`lobby:${message.lobbyId}`] : [])], async () => { - const id = `${username || 'system'}:${message.requestId}`; + const id = `${username ? `user:${username}` : 'system'}:${message.requestId}`; const fingerprint = hash(JSON.stringify(canonical(message))); const prior = await repository.command(id); if (prior) { diff --git a/public/client.js b/public/client.js index 6133001..00f75c3 100644 --- a/public/client.js +++ b/public/client.js @@ -45,11 +45,11 @@ window.RealtimeClient = class extends EventTarget { } this.dispatchEvent(new CustomEvent('message', {detail: message})); }); - socket.addEventListener('error', () => this.status('Connection interrupted. Reconnecting…')); + socket.addEventListener('error', () => this.status('Connection interrupted. Reconnecting...')); socket.addEventListener('close', async event => { clearTimeout(timeout); this.ready = false; if (this.stopped) return; - this.status('Connection interrupted. Reconnecting…'); + this.status('Connection interrupted. Reconnecting...'); if (event.code === 1008 && /Session/.test(event.reason)) { this.close(); localStorage.removeItem('activeLobbyId'); location.assign('/login'); return; } diff --git a/public/frontend_poker.js b/public/frontend_poker.js index d29c69d..46989a6 100644 --- a/public/frontend_poker.js +++ b/public/frontend_poker.js @@ -101,7 +101,7 @@ document.addEventListener("DOMContentLoaded", () => { if (winner) { const heading = document.createElement("h2"); - heading.textContent = `Winner: ${winner}`; + heading.textContent = `Payout recipients: ${winner}`; showdownPanel.appendChild(heading); }