From 1514ab3cb5b8852a40aefd1085fbfe02c313be14 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Wed, 26 Aug 2026 09:22:49 -0400 Subject: [PATCH] Ignore fs events replayed by replication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raised in review: could an event from another node re-trigger the fan-out? Not today — broadcast carries outer.* and pubsub.* only, so fs.* never crosses a node boundary, and the emitted outer.gui.* is consumed on the peer by SocketService while ShareService listens to fs.* alone, so nothing re-enters. That safety is a property of what broadcast happens to replicate, which is not this service's to rely on. The handlers now skip anything tagged from_outside: the node that did the write has already told the audience, and a second fan-out would only duplicate it. --- .../services/share/ShareService.test.ts | 37 +++++++++++++++++++ src/backend/services/share/ShareService.ts | 24 +++++++++--- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts index 1bf968816..dc808f5a5 100644 --- a/src/backend/services/share/ShareService.test.ts +++ b/src/backend/services/share/ShareService.test.ts @@ -1703,6 +1703,43 @@ describe('ShareService', () => { expect(lookups).toBe(1); }); + it('stays quiet for an event another node already handled', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dir, file } = await makeDirWithFile(owner.user); + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + const seen: unknown[] = []; + const listener = (_key: string, data: unknown) => seen.push(data); + server.clients.event.on('outer.gui.item.added', listener); + server.clients.event.on('outer.gui.item.updated', listener); + try { + // What replication looks like: the writing node already told + // this audience, so a second fan-out would only duplicate it. + await server.clients.event.emitAndWait( + 'fs.create.file', + { node: file, entry: file, uid: file.uuid }, + { from_outside: true }, + ); + await server.clients.event.emitAndWait( + 'fs.write.file', + { node: file, entry: file, target: file }, + { from_outside: true }, + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + } finally { + server.clients.event.off('outer.gui.item.added', listener); + server.clients.event.off('outer.gui.item.updated', listener); + } + + expect(seen).toEqual([]); + }); + it('says where a directly shared file moved from', async () => { const owner = await makeUser(); const recipient = await makeUser(); diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index 8fe3050d2..7deb0b645 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -224,6 +224,15 @@ const holderPayload = ( }; }; +/** + * An `fs.*` event replayed onto this bus by replication rather than raised by a + * write here. Broadcast only carries `outer.*` and `pubsub.*`, so today nothing + * reaches these handlers that way — but the node that did the write has already + * told the audience, and a second fan-out would only duplicate it. + */ +const fromAnotherNode = (meta?: { from_outside?: boolean }): boolean => + Boolean(meta?.from_outside); + /** The GUI events a share recipient is an audience for. */ type HolderGuiEvent = | 'outer.gui.item.added' @@ -288,7 +297,8 @@ export class ShareService extends PuterService { * first and cannot depend on this service. */ override onServerStart(): void { - this.clients.event.on('fs.remove.node', (_key, data) => { + this.clients.event.on('fs.remove.node', (_key, data, meta) => { + if (fromAnotherNode(meta)) return; const entry = (data as { node?: FSEntry })?.node; if (!entry?.uuid) return; // Returned so an `emitAndWait` caller can observe the cleanup; the @@ -302,7 +312,8 @@ export class ShareService extends PuterService { }); }); - this.clients.event.on('fs.move.node', (_key, data) => { + this.clients.event.on('fs.move.node', (_key, data, meta) => { + if (fromAnotherNode(meta)) return; const { node, fromPath, fromUserId } = (data ?? {}) as { node?: FSEntry; fromPath?: string; @@ -380,7 +391,8 @@ export class ShareService extends PuterService { return claimFor(user_id, new_email); }); - this.clients.event.on('fs.write.file', (_key, data) => { + this.clients.event.on('fs.write.file', (_key, data, meta) => { + if (fromAnotherNode(meta)) return; const entry = (data as { node?: FSEntry })?.node; if (!entry?.uuid) return; return this.#fanOutToHolders(entry, 'outer.gui.item.updated').catch( @@ -391,13 +403,15 @@ export class ShareService extends PuterService { }); // A create is `fs.create.`, not `fs.write.file`. - this.clients.event.on('fs.create.*', (_key, data) => { + this.clients.event.on('fs.create.*', (_key, data, meta) => { + if (fromAnotherNode(meta)) return; const entry = (data as { node?: FSEntry })?.node; if (!entry?.uuid) return; return this.#scheduleCreateFanOut(entry).catch(() => {}); }); - this.clients.event.on('fs.rename', (_key, data) => { + this.clients.event.on('fs.rename', (_key, data, meta) => { + if (fromAnotherNode(meta)) return; const { node: entry, old_path: oldPath } = (data ?? {}) as { node?: FSEntry; old_path?: string;