-
Notifications
You must be signed in to change notification settings - Fork 9
feat: add connection retry to proxyProvider #492
Changes from all commits
0a3b239
044b8a5
9c9729f
3ba9233
49ff4ca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1 @@ | ||
export * from "https://deno.land/std@0.127.0/async/mod.ts" | ||
export * from "https://deno.land/std@0.170.0/async/mod.ts" |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -71,3 +71,54 @@ export function nextIdFactory() { | |
let i = 0 | ||
return () => i++ | ||
} | ||
|
||
export class ListenersContainer< | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The intent of this class is to replace the |
||
DiscoveryValue, | ||
SendErrorData, | ||
HandlerErrorData, | ||
> { | ||
#listeners = new Map< | ||
DiscoveryValue, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm curious how this will be generalized for smoldot (perhaps I recall incorrectly: the discovery value must first be retrieved based on the chainspec, right?) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For smoldot, it's a bit challenging to pick a key for parachains.
|
||
Map< | ||
ProviderListener<SendErrorData, HandlerErrorData>, | ||
ProviderListener<SendErrorData, HandlerErrorData> | ||
> | ||
>() | ||
|
||
set(discoveryValue: DiscoveryValue, listener: ProviderListener<SendErrorData, HandlerErrorData>) { | ||
const map = U.getOrInit(this.#listeners, discoveryValue, () => | ||
new Map< | ||
ProviderListener<SendErrorData, HandlerErrorData>, | ||
ProviderListener<SendErrorData, HandlerErrorData> | ||
>()) | ||
if (map.has(listener)) return | ||
map.set( | ||
listener, | ||
listener.bind({ | ||
stop: () => map!.delete(listener), | ||
}), | ||
) | ||
} | ||
|
||
delete( | ||
discoveryValue: DiscoveryValue, | ||
listener: ProviderListener<SendErrorData, HandlerErrorData>, | ||
) { | ||
this.#listeners.get(discoveryValue)?.delete(listener) | ||
} | ||
|
||
count(discoveryValue: DiscoveryValue) { | ||
return this.#listeners.get(discoveryValue)?.size ?? 0 | ||
} | ||
|
||
forEachListener( | ||
discoveryValue: DiscoveryValue, | ||
message: Parameters<ProviderListener<SendErrorData, HandlerErrorData>>[0], | ||
) { | ||
const map = this.#listeners.get(discoveryValue) | ||
if (!map) return | ||
for (const listener of map.values()) { | ||
listener(message) | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,95 +1,126 @@ | ||
import * as U from "../../util/mod.ts" | ||
import { retry, RetryOptions } from "../../deps/std/async.ts" | ||
import * as msg from "../messages.ts" | ||
import { nextIdFactory, Provider, ProviderConnection, ProviderListener } from "./base.ts" | ||
import { ListenersContainer, nextIdFactory, Provider, ProviderListener } from "./base.ts" | ||
import { ProviderCloseError, ProviderHandlerError, ProviderSendError } from "./errors.ts" | ||
|
||
/** Global lookup of existing connections */ | ||
const connections = new Map<string, ProxyProviderConnection>() | ||
type ProxyProviderConnection = ProviderConnection<WebSocket, Event, Event> | ||
const CUSTOM_WS_CLOSE_CODE = 4000 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Status codes in the range 4000-4999 are reserved for private use (see https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1) This code is used to signal a graceful client |
||
|
||
const nextId = nextIdFactory() | ||
export interface ProxyProviderFactoryProps { | ||
retryOptions?: RetryOptions | ||
} | ||
|
||
export const proxyProvider: Provider<string, Event, Event, Event> = (url, listener) => { | ||
return { | ||
nextId, | ||
send: (message) => { | ||
let conn | ||
try { | ||
conn = connection(url, listener) | ||
} catch (error) { | ||
listener(new ProviderHandlerError(error as Event)) | ||
return | ||
} | ||
;(async () => { | ||
const openError = await ensureWsOpen(conn.inner) | ||
if (openError) { | ||
conn.forEachListener(new ProviderSendError(openError, message)) | ||
return | ||
} | ||
try { | ||
conn.inner.send(JSON.stringify(message)) | ||
} catch (error) { | ||
listener(new ProviderSendError(error as Event, message)) | ||
export const proxyProviderFactory = ( | ||
{ retryOptions }: ProxyProviderFactoryProps = {}, | ||
): Provider<string, Event, Event, Event> => { | ||
const listenersContainer = new ListenersContainer<string, Event, Event>() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be within the module scope? If not, multiple providers could house websockets of identical discovery values (correct?). On the flip side, the current approach means we don't need to track the number of "users" of a globally accessible ws instance (to decide whether or not it can actually be closed). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It depends on how the factory is used. |
||
const activeWs = new Map<string, WebSocket>() | ||
const connectingWs = new Map<string, Promise<WebSocket>>() | ||
return (url, listener) => { | ||
listenersContainer.set(url, listener) | ||
let ws: WebSocket | undefined | ||
return { | ||
nextId: nextIdFactory(), | ||
send: (message) => { | ||
;(async () => { | ||
try { | ||
ws = await openedWs({ | ||
url, | ||
activeWs, | ||
connectingWs, | ||
retryOptions, | ||
listener: (e) => listenersContainer.forEachListener(url, e), | ||
}) | ||
} catch (error) { | ||
return listener(new ProviderHandlerError(error as Event)) | ||
} | ||
try { | ||
ws.send(JSON.stringify(message)) | ||
} catch (error) { | ||
listener(new ProviderSendError(error as Event, message)) | ||
} | ||
})() | ||
}, | ||
release: () => { | ||
listenersContainer.delete(url, listener) | ||
if (!listenersContainer.count(url) && ws) { | ||
return closeWs(ws) | ||
} | ||
})() | ||
}, | ||
release: () => { | ||
const conn = connections.get(url) | ||
if (!conn) { | ||
return Promise.resolve(undefined) | ||
} | ||
const { cleanUp, listeners, inner } = conn | ||
listeners.delete(listener) | ||
if (!listeners.size) { | ||
connections.delete(url) | ||
cleanUp() | ||
return closeWs(inner) | ||
} | ||
return Promise.resolve(undefined) | ||
}, | ||
}, | ||
} | ||
} | ||
} | ||
|
||
function connection( | ||
url: string, | ||
listener: ProviderListener<Event, Event>, | ||
): ProxyProviderConnection { | ||
const conn = U.getOrInit(connections, url, () => { | ||
const controller = new AbortController() | ||
const ws = new WebSocket(url) | ||
ws.addEventListener("message", (e) => { | ||
conn!.forEachListener(msg.parse(e.data)) | ||
}, controller) | ||
ws.addEventListener("error", (e) => { | ||
conn!.forEachListener(new ProviderHandlerError(e)) | ||
}, controller) | ||
ws.addEventListener("close", (e) => { | ||
conn!.forEachListener(new ProviderHandlerError(e)) | ||
}, controller) | ||
return new ProviderConnection(ws, () => controller.abort()) | ||
}) | ||
conn.addListener(listener) | ||
return conn | ||
export const proxyProvider = proxyProviderFactory() | ||
|
||
interface OpenedWsProps { | ||
url: string | ||
activeWs: Map<string, WebSocket> | ||
connectingWs: Map<string, Promise<WebSocket>> | ||
retryOptions?: RetryOptions | ||
listener: ProviderListener<Event, Event> | ||
} | ||
|
||
function ensureWsOpen(ws: WebSocket): Promise<undefined | Event> { | ||
if (ws.readyState === WebSocket.OPEN) { | ||
return Promise.resolve(undefined) | ||
} else if (ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED) { | ||
return Promise.resolve(new Event("error")) | ||
} else { | ||
return new Promise<undefined | Event>((resolve) => { | ||
const controller = new AbortController() | ||
ws.addEventListener("open", () => { | ||
controller.abort() | ||
resolve(undefined) | ||
}, controller) | ||
ws.addEventListener("error", (e) => { | ||
controller.abort() | ||
resolve(e) | ||
}, controller) | ||
function openedWs( | ||
{ url, activeWs, connectingWs, listener, retryOptions }: OpenedWsProps, | ||
): Promise<WebSocket> { | ||
return retry(() => { | ||
const activeWsValue = activeWs.get(url) | ||
if (activeWsValue) return Promise.resolve(activeWsValue) | ||
const connectingWsValue = connectingWs.get(url) | ||
if (connectingWsValue) return connectingWsValue | ||
const openedWs = new Promise<WebSocket>((resolve, reject) => { | ||
const connectingWsController = new AbortController() | ||
const ws = new WebSocket(url) | ||
ws.addEventListener( | ||
"open", | ||
() => { | ||
connectingWsController.abort() | ||
connectingWs.delete(url) | ||
activeWs.set(url, ws) | ||
const activeWsController = new AbortController() | ||
ws.addEventListener( | ||
"message", | ||
(e) => listener(msg.parse(e.data)), | ||
activeWsController, | ||
) | ||
ws.addEventListener( | ||
"error", | ||
(e) => { | ||
activeWs.delete(url) | ||
listener(new ProviderHandlerError(e)) | ||
}, | ||
activeWsController, | ||
) | ||
ws.addEventListener( | ||
"close", | ||
(e) => { | ||
activeWs.delete(url) | ||
activeWsController.abort() | ||
if (e.code !== CUSTOM_WS_CLOSE_CODE) { | ||
listener(new ProviderHandlerError(e)) | ||
} | ||
}, | ||
activeWsController, | ||
) | ||
resolve(ws) | ||
}, | ||
connectingWsController, | ||
) | ||
ws.addEventListener( | ||
"close", | ||
(e) => { | ||
connectingWsController.abort() | ||
connectingWs.delete(url) | ||
activeWs.delete(url) | ||
reject(e) | ||
}, | ||
connectingWsController, | ||
) | ||
}) | ||
} | ||
connectingWs.set(url, openedWs) | ||
return openedWs | ||
}, retryOptions) | ||
} | ||
|
||
function closeWs(socket: WebSocket): Promise<undefined | ProviderCloseError<Event>> { | ||
|
@@ -106,6 +137,6 @@ function closeWs(socket: WebSocket): Promise<undefined | ProviderCloseError<Even | |
controller.abort() | ||
resolve(new ProviderCloseError(e)) | ||
}, controller) | ||
socket.close() | ||
socket.close(CUSTOM_WS_CLOSE_CODE, "Client normal closure") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Where will devs see the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this would be the |
||
}) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I re-wrote this because there is an issue with the client reference counting.
After
The client reference count reaches
1
and it's discarded incapi/effects/rpc.ts
Lines 93 to 102 in 7edf6a2
The above invokes the
proxyProvider.release
that removes theWebSocket
listener.As a result the next call
C.Z.each(...)
successfully creates a newWebSocket
but there is not listener for the incoming messages.Note: The listener is placed by the
rpcClient
effect.