From e3a684ff3bda19e0e5f06513e707aa827161540b Mon Sep 17 00:00:00 2001 From: ILLISIS <92364928+ILLISIS@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:38:07 +1100 Subject: [PATCH] Download each Factorio version only once at a time (#1107) * Download each Factorio version only once at a time Instances starting together each downloaded the new version into the same .tmp file and extracted into the same version directory, corrupting the install. Keep the downloads in progress in a map by target directory and let later callers wait on the existing download. Fixes #1105 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RhMv1AxB8EZdyZoSJhyNmY * Wait for a download in progress whose version looks installed The changelog is extracted partway through the archive, so a server checking during the extraction saw the version as installed and could start the half-extracted install. Treat a version with a download in progress as not installed, so the server waits on that download. Hold the mocked download open in the concurrency tests and wait for the second server to reach the waiting path instead of relying on a delay. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RhMv1AxB8EZdyZoSJhyNmY --------- Co-authored-by: ILLISIS Co-authored-by: Claude Opus 5.5 (1M context) --- packages/host/src/server.ts | 23 +++++++++++---- test/host/server.js | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index 943660be..01f0d772 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -170,6 +170,9 @@ async function downloadAndExtractTar(url: string, targetDir: string) { } } +// Factorio downloads in progress by target directory, shared by every server in the process +const downloadsInProgress = new Map>(); + /** * Find factorio data directory of the given version * @@ -1001,8 +1004,10 @@ export class FactorioServer extends events.EventEmitter { return; } + // A version that is still being extracted can already look installed + const dst = path.join(this._factorioDir, latestVersion.version); const installedVersions = await listFactorioVersions(this._factorioDir); - if (!installedVersions.versions.has(latestVersion.version)) { + if (!installedVersions.versions.has(latestVersion.version) || downloadsInProgress.has(dst)) { const v = latestVersion.version; // Can't download if not linux or this is a direct install if (installedVersions.direct) { @@ -1022,10 +1027,18 @@ export class FactorioServer extends events.EventEmitter { return; } - // Download the newer version - this._logger.info(`A newer version of factorio is available (${v}), starting download...`); - const dst = path.join(this._factorioDir, latestVersion.version); - await downloadAndExtractTar(latestVersion.headlessUrl, dst); + // Download the newer version, or wait for another server already downloading it + let download = downloadsInProgress.get(dst); + if (download) { + this._logger.info(`A newer version of factorio is available (${v}), waiting for download...`); + } else { + this._logger.info(`A newer version of factorio is available (${v}), starting download...`); + download = downloadAndExtractTar(latestVersion.headlessUrl, dst).finally(() => { + downloadsInProgress.delete(dst); + }); + downloadsInProgress.set(dst, download); + } + await download; this._logger.info(`Downloaded factorio version ${v}`); } } diff --git a/test/host/server.js b/test/host/server.js index c8965ed6..1b14e200 100644 --- a/test/host/server.js +++ b/test/host/server.js @@ -505,6 +505,62 @@ describe("host/server", function() { }); /* eslint-enable no-loop-func */ } + describe("concurrent downloads", function() { + const versions = [{ stable: true, version: "0.1.5", headlessUrl: "test1" }]; + + // Holds the mocked download open until release() and resolves waiting once a server waits for it + function holdDownload(factorioDir) { + const held = { fetches: 0 }; + const release = new Promise(resolve => { held.release = resolve; }); + held.fetching = new Promise(resolve => { + global.fetch = async function() { + held.fetches += 1; + resolve(); + await release; + return { ok: false, status: -1, statusText: "Fetch called" }; + }; + }); + let waited; + held.waiting = new Promise(resolve => { waited = resolve; }); + const logger = { info: line => line.endsWith("waiting for download...") && waited() }; + held.servers = [0, 1].map(() => new hostServer.FactorioServer(factorioDir, writePath, { logger })); + Object.defineProperty(process, "platform", { value: "linux" }); + return held; + } + + it("should download once when servers check at the same time", async function() { + const held = holdDownload(path.join("test", "file", "factorio")); + let settled = 0; + const checks = held.servers.map(s => s.checkForUpdates(versions).finally(() => { settled += 1; })); + + await held.waiting; + assert.equal(held.fetches, 1); + assert.equal(settled, 0); + + held.release(); + await Promise.all(checks.map(check => assert.rejects(check, /Fetch called/))); + assert.equal(held.fetches, 1); + }); + it("should wait for a download in progress whose version already looks installed", async function() { + const factorioDir = path.join("temp", "test", "download-lock"); + await fs.rm(factorioDir, { force: true, recursive: true }); + await fs.mkdir(factorioDir, { recursive: true }); + const held = holdDownload(factorioDir); + const first = held.servers[0].checkForUpdates(versions); + await held.fetching; + + // The extraction has written the changelog but has not finished + const dataDir = path.join(factorioDir, "0.1.5", "data"); + await fs.mkdir(dataDir, { recursive: true }); + await fs.writeFile(path.join(dataDir, "changelog.txt"), "Version: 0.1.5\n"); + const second = held.servers[1].checkForUpdates(versions); + + await held.waiting; + held.release(); + await Promise.all([first, second].map(check => assert.rejects(check, /Fetch called/))); + assert.equal(held.fetches, 1); + }); + }); it("should download a version correctly (live api)", async function() { slowTest(this); externalTest(this);