From 5fc9c1c8cdebec868440cd8ed4b3ad84f214e117 Mon Sep 17 00:00:00 2001 From: piuvas <piuvas@proton.me> Date: Wed, 8 Jan 2025 12:51:46 -0300 Subject: [PATCH 1/5] shallow clone --- .../backend/src/server/api/stream/channel.ts | 37 ++++++++++++++++--- .../api/stream/channels/bubble-timeline.ts | 23 +++--------- .../api/stream/channels/global-timeline.ts | 23 +++--------- .../api/stream/channels/home-timeline.ts | 19 ++-------- .../api/stream/channels/hybrid-timeline.ts | 19 ++-------- .../api/stream/channels/local-timeline.ts | 19 ++-------- 6 files changed, 51 insertions(+), 89 deletions(-) diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 93d5046902..ed6e41c4bb 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -9,8 +9,8 @@ import { isUserRelated } from '@/misc/is-user-related.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { Packed } from '@/misc/json-schema.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; -import type Connection from './Connection.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import type Connection from './Connection.js'; /** * Stream channel @@ -103,14 +103,41 @@ export default abstract class Channel { public onMessage?(type: string, body: JsonValue): void; - public async assignMyReaction(note: Packed<'Note'>, noteEntityService: NoteEntityService) { - if (this.user && Object.keys(note.reactions).length > 0) { - const myReaction = await noteEntityService.populateMyReaction(note, this.user.id); - note.myReaction = myReaction; + public async assignMyReaction(note: Packed<'Note'>, noteEntityService: NoteEntityService): Promise<Packed<'Note'>> { + let changed = false; + const clonedNote = { ...note }; + if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { + if (note.renote && Object.keys(note.renote.reactions).length > 0) { + const myReaction = await noteEntityService.populateMyReaction(note.renote, this.user.id); + if (myReaction) { + changed = true; + clonedNote.renote = { ...note.renote }; + clonedNote.renote.myReaction = myReaction; + } + } + } + if (this.user && note.renote?.reply && Object.keys(note.renote.reply.reactions).length > 0) { + const myReaction = await noteEntityService.populateMyReaction(note.renote.reply, this.user.id); + if (myReaction) { + changed = true; + clonedNote.renote = { ...note.renote }; + clonedNote.renote.reply = { ...note.renote.reply }; + clonedNote.renote.reply.myReaction = myReaction; + } } + if (this.user && note.reply && Object.keys(note.reply.reactions).length > 0) { + const myReaction = await noteEntityService.populateMyReaction(note.reply, this.user.id); + if (myReaction) { + changed = true; + clonedNote.reply = { ...note.reply }; + clonedNote.reply.myReaction = myReaction; + } + } + return changed ? clonedNote : note; } } + export type MiChannelService<T extends boolean> = { shouldShare: boolean; requireCredential: T; diff --git a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts index b2745db92d..98ecf16a83 100644 --- a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts @@ -65,24 +65,11 @@ class BubbleTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - const reactionsToFetch = []; - if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { - if (note.renote) { - reactionsToFetch.push(this.assignMyReaction(note.renote, this.noteEntityService)); - if (note.renote.reply) { - reactionsToFetch.push(this.assignMyReaction(note.renote.reply, this.noteEntityService)); - } - } - } - if (this.user && note.reply) { - reactionsToFetch.push(this.assignMyReaction(note.reply, this.noteEntityService)); - } - - await Promise.all(reactionsToFetch); - - this.connection.cacheNote(note); - - this.send('note', note); + const clonedNote = await this.assignMyReaction(note, this.noteEntityService); + + this.connection.cacheNote(clonedNote); + + this.send('note', clonedNote); } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index 8df59d906d..4443b20bed 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -60,24 +60,11 @@ class GlobalTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - const reactionsToFetch = []; - if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { - if (note.renote) { - reactionsToFetch.push(this.assignMyReaction(note.renote, this.noteEntityService)); - if (note.renote.reply) { - reactionsToFetch.push(this.assignMyReaction(note.renote.reply, this.noteEntityService)); - } - } - } - if (this.user && note.reply) { - reactionsToFetch.push(this.assignMyReaction(note.reply, this.noteEntityService)); - } - - await Promise.all(reactionsToFetch); - - this.connection.cacheNote(note); - - this.send('note', note); + const clonedNote = await this.assignMyReaction(note, this.noteEntityService); + + this.connection.cacheNote(clonedNote); + + this.send('note', clonedNote); } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index f48eff85c9..af1b17b533 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -81,24 +81,11 @@ class HomeTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - const reactionsToFetch = []; - if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { - if (note.renote) { - reactionsToFetch.push(this.assignMyReaction(note.renote, this.noteEntityService)); - if (note.renote.reply) { - reactionsToFetch.push(this.assignMyReaction(note.renote.reply, this.noteEntityService)); - } - } - } - if (this.user && note.reply) { - reactionsToFetch.push(this.assignMyReaction(note.reply, this.noteEntityService)); - } - - await Promise.all(reactionsToFetch); + const clonedNote = await this.assignMyReaction(note, this.noteEntityService); - this.connection.cacheNote(note); + this.connection.cacheNote(clonedNote); - this.send('note', note); + this.send('note', clonedNote); } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 8c58b2518e..7c604c0b58 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -98,24 +98,11 @@ class HybridTimelineChannel extends Channel { } } - const reactionsToFetch = []; - if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { - if (note.renote) { - reactionsToFetch.push(this.assignMyReaction(note.renote, this.noteEntityService)); - if (note.renote.reply) { - reactionsToFetch.push(this.assignMyReaction(note.renote.reply, this.noteEntityService)); - } - } - } - if (this.user && note.reply) { - reactionsToFetch.push(this.assignMyReaction(note.reply, this.noteEntityService)); - } - - await Promise.all(reactionsToFetch); + const clonedNote = await this.assignMyReaction(note, this.noteEntityService); - this.connection.cacheNote(note); + this.connection.cacheNote(clonedNote); - this.send('note', note); + this.send('note', clonedNote); } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index cb832bd3c2..2d48b6ecfb 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -70,24 +70,11 @@ class LocalTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - const reactionsToFetch = []; - if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { - if (note.renote) { - reactionsToFetch.push(this.assignMyReaction(note.renote, this.noteEntityService)); - if (note.renote.reply) { - reactionsToFetch.push(this.assignMyReaction(note.renote.reply, this.noteEntityService)); - } - } - } - if (this.user && note.reply) { - reactionsToFetch.push(this.assignMyReaction(note.reply, this.noteEntityService)); - } - - await Promise.all(reactionsToFetch); + const clonedNote = await this.assignMyReaction(note, this.noteEntityService); - this.connection.cacheNote(note); + this.connection.cacheNote(clonedNote); - this.send('note', note); + this.send('note', clonedNote); } @bindThis -- GitLab From e76e6cd08f69ddd7f8c62039b9d3e64a7a6189ca Mon Sep 17 00:00:00 2001 From: piuvas <piuvas@proton.me> Date: Wed, 8 Jan 2025 12:58:57 -0300 Subject: [PATCH 2/5] small refactor --- .../backend/src/server/api/stream/channel.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index ed6e41c4bb..6589a7af8d 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -115,14 +115,14 @@ export default abstract class Channel { clonedNote.renote.myReaction = myReaction; } } - } - if (this.user && note.renote?.reply && Object.keys(note.renote.reply.reactions).length > 0) { - const myReaction = await noteEntityService.populateMyReaction(note.renote.reply, this.user.id); - if (myReaction) { - changed = true; - clonedNote.renote = { ...note.renote }; - clonedNote.renote.reply = { ...note.renote.reply }; - clonedNote.renote.reply.myReaction = myReaction; + if (note.renote?.reply && Object.keys(note.renote.reply.reactions).length > 0) { + const myReaction = await noteEntityService.populateMyReaction(note.renote.reply, this.user.id); + if (myReaction) { + changed = true; + clonedNote.renote = { ...note.renote }; + clonedNote.renote.reply = { ...note.renote.reply }; + clonedNote.renote.reply.myReaction = myReaction; + } } } if (this.user && note.reply && Object.keys(note.reply.reactions).length > 0) { -- GitLab From a3fc9a1085c7ad1fedf64fd6417c04cdcc936887 Mon Sep 17 00:00:00 2001 From: piuvas <piuvas@proton.me> Date: Wed, 8 Jan 2025 13:10:20 -0300 Subject: [PATCH 3/5] comment code --- packages/backend/src/server/api/stream/channel.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 6589a7af8d..7a044296fe 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -105,6 +105,8 @@ export default abstract class Channel { public async assignMyReaction(note: Packed<'Note'>, noteEntityService: NoteEntityService): Promise<Packed<'Note'>> { let changed = false; + // cloning here seems like the best solution for a race condition + // where multiple users shared the same myReaction. (Sharkey #877) const clonedNote = { ...note }; if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { -- GitLab From f1d9bb2cf1d9045ec13cdeb231656054b9e5bfde Mon Sep 17 00:00:00 2001 From: piuvas <piuvas@proton.me> Date: Fri, 10 Jan 2025 22:10:18 -0300 Subject: [PATCH 4/5] requested changes --- packages/backend/src/server/api/stream/channel.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 7a044296fe..cfac4b30ce 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -9,8 +9,8 @@ import { isUserRelated } from '@/misc/is-user-related.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { Packed } from '@/misc/json-schema.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import type Connection from './Connection.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; /** * Stream channel @@ -105,7 +105,7 @@ export default abstract class Channel { public async assignMyReaction(note: Packed<'Note'>, noteEntityService: NoteEntityService): Promise<Packed<'Note'>> { let changed = false; - // cloning here seems like the best solution for a race condition + // cloning here seems like the best solution for not sharing changes with other users. // where multiple users shared the same myReaction. (Sharkey #877) const clonedNote = { ...note }; if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { @@ -139,7 +139,6 @@ export default abstract class Channel { } } - export type MiChannelService<T extends boolean> = { shouldShare: boolean; requireCredential: T; -- GitLab From 3fc377839c6155d1eb3c55ae7eb24e85e4ad90c7 Mon Sep 17 00:00:00 2001 From: piuvas <mail@piuvas.net> Date: Sun, 12 Jan 2025 21:11:36 -0300 Subject: [PATCH 5/5] comment :3 --- packages/backend/src/server/api/stream/channel.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index cfac4b30ce..047dedd5ce 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -105,8 +105,11 @@ export default abstract class Channel { public async assignMyReaction(note: Packed<'Note'>, noteEntityService: NoteEntityService): Promise<Packed<'Note'>> { let changed = false; - // cloning here seems like the best solution for not sharing changes with other users. - // where multiple users shared the same myReaction. (Sharkey #877) + // StreamingApiServerService creates a single EventEmitter per server process, + // so a new note arriving from redis gets de-serialised once per server process, + // and then that single object is passed to all active channels on each connection. + // If we didn't clone the notes here, different connections would asynchronously write + // different values to the same object, resulting in a random value being sent to each frontend. -- Dakkar const clonedNote = { ...note }; if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { -- GitLab