@novasamatech/host-substrate-chain-connection 0.7.4 → 0.7.5-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/pauseController.js
CHANGED
|
@@ -25,6 +25,12 @@ export const createPauseController = () => {
|
|
|
25
25
|
base = b;
|
|
26
26
|
return (onMsg, onH) => {
|
|
27
27
|
reinvocationPending = false;
|
|
28
|
+
// A new inner invocation means a fresh consumer (e.g. the host cached
|
|
29
|
+
// this provider across destroy → re-acquire). Clear `destroyed` so
|
|
30
|
+
// pause/resume work on the new connection. The flag's job is to gate
|
|
31
|
+
// pause/resume between the previous inner.disconnect and the next
|
|
32
|
+
// inner re-invocation — outside that window it should not stick.
|
|
33
|
+
destroyed = false;
|
|
28
34
|
onMessage = onMsg;
|
|
29
35
|
onHalt = onH;
|
|
30
36
|
real = null;
|
|
@@ -174,4 +174,27 @@ describe('createPauseController', () => {
|
|
|
174
174
|
inner(vi.fn(), vi.fn());
|
|
175
175
|
expect(mock.latest.send).not.toHaveBeenCalled();
|
|
176
176
|
});
|
|
177
|
+
it('pause/resume work again after disconnect + a fresh inner invocation', () => {
|
|
178
|
+
// The host caches one provider per chain. After destroyClient cascades
|
|
179
|
+
// through the inner connection's disconnect, the same pauseController is
|
|
180
|
+
// reused on the next lockApi. pause/resume must drive the new socket; the
|
|
181
|
+
// `destroyed` flag from the prior teardown must not stick.
|
|
182
|
+
const mock = createMockBase();
|
|
183
|
+
const pc = createPauseController();
|
|
184
|
+
const inner = pc.middleware(mock.base);
|
|
185
|
+
const firstConn = inner(vi.fn(), vi.fn());
|
|
186
|
+
firstConn.disconnect(); // sets destroyed = true under the old design
|
|
187
|
+
const secondOnHalt = vi.fn();
|
|
188
|
+
inner(vi.fn(), secondOnHalt);
|
|
189
|
+
expect(mock.invocations).toHaveLength(2); // new socket opened
|
|
190
|
+
pc.pause();
|
|
191
|
+
expect(pc.isPaused()).toBe(true);
|
|
192
|
+
expect(mock.invocations[1].disconnect).toHaveBeenCalledTimes(1);
|
|
193
|
+
expect(secondOnHalt).toHaveBeenCalledWith({ type: 'paused' });
|
|
194
|
+
inner(vi.fn(), vi.fn()); // simulate the post-halt re-invocation
|
|
195
|
+
expect(mock.invocations).toHaveLength(2); // still paused → no new base connection
|
|
196
|
+
pc.resume();
|
|
197
|
+
expect(pc.isPaused()).toBe(false);
|
|
198
|
+
expect(mock.invocations).toHaveLength(3); // resume opens a fresh socket
|
|
199
|
+
});
|
|
177
200
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isResponse } from '@polkadot-api/json-rpc-provider';
|
|
1
|
+
import { isRequest, isResponse } from '@polkadot-api/json-rpc-provider';
|
|
2
2
|
const isChainMethod = (method) => method.startsWith('chain_');
|
|
3
3
|
const isSubscribeMethod = (method) => {
|
|
4
4
|
if (isChainMethod(method))
|
|
@@ -8,47 +8,88 @@ const isSubscribeMethod = (method) => {
|
|
|
8
8
|
};
|
|
9
9
|
const isUnsubscribeMethod = (method) => !isChainMethod(method) && method.toLowerCase().includes('unsubscribe');
|
|
10
10
|
export const withSubscriptionReplay = (provider, onReconnect) => onMessage => {
|
|
11
|
-
// request id → request object (sent, awaiting server subscription ID)
|
|
12
11
|
const pendingSubscriptions = new Map();
|
|
13
|
-
//
|
|
12
|
+
// Confirmed subscriptions keyed by the FIRST subId the consumer received.
|
|
13
|
+
// That key stays stable across reconnects so the consumer's subscription
|
|
14
|
+
// manager (which only ever sees the first subId) keeps routing notifications.
|
|
14
15
|
const activeSubscriptions = new Map();
|
|
16
|
+
// Reverse index for inbound notification translation.
|
|
17
|
+
const currentToConsumer = new Map();
|
|
18
|
+
const removeSubscription = (consumerSubId) => {
|
|
19
|
+
const sub = activeSubscriptions.get(consumerSubId);
|
|
20
|
+
if (sub === undefined)
|
|
21
|
+
return undefined;
|
|
22
|
+
activeSubscriptions.delete(consumerSubId);
|
|
23
|
+
currentToConsumer.delete(sub.currentSubId);
|
|
24
|
+
// If a reconnect re-send for this entry is mid-flight, drop the pending
|
|
25
|
+
// record so the eventual response doesn't try to update a removed entry.
|
|
26
|
+
const pending = pendingSubscriptions.get(sub.id);
|
|
27
|
+
if (pending?.reconnectFor === consumerSubId)
|
|
28
|
+
pendingSubscriptions.delete(sub.id);
|
|
29
|
+
return sub;
|
|
30
|
+
};
|
|
15
31
|
const conn = provider(message => {
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const pending = pendingSubscriptions.get(id);
|
|
32
|
+
// Subscribe response (string result) — register fresh, or update an
|
|
33
|
+
// existing entry's currentSubId on a re-confirmation after reconnect.
|
|
34
|
+
if (isResponse(message) && message.id != null && 'result' in message && typeof message.result === 'string') {
|
|
35
|
+
const pending = pendingSubscriptions.get(message.id);
|
|
20
36
|
if (pending !== undefined) {
|
|
21
|
-
pendingSubscriptions.delete(id);
|
|
22
|
-
|
|
37
|
+
pendingSubscriptions.delete(message.id);
|
|
38
|
+
const newSubId = message.result;
|
|
39
|
+
if (pending.reconnectFor !== null) {
|
|
40
|
+
const sub = activeSubscriptions.get(pending.reconnectFor);
|
|
41
|
+
if (sub !== undefined) {
|
|
42
|
+
currentToConsumer.delete(sub.currentSubId);
|
|
43
|
+
sub.currentSubId = newSubId;
|
|
44
|
+
currentToConsumer.set(newSubId, pending.reconnectFor);
|
|
45
|
+
}
|
|
46
|
+
// Suppress: the consumer already saw the original response and
|
|
47
|
+
// registered its subscriber under the original subId.
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
activeSubscriptions.set(newSubId, { id: message.id, payload: pending.payload, currentSubId: newSubId });
|
|
51
|
+
currentToConsumer.set(newSubId, newSubId);
|
|
52
|
+
}
|
|
53
|
+
onMessage(message);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// Notification: rewrite the server's current subId back to the consumer's
|
|
57
|
+
// stable subId so raw-client's subscription manager routes the event.
|
|
58
|
+
if (isRequest(message)) {
|
|
59
|
+
const params = message.params;
|
|
60
|
+
const incoming = params?.subscription;
|
|
61
|
+
if (typeof incoming === 'string') {
|
|
62
|
+
const consumerSubId = currentToConsumer.get(incoming);
|
|
63
|
+
if (consumerSubId !== undefined && consumerSubId !== incoming) {
|
|
64
|
+
onMessage({ ...message, params: { ...params, subscription: consumerSubId } });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
23
67
|
}
|
|
24
68
|
}
|
|
25
69
|
onMessage(message);
|
|
26
70
|
});
|
|
27
71
|
const unsubReconnect = onReconnect(() => {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
pendingSubscriptions.set(id, payload);
|
|
32
|
-
conn.send(payload);
|
|
72
|
+
for (const [consumerSubId, sub] of activeSubscriptions) {
|
|
73
|
+
pendingSubscriptions.set(sub.id, { payload: sub.payload, reconnectFor: consumerSubId });
|
|
74
|
+
conn.send(sub.payload);
|
|
33
75
|
}
|
|
34
76
|
});
|
|
35
77
|
return {
|
|
36
78
|
send(message) {
|
|
37
|
-
|
|
38
|
-
|
|
79
|
+
if (isRequest(message)) {
|
|
80
|
+
const { method, id, params } = message;
|
|
39
81
|
if (isSubscribeMethod(method)) {
|
|
40
|
-
if (id
|
|
41
|
-
pendingSubscriptions.set(id, message);
|
|
82
|
+
if (id != null)
|
|
83
|
+
pendingSubscriptions.set(id, { payload: message, reconnectFor: null });
|
|
42
84
|
}
|
|
43
85
|
else if (isUnsubscribeMethod(method)) {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
activeSubscriptions.delete(subId);
|
|
86
|
+
const consumerSubId = params?.[0];
|
|
87
|
+
const sub = consumerSubId !== undefined ? removeSubscription(consumerSubId) : undefined;
|
|
88
|
+
if (sub !== undefined && sub.currentSubId !== consumerSubId) {
|
|
89
|
+
const rest = (params ?? []).slice(1);
|
|
90
|
+
conn.send({ ...message, params: [sub.currentSubId, ...rest] });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
52
93
|
}
|
|
53
94
|
}
|
|
54
95
|
conn.send(message);
|
|
@@ -56,6 +97,7 @@ export const withSubscriptionReplay = (provider, onReconnect) => onMessage => {
|
|
|
56
97
|
disconnect() {
|
|
57
98
|
pendingSubscriptions.clear();
|
|
58
99
|
activeSubscriptions.clear();
|
|
100
|
+
currentToConsumer.clear();
|
|
59
101
|
unsubReconnect();
|
|
60
102
|
conn.disconnect();
|
|
61
103
|
},
|
|
@@ -98,23 +98,63 @@ describe('withSubscriptionReplay', () => {
|
|
|
98
98
|
expect(mock.send).toHaveBeenCalledWith(msg1);
|
|
99
99
|
expect(mock.send).toHaveBeenCalledWith(msg2);
|
|
100
100
|
});
|
|
101
|
-
it('
|
|
101
|
+
it('translates inbound notifications from the new server subId back to the consumer-facing subId after reconnect', () => {
|
|
102
|
+
const mock = createMockProvider();
|
|
103
|
+
const control = createReconnectControl();
|
|
104
|
+
const onMessage = vi.fn();
|
|
105
|
+
const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(onMessage);
|
|
106
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
107
|
+
mock.simulateMessage(res(1, 'old-sub-id'));
|
|
108
|
+
control.triggerReconnect();
|
|
109
|
+
mock.simulateMessage(res(1, 'new-sub-id'));
|
|
110
|
+
// The post-reconnect re-confirmation must NOT reach the consumer; it would be
|
|
111
|
+
// a duplicate response for a request whose callback was consumed on first connect.
|
|
112
|
+
expect(onMessage).not.toHaveBeenCalledWith(res(1, 'new-sub-id'));
|
|
113
|
+
// Notifications from the server now arrive under the new server subId. The
|
|
114
|
+
// middleware must rewrite them to the consumer's stable subId, otherwise
|
|
115
|
+
// the consumer's subscription manager (which only ever saw 'old-sub-id')
|
|
116
|
+
// would route them nowhere.
|
|
117
|
+
onMessage.mockClear();
|
|
118
|
+
mock.simulateMessage({
|
|
119
|
+
jsonrpc: '2.0',
|
|
120
|
+
method: 'state_storage',
|
|
121
|
+
params: { subscription: 'new-sub-id', result: { changes: [] } },
|
|
122
|
+
});
|
|
123
|
+
expect(onMessage).toHaveBeenCalledWith({
|
|
124
|
+
jsonrpc: '2.0',
|
|
125
|
+
method: 'state_storage',
|
|
126
|
+
params: { subscription: 'old-sub-id', result: { changes: [] } },
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
it('translates outbound unsubscribe requests from the consumer-facing subId to the current server subId', () => {
|
|
130
|
+
const mock = createMockProvider();
|
|
131
|
+
const control = createReconnectControl();
|
|
132
|
+
const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(vi.fn());
|
|
133
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
134
|
+
mock.simulateMessage(res(1, 'old-sub-id'));
|
|
135
|
+
control.triggerReconnect();
|
|
136
|
+
mock.simulateMessage(res(1, 'new-sub-id'));
|
|
137
|
+
mock.send.mockClear();
|
|
138
|
+
// Consumer only knows 'old-sub-id' (the first response it ever received).
|
|
139
|
+
// Middleware must rewrite the unsubscribe params to use the current server subId.
|
|
140
|
+
conn.send(req(2, 'statement_unsubscribeStatement', ['old-sub-id']));
|
|
141
|
+
expect(mock.send).toHaveBeenCalledWith(expect.objectContaining({ method: 'statement_unsubscribeStatement', params: ['new-sub-id'] }));
|
|
142
|
+
});
|
|
143
|
+
it('drops a subscription on consumer-side unsubscribe so it is not replayed on the next reconnect', () => {
|
|
102
144
|
const mock = createMockProvider();
|
|
103
145
|
const control = createReconnectControl();
|
|
104
146
|
const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(vi.fn());
|
|
105
147
|
const subscribeMsg = req(1, 'statement_subscribeStatement');
|
|
106
148
|
conn.send(subscribeMsg);
|
|
107
149
|
mock.simulateMessage(res(1, 'old-sub-id'));
|
|
108
|
-
// First reconnect — replays and moves to pending
|
|
109
150
|
control.triggerReconnect();
|
|
110
|
-
// Server assigns new subscription ID
|
|
111
151
|
mock.simulateMessage(res(1, 'new-sub-id'));
|
|
112
|
-
//
|
|
152
|
+
// Consumer unsubscribes using the only subId it knows about.
|
|
113
153
|
conn.send(req(2, 'statement_unsubscribeStatement', ['old-sub-id']));
|
|
114
154
|
mock.send.mockClear();
|
|
115
|
-
// Second reconnect — subscription is still active (new-sub-id was not removed)
|
|
116
155
|
control.triggerReconnect();
|
|
117
|
-
|
|
156
|
+
// Subscription was unsubscribed; nothing should be re-sent.
|
|
157
|
+
expect(mock.send).not.toHaveBeenCalled();
|
|
118
158
|
});
|
|
119
159
|
it('does not replay unsubscribed subscriptions on reconnect', () => {
|
|
120
160
|
const mock = createMockProvider();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novasamatech/host-substrate-chain-connection",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.5-1",
|
|
5
5
|
"description": "Chain connection pool with ref counting and provider branching for Polkadot API",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"README.md"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@novasamatech/storage-adapter": "0.7.
|
|
28
|
+
"@novasamatech/storage-adapter": "0.7.5-1",
|
|
29
29
|
"@polkadot-api/ws-provider": "^0.9.0",
|
|
30
30
|
"@polkadot-api/json-rpc-provider": "^0.2.0",
|
|
31
31
|
"@polkadot-api/json-rpc-provider-proxy": "^0.4.0",
|