Skip to content

Commit 6395248

Browse files
authored
Merge pull request #3 from omnia-network/release/0.3.2
release/0.3.2
2 parents 2b46b15 + eac3528 commit 6395248

File tree

11 files changed

+364
-119
lines changed

11 files changed

+364
-119
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ic-websocket-js",
3-
"version": "0.3.1",
3+
"version": "0.3.2",
44
"description": "IC WebSocket on the Internet Computer",
55
"license": "MIT",
66
"repository": {

src/ic-websocket.test.ts

Lines changed: 234 additions & 69 deletions
Large diffs are not rendered by default.

src/ic-websocket.ts

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { IDL } from "@dfinity/candid";
99
import { Principal } from "@dfinity/principal";
1010
import {
1111
CanisterAckMessageContent,
12+
CanisterCloseMessageContent,
1213
CanisterWsMessageArguments,
1314
ClientKeepAliveMessageContent,
1415
ClientKey,
@@ -36,10 +37,15 @@ import {
3637
import { WsAgent } from "./agent";
3738

3839
/**
39-
* The default expiration time for receiving an ack message from the canister after sending a message.
40-
* It's **3/2 times** the canister's default send ack period.
40+
* The default interval (in milliseconds) at which the canister sends an ack message.
4141
*/
42-
const DEFAULT_ACK_MESSAGE_TIMEOUT_MS = 450_000;
42+
const DEFAULT_ACK_MESSAGE_INTERVAL_MS = 300_000;
43+
/**
44+
* The maximum communication latency allowed between the client and the canister (same as in the canister).
45+
*
46+
* Used to determine the ack message timeout.
47+
*/
48+
export const COMMUNICATION_LATENCY_BOUND_MS = 30_000;
4349

4450
/**
4551
* Interface to create a new IcWebSocketConfig. For a simple configuration, use {@link createWsConfig}.
@@ -62,12 +68,12 @@ export interface IcWebSocketConfig<S extends _WS_CANISTER_SERVICE> {
6268
*/
6369
networkUrl: string;
6470
/**
65-
* The expiration (in milliseconds) time for receiving an ack message from the canister after sending a message.
66-
* If the ack message is not received within this time, the connection will be closed.
67-
* This parameter should always me **3/2 times or more** the canister's send ack period.
68-
* @default 450_000 (7.5 minutes = 3/2 default send ack period on the canister)
71+
* The interval (in milliseconds) at which the canister sends an ack message.
72+
* This parameter must be **equal** to the canister's send ack interval.
73+
*
74+
* @default 300_000 (default send ack period on the canister)
6975
*/
70-
ackMessageTimeout?: number;
76+
ackMessageIntervalMs?: number;
7177
/**
7278
* The maximum age of the certificate received from the canister, in minutes. You won't likely need to set this parameter. Used in tests.
7379
*
@@ -104,6 +110,7 @@ export default class IcWebSocket<
104110
private _clientKey: ClientKey;
105111
private _gatewayPrincipal: Principal | null = null;
106112
private _maxCertificateAgeInMinutes = 5;
113+
private _openTimeout: NodeJS.Timeout | null = null;
107114

108115
onclose: ((this: IcWebSocket<S, ApplicationMessageType>, ev: CloseEvent) => any) | null = null;
109116
onerror: ((this: IcWebSocket<S, ApplicationMessageType>, ev: ErrorEvent) => any) | null = null;
@@ -174,7 +181,7 @@ export default class IcWebSocket<
174181
});
175182

176183
this._ackMessagesQueue = new AckMessagesQueue({
177-
expirationMs: config.ackMessageTimeout || DEFAULT_ACK_MESSAGE_TIMEOUT_MS,
184+
expirationMs: (config.ackMessageIntervalMs || DEFAULT_ACK_MESSAGE_INTERVAL_MS) + COMMUNICATION_LATENCY_BOUND_MS,
178185
timeoutExpiredCallback: this._onAckMessageTimeout.bind(this),
179186
});
180187

@@ -226,6 +233,27 @@ export default class IcWebSocket<
226233
this._incomingMessagesQueue.addAndProcess(event.data);
227234
}
228235

236+
private _startOpenTimeout() {
237+
// the timeout is double the maximum allowed network latency,
238+
// because opening the connection involves a message sent by the client and one by the canister
239+
this._openTimeout = setTimeout(() => {
240+
if (!this._isConnectionEstablished) {
241+
logger.error("[onWsOpen] Error: Open timeout expired before receiving the open message");
242+
this._callOnErrorCallback(new Error("Open timeout expired before receiving the open message"));
243+
this._wsInstance.close(4000, "Open connection timeout");
244+
}
245+
246+
this._openTimeout = null;
247+
}, 2 * COMMUNICATION_LATENCY_BOUND_MS);
248+
}
249+
250+
private _cancelOpenTimeout() {
251+
if (this._openTimeout) {
252+
clearTimeout(this._openTimeout);
253+
this._openTimeout = null;
254+
}
255+
}
256+
229257
private async _handleHandshakeMessage(handshakeMessage: GatewayHandshakeMessage): Promise<boolean> {
230258
// at this point, we're sure that the gateway_principal is valid
231259
// because the isGatewayHandshakeMessage function checks it
@@ -234,6 +262,8 @@ export default class IcWebSocket<
234262

235263
try {
236264
await this._sendOpenMessage();
265+
266+
this._startOpenTimeout();
237267
} catch (error) {
238268
logger.error("[onWsMessage] Handshake message error:", error);
239269
// if a handshake message fails, we can't continue
@@ -335,12 +365,17 @@ export default class IcWebSocket<
335365
}
336366

337367
this._isConnectionEstablished = true;
368+
this._cancelOpenTimeout();
338369

339370
this._callOnOpenCallback();
340371

341372
this._outgoingMessagesQueue.enableAndProcess();
342373
} else if ("AckMessage" in serviceMessage) {
343374
await this._handleAckMessageFromCanister(serviceMessage.AckMessage);
375+
} else if ("CloseMessage" in serviceMessage) {
376+
await this._handleCloseMessageFromCanister(serviceMessage.CloseMessage);
377+
// we don't have to process any further message (there shouldn't be any anyway)
378+
return false;
344379
} else {
345380
throw new Error("Invalid service message from canister");
346381
}
@@ -369,6 +404,17 @@ export default class IcWebSocket<
369404
await this._sendKeepAliveMessage();
370405
}
371406

407+
private async _handleCloseMessageFromCanister(content: CanisterCloseMessageContent): Promise<void> {
408+
if ("ClosedByApplication" in content.reason) {
409+
logger.debug("[onWsMessage] Received close message from canister. Reason: ClosedByApplication");
410+
this._wsInstance.close(4001, "ClosedByApplication");
411+
} else {
412+
logger.error("[onWsMessage] Received close message from canister. Reason:", content.reason);
413+
this._callOnErrorCallback(new Error(`Received close message from canister. Reason: ${content.reason}`));
414+
this._wsInstance.close(4000, "Received close message from canister");
415+
}
416+
}
417+
372418
private async _sendKeepAliveMessage(): Promise<void> {
373419
const keepAliveMessageContent: ClientKeepAliveMessageContent = {
374420
last_incoming_sequence_num: this._incomingSequenceNum - BigInt(1),

src/idl.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,26 @@ export type CanisterAckMessageContent = {
7878
export type ClientKeepAliveMessageContent = {
7979
'last_incoming_sequence_num': bigint,
8080
};
81+
export type CloseMessageReason = {
82+
WrongSequenceNumber: null,
83+
} | {
84+
InvalidServiceMessage: null,
85+
} | {
86+
KeepAliveTimeout: null,
87+
} | {
88+
ClosedByApplication: null
89+
};
90+
export type CanisterCloseMessageContent = {
91+
reason: CloseMessageReason,
92+
};
8193
export type WebsocketServiceMessageContent = {
8294
OpenMessage: CanisterOpenMessageContent,
8395
} | {
8496
AckMessage: CanisterAckMessageContent,
8597
} | {
8698
KeepAliveMessage: ClientKeepAliveMessageContent,
99+
} | {
100+
CloseMessage: CanisterCloseMessageContent,
87101
};
88102

89103
const CanisterOpenMessageContentIdl = IDL.Record({
@@ -95,10 +109,20 @@ const CanisterAckMessageContentIdl = IDL.Record({
95109
const ClientKeepAliveMessageContentIdl = IDL.Record({
96110
'last_incoming_sequence_num': IDL.Nat64,
97111
});
112+
const CloseMessageReasonIdl = IDL.Variant({
113+
'WrongSequenceNumber': IDL.Null,
114+
'InvalidServiceMessage': IDL.Null,
115+
'KeepAliveTimeout': IDL.Null,
116+
'ClosedByApplication': IDL.Null,
117+
});
118+
const CanisterCloseMessageContentIdl = IDL.Record({
119+
'reason': CloseMessageReasonIdl,
120+
})
98121
const WebsocketServiceMessageContentIdl = IDL.Variant({
99122
'OpenMessage': CanisterOpenMessageContentIdl,
100123
'AckMessage': CanisterAckMessageContentIdl,
101124
'KeepAliveMessage': ClientKeepAliveMessageContentIdl,
125+
'CloseMessage': CanisterCloseMessageContentIdl,
102126
});
103127

104128
export const decodeWebsocketServiceMessageContent = (bytes: Uint8Array): WebsocketServiceMessageContent => {

src/queues.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ describe("BaseQueue", () => {
55

66
beforeEach(() => {
77
queue = new BaseQueue({
8-
itemCallback: (message: string) => true,
8+
itemCallback: (_: string) => true,
99
});
1010
});
1111

@@ -121,6 +121,7 @@ describe("AckMessagesQueue", () => {
121121
const expirationMs = 1000;
122122

123123
beforeEach(() => {
124+
jest.useFakeTimers();
124125
queue = new AckMessagesQueue({
125126
expirationMs,
126127
timeoutExpiredCallback: jest.fn(),
@@ -140,7 +141,7 @@ describe("AckMessagesQueue", () => {
140141
});
141142

142143
it("should call the timeoutExpiredCallback for expired items when not receiving any ack", () => {
143-
jest.useFakeTimers().setSystemTime(Date.now() + expirationMs + 1);
144+
jest.setSystemTime(Date.now() + expirationMs + 1);
144145
queue.add(BigInt(1));
145146
jest.advanceTimersByTime(expirationMs + 1);
146147
expect(queue.last()).toBeNull();
@@ -170,19 +171,18 @@ describe("AckMessagesQueue", () => {
170171

171172
it("should call the timeoutExpiredCallback for expired items when receiving the ack", () => {
172173
queue.add(BigInt(1));
173-
jest.useFakeTimers().setSystemTime(Date.now() + expirationMs + 1);
174174
queue.add(BigInt(2));
175+
queue.add(BigInt(3));
176+
jest.setSystemTime(Date.now() + expirationMs + 1);
175177
queue.ack(BigInt(1));
176-
jest.advanceTimersByTime(expirationMs + 1);
177178
expect(queue.last()).toBeNull();
178-
expect(queue["_timeoutExpiredCallback"]).toHaveBeenCalledWith([BigInt(2)]);
179+
expect(queue["_timeoutExpiredCallback"]).toHaveBeenCalledWith([BigInt(2), BigInt(3)]);
179180
});
180181

181182
it("should call the timeoutExpiredCallback for all expired items after not receiving the ack", () => {
182183
queue.add(BigInt(1));
183184
queue.add(BigInt(2));
184185
queue.add(BigInt(3));
185-
jest.useFakeTimers();
186186
queue.ack(BigInt(1));
187187
jest.advanceTimersByTime(expirationMs);
188188
expect(queue.last()).toBeNull();

src/queues.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -173,13 +173,10 @@ export class AckMessagesQueue {
173173
}
174174

175175
// for the remaining items in the queue, check if they have expired
176-
// if yes, call the callback for the first expired item
177-
for (const item of this._queue) {
178-
if (Date.now() - item.addedAt >= this._expirationMs) {
179-
// if it has expired and is still in the queue,
180-
// it means it has not been acked, so we call the callback
181-
return this._onTimeoutExpired([item]);
182-
}
176+
// if yes, call the callback for the expired items
177+
const expiredItems = this._queue.filter((item) => Date.now() - item.addedAt >= this._expirationMs);
178+
if (expiredItems.length > 0) {
179+
return this._onTimeoutExpired(expiredItems);
183180
}
184181

185182
this._restartLastAckTimeout();

src/test/clients.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@ import { Principal } from "@dfinity/principal";
33

44
export const canisterId = Principal.fromText("bnz7o-iuaaa-aaaaa-qaaaa-cai");
55

6-
// Principal: "pmisz-prtlk-b6oe6-bj4fl-6l5fy-h7c2h-so6i7-jiz2h-bgto7-piqfr-7ae"
7-
// const client1Seed = "rabbit fun moral twin food kangaroo egg among adjust pottery measure seek";
86
export const client1Key: ClientKey = {
9-
client_principal: Principal.fromText("pmisz-prtlk-b6oe6-bj4fl-6l5fy-h7c2h-so6i7-jiz2h-bgto7-piqfr-7ae"),
10-
client_nonce: BigInt("5768810803147064100"),
7+
client_principal: Principal.fromText("kj67s-b5v2y-ahlkr-kmume-xbow6-zwbtj-j4j3m-ae46e-qqrcu-uxiby-yae"),
8+
client_nonce: BigInt("385892949151814926"),
119
};

src/test/constants.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { fromHex } from "@dfinity/agent";
12
import { Principal } from "@dfinity/principal";
23

3-
export const GATEWAY_PRINCIPAL = Principal.fromText("i3gux-m3hwt-5mh2w-t7wwm-fwx5j-6z6ht-hxguo-t4rfw-qp24z-g5ivt-2qe");
4+
export const GATEWAY_PRINCIPAL = Principal.fromText("sqdfl-mr4km-2hfjy-gajqo-xqvh7-hf4mf-nra4i-3it6l-neaw4-soolw-tae");
5+
6+
export const LOCAL_REPLICA_ROOT_KEY = fromHex("d9d9f7a66e69635f6170695f76657273696f6e66302e31382e3068726f6f745f6b65795885308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c050302010361008005229d89a17c6f9ec403a4b1a8aa103fc48055046c95f1e60ee2fbfb0bb23ab21617a93f48b99b1199ac89008cf3cf0a83e9da35f5cf27d0d51535ceff89c43ee236c31c3a7865cc6b333194ad3f7155b2931a7ffec2066777dffb20f277ca6c696d706c5f76657273696f6e65302e382e3069696d706c5f68617368784064613931633732316637386462393433346561336630303437383939383836346439313731346538626561363862333963633736326662306263383937313662757265706c6963615f6865616c74685f737461747573676865616c746879706365727469666965645f68656967687418d4");

0 commit comments

Comments
 (0)