Skip to content

Commit b98ffd0

Browse files
committed
Fix duplicate Foundry actor on createActor sync race
Symptom: creating one character actor in Foundry produced two actors sharing the same chronicle entityId, with chronicle holding a single entity. The dashboard reported both as synced because both carried the flag, but they were competing for the same chronicle row. Cause: when _handleCreateActor POSTs the new entity, chronicle's entity.created WebSocket broadcast fires before (or alongside) the POST response. _onCharacterCreated runs, finds no Foundry actor with the entity's id flag (the originator hasn't returned yet to set it), and creates a fresh actor. The originator then sets the flag on its own actor too, leaving two flagged actors. Fix: track Foundry-originated creates in an in-flight Map keyed by actor.id holding actor.name. Set on entry to _handleCreateActor, clear in finally. _onCharacterCreated checks the in-flight names; on match, it skips creation and lets the originating handler link the existing actor when its POST returns. Match by name because the incoming Foundry actor doesn't have the new chronicle entity id yet. Existing duplicates from before this fix must be cleaned up manually by deleting the newer of each duplicate pair.
1 parent 7f98338 commit b98ffd0

1 file changed

Lines changed: 33 additions & 0 deletions

File tree

scripts/actor-sync.mjs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ export class ActorSync {
3333
/** @type {boolean} Suppress hook processing during sync-initiated changes. */
3434
this._syncing = false;
3535

36+
/**
37+
* Tracks Foundry-originated creates whose POST is in flight.
38+
* Maps actor.id → actor.name. Used to suppress the WS-driven
39+
* `_onCharacterCreated` from spawning a duplicate Foundry actor when
40+
* `entity.created` arrives before our POST returns.
41+
* @type {Map<string, string>}
42+
*/
43+
this._inFlightCreates = new Map();
44+
3645
/** @type {object|null} Loaded system adapter module. */
3746
this._adapter = null;
3847

@@ -192,6 +201,21 @@ export class ActorSync {
192201
);
193202
if (existing) return;
194203

204+
// Race guard: a Foundry-originated POST for this name is in flight.
205+
// The originating handler will set the entityId flag once its POST
206+
// returns; creating another actor here would produce a duplicate
207+
// Foundry actor sharing the same chronicle entity. Match on name
208+
// because we don't yet know the new entity's ID on the Foundry side.
209+
for (const inFlightName of this._inFlightCreates.values()) {
210+
if (inFlightName === entity.name) {
211+
console.debug(
212+
`Chronicle: Skipping WS-driven actor create for "${entity.name}" — ` +
213+
`Foundry-originated POST is in flight; the originating handler will link it.`
214+
);
215+
return;
216+
}
217+
}
218+
195219
try {
196220
this._syncing = true;
197221

@@ -344,6 +368,13 @@ export class ActorSync {
344368
// Skip if already linked (came from Chronicle).
345369
if (actor.getFlag(FLAG_SCOPE, 'entityId')) return;
346370

371+
// Mark this Foundry-originated create as in-flight so the WS-driven
372+
// _onCharacterCreated can skip the matching `entity.created` broadcast
373+
// that arrives before our POST returns. The flag is set after the POST
374+
// succeeds; without this guard, the WS handler doesn't see the flag yet
375+
// and creates a duplicate Foundry actor.
376+
this._inFlightCreates.set(actor.id, actor.name);
377+
347378
try {
348379
const fields = this._adapter.toChronicleFields(actor);
349380

@@ -388,6 +419,8 @@ export class ActorSync {
388419
}
389420
} catch (err) {
390421
console.error('Chronicle: Failed to push new actor to Chronicle', err);
422+
} finally {
423+
this._inFlightCreates.delete(actor.id);
391424
}
392425
}
393426

0 commit comments

Comments
 (0)