Skip to content

Commit

Permalink
fix: leader election mechanism
Browse files Browse the repository at this point in the history
  • Loading branch information
IsaccoSordo committed Sep 21, 2024
1 parent 671da69 commit 9d91c44
Show file tree
Hide file tree
Showing 5 changed files with 62 additions and 117 deletions.
173 changes: 58 additions & 115 deletions packages/beacon-core/src/utils/multi-tab-channel.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,8 @@
import { BeaconMessageType } from '@airgap/beacon-types'
import { BeaconMessageType, StorageKey } from '@airgap/beacon-types'
import { Logger } from './Logger'
import { LocalStorage } from '../storage/LocalStorage'

type BCMessageType =
| 'REQUEST_LEADERSHIP'
| 'LEADER_EXISTS'
| 'LEADER_UNLOAD'
| 'LEADER_STILL_ALIVE'
| 'IS_LEADER_ALIVE'
| 'CHILD_UNLOAD'
| 'CHILD_STILL_ALIVE'
| 'IS_CHILD_ALIVE'
| 'RESPONSE'
| 'DISCONNECT'
| 'REQUEST_PAIRING'
Expand All @@ -31,6 +24,7 @@ const logger = new Logger('MultiTabChannel')

export class MultiTabChannel {
private id: string = String(Date.now())
private leaderID: string = ''
private neighborhood: Set<string> = new Set()
private channel: BroadcastChannel
private eventListeners = [
Expand All @@ -39,23 +33,14 @@ export class MultiTabChannel {
]
private onBCMessageHandler: Function
private onElectedLeaderHandler: Function
private leaderElectionTimeout: NodeJS.Timeout | undefined
private pendingACKs: Map<string, NodeJS.Timeout> = new Map()
private storage: LocalStorage = new LocalStorage()

isLeader: boolean = false

private messageHandlers: {
[key in BCMessageType]?: (data: BCMessage) => void
} = {
REQUEST_LEADERSHIP: this.handleRequestLeadership.bind(this),
LEADER_EXISTS: this.handleLeaderExists.bind(this),
CHILD_UNLOAD: this.handleChildUnload.bind(this),
CHILD_STILL_ALIVE: this.handleChildStillAlive.bind(this),
IS_CHILD_ALIVE: this.handleIsChildAlive.bind(this),
LEADER_UNLOAD: this.handleLeaderUnload.bind(this),
LEADER_STILL_ALIVE: this.handleLeaderStillAlive.bind(this),
IS_LEADER_ALIVE: this.handleIsLeaderAlive.bind(this)
}
} = {}

constructor(name: string, onBCMessageHandler: Function, onElectedLeaderHandler: Function) {
this.onBCMessageHandler = onBCMessageHandler
Expand All @@ -64,36 +49,69 @@ export class MultiTabChannel {
this.init()
}

private init() {
this.postMessage({ type: 'REQUEST_LEADERSHIP' })
this.leaderElectionTimeout = setTimeout(() => {
private async init() {
this.storage.subscribeToStorageChanged(async (event) => {
if (
event.eventType === 'entryModified' &&
event.key === this.storage.getPrefixedKey(StorageKey.BC_NEIGHBORHOOD)
) {
const newNeighborhood = !event.newValue ? this.neighborhood : JSON.parse(event.newValue)

if (newNeighborhood[0] !== this.leaderID) {
this.pendingACKs.set(
this.leaderID,
setTimeout(() => {
const neighborhood = Array.from(this.neighborhood)
this.leaderID = neighborhood[0]
if (neighborhood[0] !== this.id) {
return
}
this.isLeader = true
this.onElectedLeaderHandler()
logger.log('The current tab is the leader.')
}, timeout)
)
} else {
clearTimeout(this.pendingACKs.get(this.leaderID))
}

this.neighborhood = newNeighborhood
}
})
await this.requestLeadership()
}

private async requestLeadership() {
const neighborhood = await this.storage.get(StorageKey.BC_NEIGHBORHOOD)

if (!neighborhood.length) {
this.isLeader = true
logger.log('The current tab is the leader.')
}, timeout)
}

neighborhood.push(this.id)
this.leaderID = neighborhood[0]
this.neighborhood = new Set(neighborhood)
this.storage.set(StorageKey.BC_NEIGHBORHOOD, neighborhood)

window?.addEventListener('beforeunload', this.eventListeners[0])
this.channel.onmessage = this.eventListeners[1]
}

private chooseNextLeader() {
return Math.floor(Math.random() * this.neighborhood.size)
}
private async onBeforeUnloadHandler() {
const oldNeighborhood = this.neighborhood
const newNeighborhood = new Set(this.neighborhood)
newNeighborhood.delete(this.id)

await this.storage.set(StorageKey.BC_NEIGHBORHOOD, Array.from(newNeighborhood))
this.neighborhood = newNeighborhood

private onBeforeUnloadHandler() {
// We can't immediately say that a child or the leader is dead
// beacause, on mobile a browser tab gets unloaded every time it no longer has focus
if (this.isLeader) {
this.postMessage({
type: 'LEADER_UNLOAD',
recipient: Array.from(this.neighborhood)[this.chooseNextLeader()],
data: this.neighborhood
})
} else {
this.postMessage({ type: 'CHILD_UNLOAD' })
}

window?.removeEventListener('beforeunload', this.eventListeners[0])
this.channel.removeEventListener('message', this.eventListeners[1])
setTimeout(() => {
this.storage.set(StorageKey.BC_NEIGHBORHOOD, Array.from(oldNeighborhood))
this.neighborhood = oldNeighborhood
}, timeout / 2)
}

private onMessageHandler({ data }: { data: BCMessage }) {
Expand All @@ -105,81 +123,6 @@ export class MultiTabChannel {
}
}

private handleRequestLeadership(data: BCMessage) {
if (this.isLeader) {
this.postMessage({ type: 'LEADER_EXISTS', recipient: data.sender })
this.neighborhood.add(data.sender)
}
}

private handleLeaderExists(data: BCMessage) {
if (data.recipient === this.id) {
clearTimeout(this.leaderElectionTimeout)
}
}

private handleChildUnload(data: BCMessage) {
if (this.isLeader) {
this.pendingACKs.set(
data.sender,
setTimeout(() => {
this.neighborhood.delete(data.sender)
this.pendingACKs.delete(data.sender)
}, timeout)
)

this.postMessage({ type: 'IS_CHILD_ALIVE', recipient: data.sender })
}
}

private handleChildStillAlive(data: BCMessage) {
if (this.isLeader) {
this.clearPendingACK(data.sender)
}
}

private handleIsChildAlive(data: BCMessage) {
if (data.recipient === this.id) {
this.postMessage({ type: 'CHILD_STILL_ALIVE' })
}
}

private handleLeaderUnload(data: BCMessage) {
if (data.recipient === this.id) {
this.pendingACKs.set(
data.sender,
setTimeout(() => {
this.isLeader = true
this.neighborhood = data.data
this.neighborhood.delete(this.id)
this.onElectedLeaderHandler()
logger.log('The current tab is the leader.')
}, timeout)
)
}
this.postMessage({ type: 'IS_LEADER_ALIVE', recipient: data.sender })
}

private handleLeaderStillAlive(data: BCMessage) {
if (this.isLeader) {
this.clearPendingACK(data.sender)
}
}

private handleIsLeaderAlive(data: BCMessage) {
if (data.recipient === this.id) {
this.postMessage({ type: 'LEADER_STILL_ALIVE' })
}
}

private clearPendingACK(sender: string) {
const timeout = this.pendingACKs.get(sender)
if (timeout) {
clearTimeout(timeout)
this.pendingACKs.delete(sender)
}
}

postMessage(message: Omit<BCMessage, 'sender'>): void {
this.channel.postMessage({ ...message, sender: this.id })
}
Expand Down
3 changes: 1 addition & 2 deletions packages/beacon-dapp/src/dapp-client/DAppClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,8 +320,7 @@ export class DAppClient extends Client {
data: {
message,
connectionInfo
},
recipient: message.id
}
})

if (typedMessage.type !== BeaconMessageType.Acknowledge) {
Expand Down
1 change: 1 addition & 0 deletions packages/beacon-types/src/types/storage/StorageKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export enum StorageKey {
USER_ID = 'beacon:user-id',
ENABLE_METRICS = 'beacon:enable_metrics',
WC_INIT_ERROR = 'beacon:wc-init-error',
BC_NEIGHBORHOOD = 'beacon:bc-neighborhood',
WC_2_CORE_PAIRING = 'wc@2:core:0.3:pairing',
WC_2_CLIENT_SESSION = 'wc@2:client:0.3:session',
WC_2_CORE_KEYCHAIN = 'wc@2:core:0.3:keychain',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const defaultValues: StorageKeyReturnDefaults = {
[StorageKey.WC_2_CLIENT_SESSION]: undefined,
[StorageKey.USER_ID]: undefined,
[StorageKey.ENABLE_METRICS]: undefined,
[StorageKey.BC_NEIGHBORHOOD]: [],
[StorageKey.WC_INIT_ERROR]: undefined,
[StorageKey.WC_2_CORE_PAIRING]: undefined,
[StorageKey.WC_2_CORE_KEYCHAIN]: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export interface StorageKeyReturnType {
[StorageKey.USER_ID]: string | undefined
[StorageKey.ENABLE_METRICS]: boolean | undefined
[StorageKey.WC_INIT_ERROR]: string | undefined
[StorageKey.BC_NEIGHBORHOOD]: string[]
[StorageKey.WC_2_CLIENT_SESSION]: string | undefined
[StorageKey.WC_2_CORE_PAIRING]: string | undefined
[StorageKey.WC_2_CORE_KEYCHAIN]: string | undefined
Expand Down

0 comments on commit 9d91c44

Please sign in to comment.