From d36dcacd3912e54bfe07c88ed91d372f66984910 Mon Sep 17 00:00:00 2001 From: MrEgosar Date: Sun, 1 Nov 2026 18:50:12 +0100 Subject: [PATCH] =?UTF-8?q?Fix:=20Starts=20meiden=20hochfahrende=20Hosts,?= =?UTF-8?q?=20Messerrunde=20z=C3=A4hlt=20nicht=20als=20Map-Ende?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue Server meiden Hosts, auf denen gerade ein Container per SteamCMD lädt (Warteschlange im Prozess und laufende Starts laut Datenbank). Vorher warteten alle Starts hinter einem langen Download, obwohl andere Hosts frei waren. - Vor dem Einrichten des Matches wird eine laufende Messerrunde in die Aufwärmphase gesetzt, und Logzeilen zählen erst, wenn die Map eingerichtet ist. Vorher wurde das Ende der Messerrunde als Ende von Map 1 gewertet und die nächste Map automatisch geladen. --- server/src/app.js | 1 + server/src/matchService.js | 3 +++ server/src/servers/dockerAdapter.js | 34 +++++++++++++++++++++++------ server/src/servers/serverManager.js | 15 ++++++++++++- server/test/api.test.js | 12 ++++++++++ server/test/dockerHostPool.test.js | 26 ++++++++++++++++++++++ 6 files changed, 83 insertions(+), 8 deletions(-) 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); +});