diff --git a/server/src/app.js b/server/src/app.js index 82675a8..a5c9068 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -29,6 +29,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console } let manager; const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: (host) => (host ? manager.usedPortsOn(host) : manager.usedPorts()), + getStarting: (host, exclude) => manager.startingOn(host, exclude), }); manager = new ServerManager({ db, service, adapter, config, logger }); manager.init(); diff --git a/server/src/matchService.js b/server/src/matchService.js index 72c3808..36e4a77 100644 --- a/server/src/matchService.js +++ b/server/src/matchService.js @@ -555,6 +555,9 @@ export class MatchService extends EventEmitter { if (!server || !safeEqual(String(key ?? ''), server.config_key)) { throw new HttpError(403, 'Unbekannter Log-Schlüssel', 'forbidden'); } + // Erst zählen, wenn die Map eingerichtet ist. Während des Einrichtens kommen noch Zeilen vom vorigen + // Spiel auf dem Server (z. B. dem Ende der Messerrunde), die nicht zur neuen Map gehören. + if (server.status !== 'configured') return; this.applyLiveLog(matchId, splitLogBody(body)); } diff --git a/server/src/servers/dockerAdapter.js b/server/src/servers/dockerAdapter.js index 18f081c..8c77cf7 100644 --- a/server/src/servers/dockerAdapter.js +++ b/server/src/servers/dockerAdapter.js @@ -141,6 +141,8 @@ export class DockerServerAdapter { this.runDocker = runDocker; // nur für Tests this.lanMode = !options.gslt; this.startQueue = Promise.resolve(); + // Starts, die gerade warten oder deren Container noch SteamCMD laufen lässt + this.pendingStarts = 0; if (!options.host) throw new Error('SERVER_ADAPTER=docker benötigt CS2_PUBLIC_HOST'); } @@ -169,8 +171,13 @@ export class DockerServerAdapter { async start(args) { // In die Warteschlange: erst starten, wenn der vorige Container seinen Update-Check hinter sich hat. const previous = this.startQueue; - let release; - this.startQueue = new Promise((resolve) => { release = resolve; }); + let releaseQueue; + this.startQueue = new Promise((resolve) => { releaseQueue = resolve; }); + this.pendingStarts += 1; + const release = () => { + this.pendingStarts -= 1; + releaseQueue(); + }; await previous; let result; try { @@ -368,10 +375,11 @@ export class DockerServerAdapter { * immer an denselben Host. Ohne CS2_DOCKER_HOSTS gibt es genau einen Host: den bisherigen. */ export class DockerHostPool { - constructor(options, { getUsedPorts, runDocker } = {}) { + constructor(options, { getUsedPorts, getStarting, runDocker } = {}) { this.name = 'docker'; this.simulated = false; this.depsGetUsedPorts = getUsedPorts; + this.depsGetStarting = getStarting; this.runDocker = runDocker; this.options = options; this.diskCache = new Map(); @@ -594,19 +602,31 @@ export class DockerHostPool { return new Set(this.files.running ? this.files.results.filter((r) => r.state === 'running').map((r) => r.name) : []); } - pickHost() { + /** + * Wie viele Server auf einem Host gerade hochfahren: Starts in der Warteschlange dieses Prozesses, und + * (nach einem Neustart der App) Server, die laut Datenbank noch starten. Ein Container lädt beim Start + * per SteamCMD, nach einem großen CS2-Update dauert das lange; weitere Starts dort müssten so lange warten. + */ + startingOn(host, exclude) { + const queued = this.adapters.get(host.name)?.pendingStarts ?? 0; + const stored = this.depsGetStarting?.(host.publicHost, exclude) ?? 0; + return Math.max(queued, stored); + } + + pickHost(exclude) { const updating = this.updatingHosts(); const best = this.hosts .filter((h) => !updating.has(h.name)) - .map((h) => ({ host: h, free: this.freeSlots(h) })) + .map((h) => ({ host: h, free: this.freeSlots(h), starting: this.startingOn(h, exclude) })) .filter((x) => x.free > 0) - .sort((a, b) => b.free - a.free)[0]; + // Zuerst Hosts, auf denen nichts hochfährt, dann die mit den meisten freien Plätzen + .sort((a, b) => a.starting - b.starting || b.free - a.free)[0]; if (!best) throw new Error(updating.size ? 'Kein Docker-Host frei, auf den anderen werden gerade die Spieldateien aktualisiert' : 'Kein Docker-Host mit freien Ports'); return best.host; } async start(args) { - return this.adapters.get(this.pickHost().name).start(args); + return this.adapters.get(this.pickHost(args?.matchId).name).start(args); } adapterFor(handle) { diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js index f385190..469bfd0 100644 --- a/server/src/servers/serverManager.js +++ b/server/src/servers/serverManager.js @@ -258,6 +258,14 @@ export class ServerManager { return result; } + /** Server (Matches und Messer-Server), die auf einem Host gerade hochfahren. `exclude`: der, der gerade startet. */ + startingOn(host, exclude = null) { + return this.db.prepare(` + SELECT (SELECT COUNT(*) FROM servers WHERE host = @host AND status = 'starting' AND handle IS NOT NULL AND match_id IS NOT @exclude) + + (SELECT COUNT(*) FROM knife_servers WHERE host = @host AND status = 'starting' AND handle IS NOT NULL AND id IS NOT @exclude) AS n + `).get({ host, exclude }).n; + } + /** Ports, die auf einem bestimmten Docker-Host belegt sind. */ usedPortsOn(host) { const used = new Set(); @@ -500,7 +508,12 @@ export class ServerManager { this.simulator.stop(matchId); this.service.resetLiveScore(matchId, seriesMap.number); // Lief vorher die Messerrunde auf diesem Server, deren Einstellungen zurücknehmen - if (row.knife_at) plan.afterMapChange = [...KNIFE_UNDO_COMMANDS, ...plan.afterMapChange]; + if (row.knife_at) { + // Eine noch laufende Messerrunde zuerst anhalten: sonst endet sie (mp_maxrounds 1) mitten im Einrichten, + // und ihr Ergebnis würde als Ende der ersten Map gezählt + plan.beforeMapChange = ['mp_warmup_pausetimer 1', 'mp_warmup_start', ...plan.beforeMapChange]; + plan.afterMapChange = [...KNIFE_UNDO_COMMANDS, ...plan.afterMapChange]; + } try { await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan }); } catch (err) { diff --git a/server/test/api.test.js b/server/test/api.test.js index 78f5ffd..0ca8d5b 100644 --- a/server/test/api.test.js +++ b/server/test/api.test.js @@ -753,7 +753,19 @@ test('Messerrunde auf dem eigenen Match-Server: Verbindungsdaten in der Lobby, d const capA = snap.teams.A.players.find((p) => p.isCaptain)?.nickname ?? 'a1'; const capB = snap.teams.B.players.find((p) => p.isCaptain)?.nickname ?? 'b1'; await playVeto(call, id, { A: tokens[capA], B: tokens[capB] }); + // Das Ende der Messerrunde kommt während des Einrichtens an: darf nicht als Ende von Map 1 zählen + await waitFor(() => app.manager.row(id).status === 'configuring', { label: 'Map 1 wird eingerichtet' }); + const firstMap = app.service.seriesMaps(id)[0].map; + const knifeEnd = ['Team "CT" triggered "SFUI_Notice_Target_Saved" (CT "1") (T "0")', 'World triggered "Round_End"', + `Game Over: scrimcomp2v2 ${firstMap} score 1:0 after 2 min`].join('\n'); + const key = app.manager.row(id).config_key; + assert.equal((await fetch(`http://127.0.0.1:${app.server.address().port}/api/logs/${id}/${key}`, { method: 'POST', body: knifeEnd })).status, 200); await waitFor(() => app.manager.row(id).status === 'configured', { label: 'Match eingerichtet' }); + const map1 = app.service.seriesMaps(id)[0]; + assert.notEqual(map1.status, 'done', 'Messerrunde zählt nicht als Map-Ende'); + assert.equal(map1.score_a, 0); + assert.equal(app.service.getMatch(id).current_map_number, 1); + assert.deepEqual(plans.at(-1).beforeMapChange.slice(0, 2), ['mp_warmup_pausetimer 1', 'mp_warmup_start'], 'laufende Messerrunde wird zuerst angehalten'); const row = app.manager.row(id); assert.equal(row.handle, handle, 'derselbe Server'); assert.equal(row.knife_at, null); diff --git a/server/test/dockerHostPool.test.js b/server/test/dockerHostPool.test.js index 3ee030d..25bc3c4 100644 --- a/server/test/dockerHostPool.test.js +++ b/server/test/dockerHostPool.test.js @@ -107,3 +107,29 @@ test('Alter Handle ohne passenden Host-Namen wird über die Adresse dem richtige assert.equal(p.adapterFor({ hostName: 'default', rconHost: '10.0.0.2' }).options.name, 'b'); assert.throws(() => p.adapterFor({ hostName: 'weg', rconHost: '10.9.9.9' }), /nicht \(mehr\) konfiguriert/); }); + +test('Neue Server meiden Hosts, auf denen gerade ein Server hochfährt (SteamCMD)', async () => { + const starting = { '10.0.0.2': 1 }; + const p = new DockerHostPool(options([hostA, hostB]), { + getUsedPorts: () => new Set(), + getStarting: (publicHost, exclude) => (exclude === 'self' ? 0 : starting[publicHost] ?? 0), + runDocker: fakeDocker().run, + }); + // b hat mehr Plätze, aber dort lädt gerade ein Container: a gewinnt + assert.equal(p.pickHost().name, 'a'); + // Der hochfahrende Server selbst zählt nicht gegen seinen eigenen Neustart + assert.equal(p.pickHost('self').name, 'b'); + // Warteschlange im Prozess zählt genauso + starting['10.0.0.2'] = 0; + p.adapters.get('b').pendingStarts = 1; + assert.equal(p.pickHost().name, 'a'); + // Starts zählen hoch und nach dem Update-Check wieder herunter + const a = p.adapters.get('a'); + let done; + a.waitForUpdateCheck = () => new Promise((r) => { done = r; }); + await a.start({ matchId: 'm1', mode: 'competitive', secrets }); + assert.equal(a.pendingStarts, 1); + done(); + await new Promise((r) => setImmediate(r)); + assert.equal(a.pendingStarts, 0); +});