From 58774fdb784b31411a40f5e3ea7accc938433367 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 21 Aug 2026 21:02:49 -0400 Subject: [PATCH] fix(save-resume): scope the replay queue to its own project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of the openplc-web fix; the source file is byte-identical across the two repos, and its tests live on the web side, which is where this surface is tested. Two defects in the save-resume queue, both letting a queued save reach a project it does not belong to. `replayQueued` read the open project once, before the loop. The replays are sequential awaits, so the user can open another project while an earlier one is still in flight — a path captured up front still matched, and `run` reads the store at the moment it runs, not when it was queued. The later save wrote its content into whatever project had just been opened. It re-reads per iteration now. `pendingFiles` was keyed on the file name alone, but two projects can each hold a POU of the same name. The second queue call evicted the first, and because the survivor belonged to the project no longer open, the replay skipped it too — so neither save ran, though a toast had promised both would. The key carries the project now, joined by NUL so no two pairs can collide. Raised in review on #1027 — the first by CodeRabbit — and deferred to DOPE-568 when that PR merged. Co-Authored-By: Claude Opus 5 (1M context) --- .../services/resume-save-after-sign-in.ts | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/frontend/services/resume-save-after-sign-in.ts b/src/frontend/services/resume-save-after-sign-in.ts index e41fd21bd..90e410e7a 100644 --- a/src/frontend/services/resume-save-after-sign-in.ts +++ b/src/frontend/services/resume-save-after-sign-in.ts @@ -77,7 +77,10 @@ type QueuedSave = { /** The queued project-wide save, if one is waiting. Excludes the per-file queue. */ let pendingProject: QueuedSave | null = null -/** Queued single-file saves, by file name. Empty whenever `pendingProject` is set. */ +/** + * Queued single-file saves, keyed by project AND file name. Empty whenever + * `pendingProject` is set. + */ const pendingFiles = new Map() /** Live only while something is actually waiting, so an idle editor holds no listener. */ @@ -88,6 +91,22 @@ function currentProjectPath(): string { return openPLCStoreBase.getState().project.meta.path } +/** + * The queue key for a single-file save. + * + * Scoped to the project, not just the file. Two projects can each hold a POU of + * the same name, and keying on the name alone let the second queue call evict the + * first. If the evicted entry was the one belonging to the project still open at + * restore, it was gone and the survivor was skipped for a path mismatch — so + * neither save ran, which is the exact failure a per-file queue exists to prevent. + * + * NUL joins the two parts because it cannot occur in a path or a file name, so no + * two different pairs can collide on one key. + */ +function fileKey(projectPath: string, fileName: string): string { + return `${projectPath}\u0000${fileName}` +} + export function resumeSaveAfterEdgeSignIn( run: () => Promise, target: SaveTarget = { scope: 'project' }, @@ -108,7 +127,7 @@ export function resumeSaveAfterEdgeSignIn( return } - pendingFiles.set(target.fileName, { run, projectPath }) + pendingFiles.set(fileKey(projectPath, target.fileName), { run, projectPath }) } // NOT guarded on `session.isExpired()`. @@ -132,7 +151,7 @@ export function resumeSaveAfterEdgeSignIn( unsubscribe?.() unsubscribe = null - void replayQueued(queued, currentProjectPath()) + void replayQueued(queued) }) } @@ -142,9 +161,14 @@ export function resumeSaveAfterEdgeSignIn( * Sequential rather than concurrent: these write into the same project through the * same store, and interleaving two of them is how a half-written project happens. */ -async function replayQueued(queued: QueuedSave[], openProject: string): Promise { +async function replayQueued(queued: QueuedSave[]): Promise { for (const save of queued) { - if (save.projectPath !== openProject) { + // Re-read the open project on every iteration rather than once before the + // loop. Because the replays are sequential awaits, the user can open another + // project while an earlier one is still running; a path captured up front + // would still match, and `run` — which reads the store at the moment it runs, + // not when it was queued — would write this project's content into that one. + if (save.projectPath !== currentProjectPath()) { continue }