Ignore fs events replayed by replication

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.
This commit is contained in:
Juan Castro
2026-08-26 09:22:49 -04:00
parent ee2b5adcf9
commit 1514ab3cb5
2 changed files with 56 additions and 5 deletions
@@ -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();
+19 -5
View File
@@ -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.<flavor>`, 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;