-
Notifications
You must be signed in to change notification settings - Fork 16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add logic back to try to reconnect to the app websocket if socket is closed #279
Changes from 2 commits
2427bbd
46d887e
76eb323
d6a7db7
1ff4ba2
d95a4d1
6dcaeec
939c11c
868e5d1
b14870c
b38aa5b
382405b
efe02c5
d3d8412
3ffb508
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 |
---|---|---|
|
@@ -40,6 +40,7 @@ export class WsClient extends Emittery { | |
options: WsClientOptions; | ||
private pendingRequests: Record<number, HolochainRequest>; | ||
private index: number; | ||
private authenticationToken: AppAuthenticationToken | undefined; | ||
|
||
constructor(socket: IsoWebSocket, url?: URL, options?: WsClientOptions) { | ||
super(); | ||
|
@@ -178,6 +179,7 @@ export class WsClient extends Emittery { | |
* @param request - The authentication request, containing an app authentication token. | ||
*/ | ||
async authenticate(request: AppAuthenticationRequest): Promise<void> { | ||
this.authenticationToken = request.token; | ||
return this.exchange(request, (request, resolve) => { | ||
const encodedMsg = encode({ | ||
type: "authenticate", | ||
|
@@ -212,6 +214,33 @@ export class WsClient extends Emittery { | |
sendHandler(request, resolve, reject); | ||
}); | ||
return promise as Promise<Response>; | ||
} else if (this.url && this.authenticationToken) { | ||
const response = new Promise<unknown>((resolve, reject) => { | ||
// typescript forgets in this promise scope that `this.url` is not undefined | ||
const socket = new IsoWebSocket(this.url as URL, this.options); | ||
this.socket = socket; | ||
socket.onerror = (errorEvent) => { | ||
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. Can this tell the difference between an invalid token and other kinds of error? It seems like the user experience wouldn't be great if requests always result in a reconnect that fails when a temporary token was in use. If we can tell the token is invalid here, it would be good to clear it so that the client stops trying to reconnect 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. With an invalid token what happens is that you get the "Client disconnected with pending requests" error and this onError callback here is never called in the first place. 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 don't actually understand where the "client disconnected with pending requests" occurs...but if I log something in that 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. Interesting. Well Holochain won't send back anything to tell the client that the connection is going to be closed, it'll just close the connection from its end. Where is that error thrown, can/should it be caught? 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 confirm that his happens. It comes in as a 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. There's a specific |
||
reject( | ||
new HolochainError( | ||
"ConnectionError", | ||
`could not connect to Holochain Conductor API at ${this.url} - ${errorEvent.error}` | ||
) | ||
); | ||
}; | ||
socket.onopen = () => { | ||
// Send authentication token | ||
const encodedMsg = encode({ | ||
type: "authenticate", | ||
data: encode({ | ||
token: this.authenticationToken as AppAuthenticationToken, | ||
}), | ||
}); | ||
this.socket.send(encodedMsg); | ||
sendHandler(request, resolve, reject); | ||
}; | ||
this.setupSocket(); | ||
}); | ||
return response as Promise<Response>; | ||
} else { | ||
return Promise.reject(new Error("Socket is not open")); | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -97,7 +97,8 @@ export const withConductor = | |
}; | ||
|
||
export const installAppAndDna = async ( | ||
adminPort: number | ||
adminPort: number, | ||
nonExpiringToken?: boolean | ||
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. Would it make sense to call this parameter 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. And then have it be true by default? We can do that yes. It's single-use and expiring...I could also make two parameters for expiry and single use. 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 changed it to take two separate parameters now to be aligned with the conductor API naming: d6a7db7 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. That works yes, just expose the API with defaults. I was thinking that single use could have a timeout and when it's false then it could have no expiry. I wouldn't be opposed to the client exposing the API that way because those are the two expected configurations really 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. Though it's probably useful to let people just have access to these |
||
): Promise<{ | ||
installed_app_id: InstalledAppId; | ||
cell_id: CellId; | ||
|
@@ -127,6 +128,8 @@ export const installAppAndDna = async ( | |
}); | ||
const issued = await admin.issueAppAuthenticationToken({ | ||
installed_app_id, | ||
single_use: !nonExpiringToken, | ||
expiry_seconds: nonExpiringToken ? 0 : 30, | ||
}); | ||
const client = await AppWebsocket.connect({ | ||
url: new URL(`ws://localhost:${appPort}`), | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -1441,6 +1441,52 @@ test( | |
}) | ||
); | ||
|
||
test( | ||
"client reconnects websocket if closed before making a zome call", | ||
withConductor(ADMIN_PORT, async (t) => { | ||
const { cell_id, client, admin } = await installAppAndDna(ADMIN_PORT, true); | ||
await admin.authorizeSigningCredentials(cell_id); | ||
await client.client.close(); | ||
const call = client.callZome({ | ||
cell_id, | ||
zome_name: TEST_ZOME_NAME, | ||
fn_name: "bar", | ||
provenance: cell_id[1], | ||
payload: null, | ||
}); | ||
try { | ||
await call; | ||
t.pass("websocket was reconnected successfully"); | ||
} catch (error) { | ||
t.fail("websocket was not reconnected"); | ||
matthme marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
}) | ||
); | ||
|
||
test( | ||
"client fails to reconnect to websocket if closed before making a zome call and does not end up in a loop", | ||
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 isn't quite testing what I was concerned about with looping. It's repeatedly reconnecting with an invalid token that I was concerned about. If the caller isn't careful about which error is which then they might treat this error as retryable and keep trying to reconnect. I'd prefer if this test could make two zome calls and the second one got a different error message back asking the caller to provide new credentials 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. Well, configuring the message to ask for new credentials doesn't work I think due to the "client closed with pending requests" error that doesn't even let us define the error message here. 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. Maybe I can look into handling this close event better in the first place. 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. But I'm not sure whether we should conflate this with this PR 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. There is no way of modifying the error in a way to ask the user to provide a valid token. Any zome call with an invalid token will now lead to the following error:
And this seems to be a result of how the conductor handles things and cannot be affected at the js side as I understand it. This error however is distinguishable by a caller from a "normal" zome call error if required so I don't think there's more we can do here without touching the conductor behavior. |
||
withConductor(ADMIN_PORT, async (t) => { | ||
const { cell_id, client, admin } = await installAppAndDna(ADMIN_PORT); | ||
await admin.authorizeSigningCredentials(cell_id); | ||
await client.client.close(); | ||
const call = client.callZome({ | ||
cell_id, | ||
zome_name: TEST_ZOME_NAME, | ||
fn_name: "bar", | ||
provenance: cell_id[1], | ||
payload: null, | ||
}); | ||
try { | ||
await call; | ||
t.fail( | ||
"reconnecting to websocket should have failed due to an invalid token." | ||
); | ||
} catch (error) { | ||
ThetaSinner marked this conversation as resolved.
Show resolved
Hide resolved
|
||
t.pass("reconnecting to websocket failed"); | ||
} | ||
}) | ||
); | ||
|
||
test( | ||
"Rust enums are serialized correctly", | ||
withConductor(ADMIN_PORT, async (t) => { | ||
|
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 don't believe this is very high risk, but I don't feel comfortable not pointing this out. Storing this token may expose it to an injected script. If it is exfiltrated then it is re-usable elsewhere.
I much prefer the single-use tokens but I understand that this is being done for UX.
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.
It is, and it's similar to the zome call signing credentials. They're equally stored in JavaScript memory for convenience, which compromises security to a degree.