How do I safely create a shared SignalR client connection?

Viewed 20

I'll admit, this is probably not even a SignalR specific question, but more of a "doing it the right way" question. Typically in C#, we could create a singleton of some shared thing by making it static and then using a lock around it so only one thread could create the shared object. Using the Javascript SignalR client, I would like to do the same thing, not having to worry about whether or not two components want to use a connection. My solution is this, but it still results in a race condition where two web components each get their own connection instance:

export class MessagingService {
    private static service: MessagingService;

    static async GetService(): Promise<MessagingService> {
        if (!this.service) {
            let service = new MessagingService();
            await service.start();
            this.service = service;
        }
        return this.service;
    }

    connection;

    async start() {
        this.connection = new signalR.HubConnectionBuilder().withUrl("/MyHub").withAutomaticReconnect().build();

        await this.connection.start();
    }
}

Naively, I wouldn't expect two web components on the page, calling await MessagingService.GetService() from their async connectedCallback() methods, to each end up with their own instance of the MessagingService and therefore connection, but that's exactly what happens.

Countless other questions on the Internets suggest that locking isn't what I need here, but rather that I'm doing it wrong. How can I make sure only one of these connections is ever created?

1 Answers

My C# brain was not thinking in promises. I was faking a lock on the static instance member, which was not assigned to until the SignalR connection was created and started, so it's no surprise that different components were getting there at the same time and not finding an instance ready. While I could reorder the code to assign it immediately, the second-comers to the call found that the connection was there, but it wasn't finished opening so invoke calls against it failed.

The trick was to check for the promise first, then wait for it to be complete. The resulting code was this:

export class MessagingService {
    private static service: MessagingService;
    private static promise: Promise<void>;

    static async GetService(): Promise<MessagingService> {
        if (!this.promise) {
            const service = new MessagingService();
            this.promise = service.start();
            this.service = service;
        }
        await Promise.all([this.promise]);
        return this.service;
    }

    connection: any;

    private async start() {
        this.connection = new signalR.HubConnectionBuilder().withUrl("/MyHub").withAutomaticReconnect().build();
        await this.connection.start();
    }
}

The static promise was assigned to the start() method, which is essentially the part that starts the connection. Before returning the service instance, I await Promise.all([this.promise]) to make sure it's done. This appears to take care of the race condition, but I'm not convinced that it's possible for two callers to land on the first line of the GetService() method and still find there's no promise.

Related