From 17f029e39ead2b8cd3d7bf8bffc2a4ac4ed55be3 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Mon, 19 Oct 2026 01:30:07 +1100 Subject: [PATCH] Check the run token on every prefetched chunk Chunks of an earlier controller run's copy still in flight could land in a newer run's copy of the same generation. Every chunk now carries the token, and the scenario ignores a chunk whose token differs from its record's. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/player_sync/instance.ts | 3 ++- plugins/player_sync/module/player_sync.lua | 10 +++++----- plugins/player_sync/test/instance.js | 6 +++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/plugins/player_sync/instance.ts b/plugins/player_sync/instance.ts index 7a64164b..8241d1ec 100644 --- a/plugins/player_sync/instance.ts +++ b/plugins/player_sync/instance.ts @@ -289,7 +289,8 @@ export default async function(context: InstancePluginContext) { logger.verbose(`Prefetching ${name}`); // This side doesn't know the save's cleared recipe list, so the whole stored list goes as a full delta await sendPlayerData(playerData, undefined, (chunk, number, total, extra) => { - const tokenArg = number === 1 ? `,'${token}'` : ""; + // Later chunks have no components argument, so nil holds its place + const tokenArg = number === 1 ? `,'${token}'` : `,nil,'${token}'`; const args = `'${name}',${generation},'${chunk}',${number},${total}${extra}${tokenArg}`; return `/sc player_sync.prefetch_inventory(${args})`; }, true); diff --git a/plugins/player_sync/module/player_sync.lua b/plugins/player_sync/module/player_sync.lua index d344db8e..3459e89f 100644 --- a/plugins/player_sync/module/player_sync.lua +++ b/plugins/player_sync/module/player_sync.lua @@ -701,11 +701,11 @@ end -- Called by instance.ts as /sc player_sync.prefetch_inventory('name', generation, 'chunk', number, total, components, -- token) with a copy of a player's stored data that the controller sent ahead (PrefetchEvent) while they switch to this --- server. It holds --- the copy for a player who is offline here or doesn't exist yet. It prints only when it refuses or ignores a chunk, --- which stops the host sending the rest. +-- server. It holds the copy for a player who is offline here or doesn't exist yet. Every chunk carries the token, so +-- chunks of an earlier controller run's copy never land in a newer one. It prints only when it refuses or ignores a +-- chunk, which stops the host sending the rest. --- @param components string? JSON of the components the instance stripped the copy by, given with the first chunk ---- @param token string? The controller run that sent the copy, given with the first chunk +--- @param token string? The controller run that sent the copy function player_sync.prefetch_inventory(player_name, generation, data, number, total, components, token) local script_data = get_script_data() if number == 1 then @@ -744,7 +744,7 @@ function player_sync.prefetch_inventory(player_name, generation, data, number, t end local record = script_data.active_downloads[player_name] - if not (record and record.prefetch and record.generation == generation) then + if not (record and record.prefetch and record.generation == generation and record.token == token) then -- Refused at chunk 1, dropped by the acquire reply or the inactivity check, or replaced by a newer copy rcon.print("Ignored prefetch chunk " .. number .. " for " .. player_name) return diff --git a/plugins/player_sync/test/instance.js b/plugins/player_sync/test/instance.js index 7a7778c6..b9568af7 100644 --- a/plugins/player_sync/test/instance.js +++ b/plugins/player_sync/test/instance.js @@ -277,7 +277,7 @@ describe("player_sync", function() { const sent = lib.escapeString(JSON.stringify(Object.fromEntries(enabled.map(c => [c, true])))); return chunks.map((chunk, i) => "/sc player_sync.prefetch_inventory(" + `'test',${playerData.generation},'${lib.escapeString(chunk)}',${i + 1},${chunks.length}` + - `${i === 0 ? `,'${sent}','tok'` : ""})`); + `${i === 0 ? `,'${sent}','tok'` : ",nil,'tok'"})`); } it("should send prefetch_inventory chunks with the generation and the full recipe list", async function() { @@ -296,11 +296,11 @@ describe("player_sync", function() { assert.ok(commands.length > 1, "more than one chunk"); assert.deepEqual(instance.server.rconCommands, commands); }); - it("should send the token with the first chunk only", async function() { + it("should send the token with every chunk", async function() { await prefetch({ generation: 3, name: "test" }); const commands = instance.server.rconCommands; assert.ok(commands[0].endsWith(",'tok')")); - assert.ok(commands.slice(1).every(command => !command.includes("tok"))); + assert.ok(commands.length > 1 && commands.slice(1).every(command => command.endsWith(",nil,'tok')"))); }); it("should stop when the save refuses the first chunk", async function() { const playerData = { generation: 3, name: "test" };