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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhMv1AxB8EZdyZoSJhyNmY

---------

Co-authored-by: ILLISIS <jayson.harris01@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-16 20:38:07 +03:00
1 parent a75354f9bb
commit e3a684ff3b
2 files changed
+74 -5

No files matched your search

+18 -5
View File
@@ -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<string, Promise<void>>();
/**
* Find factorio data directory of the given version
*
@@ -1001,8 +1004,10 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
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<FactorioServerEvents> {
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}`);
}
}
+56
View File
@@ -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);