Keep auto paused instances paused

Every RCON command makes an auto paused Factorio server run one tick, so
subspace_storage's 1 s inventory update and 5 s ping, plus a
statistics_exporter gather on every Prometheus scrape, kept a server with
nobody online running at about 1.3 ticks per second.

The instance now records the auto_pause server setting it was started
with and exposes autoPaused, true while that setting is on and no players
are online, along with an autoPauseChanged hook. subspace_storage skips
its timers while paused and sends a ping and inventory as soon as a player
joins. statistics_exporter gathers once after the game pauses and keeps
serving those values until it resumes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-10-28 23:40:30 +00:00
1 parent ebdbb1fe56
commit 11e067a933
11 files changed
+227 -19

No files matched your search

+7
View File
@@ -175,18 +175,25 @@ export async function gatherMetrics(instance: Instance, pluginName: string) {
export default async function(context: InstancePluginContext) {
const { instance, plugin } = context;
// Stats of an auto paused game do not change, and gathering them runs a tick.
let gatheredWhilePaused = false;
instance.hooks.metrics.attach(plugin.name, async () => {
if (instance.status !== "running") {
return;
}
if (instance.autoPaused && gatheredWhilePaused) {
return;
}
// Wait configured timeout for the metrics to be collected. It may
// take a long time for the command to go through if the command
// stream is overloaded. Should the timeout be exceeded the
// previous values for the metrics will end up being sent to controller.
let timeoutMs = instance.config.get("statistics_exporter.command_timeout") * 1000;
const paused = instance.autoPaused;
await lib.timeout(gatherMetrics(instance, plugin.name), timeoutMs, undefined);
gatheredWhilePaused = paused;
});
}
@@ -86,6 +86,19 @@ describe("statistics_exporter plugin", function() {
"7357", "player", "nauvis").get(),
2);
});
it("should gather once while auto paused", async function() {
mockInstance.server.rconCommands.length = 0;
mockInstance.autoPaused = true;
try {
await mockInstance.hooks.metrics.invoke();
await mockInstance.hooks.metrics.invoke();
assert.equal(mockInstance.server.rconCommands.length, 1);
} finally {
mockInstance.autoPaused = false;
}
await mockInstance.hooks.metrics.invoke();
assert.equal(mockInstance.server.rconCommands.length, 2);
});
it("should pass on JSON parse errors", async function() {
let string = "An error occured\n";
mockInstance.server.rconCommandResults.set(
+38 -18
View File
@@ -87,14 +87,38 @@ export default async function(context: InstancePluginContext) {
cachedInventoryItems = event.items;
});
function sendPing() {
if (!host.connected) {
return; // Only ping if we are actually connected to the controller.
}
instance.sendRcon(
"/sc __subspace_storage__ global.ticksSinceMasterPinged = 0", true, plugin.name
).catch(err => unexpectedError(err));
}
function sendInventory() {
if (instance.status !== "running") {
return;
}
const payloadItems = [
...cachedInventoryItems,
new Item("signal-unixtime", Math.floor(Date.now() / 1000), "normal"),
];
const payloadJson = lib.escapeString(JSON.stringify(payloadItems));
const task = instance.sendRcon(
`/sc __subspace_storage__ UpdateInvData("${payloadJson}")`, true, plugin.name
).catch(err => unexpectedError(err));
pendingTasks.add(task);
task.finally(() => { pendingTasks.delete(task); });
}
instance.hooks.start.attach(plugin.name, async () => {
// Each command runs a tick on an auto paused game.
pingId = setInterval(() => {
if (!host.connected) {
return; // Only ping if we are actually connected to the controller.
if (!instance.autoPaused) {
sendPing();
}
instance.sendRcon(
"/sc __subspace_storage__ global.ticksSinceMasterPinged = 0", true, plugin.name
).catch(err => unexpectedError(err));
}, 5000);
let items = await instance.sendTo("controller", new GetStorageRequest());
@@ -105,23 +129,19 @@ export default async function(context: InstancePluginContext) {
// Ensure a payload with updated time is sent every second
timeUpdateId = setInterval(() => {
if (instance.status !== "running") {
return;
if (!instance.autoPaused) {
sendInventory();
}
const payloadItems = [
...cachedInventoryItems,
new Item("signal-unixtime", Math.floor(Date.now() / 1000), "normal"),
];
const payloadJson = lib.escapeString(JSON.stringify(payloadItems));
const task = instance.sendRcon(
`/sc __subspace_storage__ UpdateInvData("${payloadJson}")`, true, plugin.name
).catch(err => unexpectedError(err));
pendingTasks.add(task);
task.finally(() => { pendingTasks.delete(task); });
}, 1000);
});
instance.hooks.autoPauseChanged.attach(plugin.name, async (paused: boolean) => {
if (!paused && instance.status === "running") {
sendPing();
sendInventory();
}
});
instance.hooks.stop.attach(plugin.name, async () => {
clearInterval(pingId);
clearInterval(timeUpdateId);
+25
View File
@@ -129,5 +129,30 @@ describe("subspace_storage plugin", function() {
nodeMock.timers.reset();
}
});
it("should not send commands while auto paused and catch up on unpause", async function() {
nodeMock.timers.enable({ apis: ["setInterval", "Date"], now: 1000000 });
const { instance: startedInstance, host } = await mock.loadInstancePlugin(instanceEntrypoint, info);
host.connected = true;
startedInstance.sendTo = async () => [];
try {
await startedInstance.hooks.start.invoke();
startedInstance.server.rconCommands.length = 0;
startedInstance.autoPaused = true;
nodeMock.timers.tick(10000);
assert.deepEqual(startedInstance.server.rconCommands, []);
startedInstance.autoPaused = false;
await startedInstance.hooks.autoPauseChanged.invoke(false);
assert.deepEqual(startedInstance.server.rconCommands, [
"/sc __subspace_storage__ global.ticksSinceMasterPinged = 0",
`/sc __subspace_storage__ UpdateInvData("${lib.escapeString(JSON.stringify(
[new Item("signal-unixtime", 1010)]
))}")`,
]);
} finally {
await startedInstance.hooks.stop.invoke();
nodeMock.timers.reset();
}
});
});
});