instar 1.3.493 → 1.3.494
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/messaging/slack/SocketModeClient.d.ts +10 -0
- package/dist/messaging/slack/SocketModeClient.d.ts.map +1 -1
- package/dist/messaging/slack/SocketModeClient.js +77 -49
- package/dist/messaging/slack/SocketModeClient.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +2 -2
- package/upgrades/1.3.494.md +27 -0
- package/upgrades/side-effects/socketmode-ws-leak-fix.md +74 -0
|
@@ -21,6 +21,7 @@ export declare class SocketModeClient {
|
|
|
21
21
|
private ws;
|
|
22
22
|
private started;
|
|
23
23
|
private reconnecting;
|
|
24
|
+
private epoch;
|
|
24
25
|
private consecutiveErrors;
|
|
25
26
|
private heartbeatTimer;
|
|
26
27
|
private lastEventAt;
|
|
@@ -32,6 +33,15 @@ export declare class SocketModeClient {
|
|
|
32
33
|
disconnect(): Promise<void>;
|
|
33
34
|
/** Force-reconnect: tear down existing connection and establish a new one. */
|
|
34
35
|
reconnect(): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Deliberately tear down the tracked socket. Bumps the epoch (so any
|
|
38
|
+
* in-flight open/backoff aborts) and nulls `this.ws` BEFORE closing — the
|
|
39
|
+
* socket's close event fires on a later tick, and the identity guard in the
|
|
40
|
+
* close handler (`this.ws !== sock`) is what keeps that stale event from
|
|
41
|
+
* touching whatever connection is current by then. A synchronous flag
|
|
42
|
+
* save/restore cannot do this (#1076).
|
|
43
|
+
*/
|
|
44
|
+
private _teardownSocket;
|
|
35
45
|
/** Queue an outbound message for sending (or send immediately if connected). */
|
|
36
46
|
queueOutbound(data: string): void;
|
|
37
47
|
private _openConnection;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SocketModeClient.d.ts","sourceRoot":"","sources":["../../../src/messaging/slack/SocketModeClient.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,cAAc,EAAiB,MAAM,qBAAqB,CAAC;AAUpE,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3E,aAAa,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACnE,WAAW,EAAE,MAAM,IAAI,CAAC;IACxB,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;CACrD;AAaD,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,SAAS,CAAiB;IAClC,OAAO,CAAC,QAAQ,CAAqB;IACrC,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,YAAY,CAAS;
|
|
1
|
+
{"version":3,"file":"SocketModeClient.d.ts","sourceRoot":"","sources":["../../../src/messaging/slack/SocketModeClient.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,cAAc,EAAiB,MAAM,qBAAqB,CAAC;AAUpE,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3E,aAAa,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACnE,WAAW,EAAE,MAAM,IAAI,CAAC;IACxB,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;CACrD;AAaD,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,SAAS,CAAiB;IAClC,OAAO,CAAC,QAAQ,CAAqB;IACrC,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,YAAY,CAAS;IAM7B,OAAO,CAAC,KAAK,CAAK;IAClB,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,cAAc,CAA8C;IACpE,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,aAAa,CAA2B;IAChD,OAAO,CAAC,cAAc,CAAuB;gBAEjC,SAAS,EAAE,cAAc,EAAE,QAAQ,EAAE,kBAAkB;IAKnE,IAAI,WAAW,IAAI,OAAO,CAEzB;IAEK,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAWxB,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAKjC,8EAA8E;IACxE,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC;IAQhC;;;;;;;OAOG;IACH,OAAO,CAAC,eAAe;IAUvB,gFAAgF;IAChF,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;YAWnB,eAAe;IAkC7B,OAAO,CAAC,iBAAiB;YAqDX,iBAAiB;IAmD/B,OAAO,CAAC,iBAAiB;YAsBX,iBAAiB;IAkB/B,OAAO,CAAC,eAAe;IAiCvB,OAAO,CAAC,eAAe;IASvB,OAAO,CAAC,eAAe;IAOvB,OAAO,CAAC,WAAW;CAOpB"}
|
|
@@ -25,6 +25,12 @@ export class SocketModeClient {
|
|
|
25
25
|
ws = null;
|
|
26
26
|
started = false;
|
|
27
27
|
reconnecting = false;
|
|
28
|
+
// Bumped on every deliberate teardown (disconnect / reconnect / forced /
|
|
29
|
+
// Slack-requested). In-flight async work (an awaited apps.connections.open,
|
|
30
|
+
// a sleeping backoff, a delayed too_many_websockets retry) captures the
|
|
31
|
+
// epoch when it starts and aborts if it changed — a superseded path can
|
|
32
|
+
// never open a connection the client no longer tracks (#1076).
|
|
33
|
+
epoch = 0;
|
|
28
34
|
consecutiveErrors = 0;
|
|
29
35
|
heartbeatTimer = null;
|
|
30
36
|
lastEventAt = 0;
|
|
@@ -38,36 +44,47 @@ export class SocketModeClient {
|
|
|
38
44
|
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
|
39
45
|
}
|
|
40
46
|
async connect() {
|
|
47
|
+
// Defensive: connect() on an already-live client is a reconnect. Without
|
|
48
|
+
// this, a failing dial here would arm a backoff sleeper while the old
|
|
49
|
+
// socket + heartbeat stay live, and the heartbeat's _forceReconnect would
|
|
50
|
+
// then skip dialing (reconnecting=true) while the sleeper stands down on
|
|
51
|
+
// epoch mismatch — no path left to re-dial.
|
|
52
|
+
if (this.ws)
|
|
53
|
+
this._teardownSocket(1000, 'superseded by connect()');
|
|
41
54
|
this.started = true;
|
|
42
55
|
await this._openConnection();
|
|
43
56
|
}
|
|
44
57
|
async disconnect() {
|
|
45
58
|
this.started = false;
|
|
46
|
-
this.
|
|
47
|
-
if (this.ws) {
|
|
48
|
-
this.ws.close(1000, 'client disconnect');
|
|
49
|
-
this.ws = null;
|
|
50
|
-
}
|
|
59
|
+
this._teardownSocket(1000, 'client disconnect');
|
|
51
60
|
}
|
|
52
61
|
/** Force-reconnect: tear down existing connection and establish a new one. */
|
|
53
62
|
async reconnect() {
|
|
54
|
-
this._clearHeartbeat();
|
|
55
63
|
this.reconnecting = false;
|
|
56
64
|
this.consecutiveErrors = 0;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
this._teardownSocket(1000, 'reconnect');
|
|
66
|
+
this.started = true;
|
|
67
|
+
await this._openConnection();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Deliberately tear down the tracked socket. Bumps the epoch (so any
|
|
71
|
+
* in-flight open/backoff aborts) and nulls `this.ws` BEFORE closing — the
|
|
72
|
+
* socket's close event fires on a later tick, and the identity guard in the
|
|
73
|
+
* close handler (`this.ws !== sock`) is what keeps that stale event from
|
|
74
|
+
* touching whatever connection is current by then. A synchronous flag
|
|
75
|
+
* save/restore cannot do this (#1076).
|
|
76
|
+
*/
|
|
77
|
+
_teardownSocket(code, reason) {
|
|
78
|
+
this.epoch++;
|
|
79
|
+
this._clearHeartbeat();
|
|
80
|
+
const sock = this.ws;
|
|
81
|
+
this.ws = null;
|
|
82
|
+
if (sock) {
|
|
62
83
|
try {
|
|
63
|
-
|
|
84
|
+
sock.close(code, reason);
|
|
64
85
|
}
|
|
65
|
-
catch { /*
|
|
66
|
-
this.ws = null;
|
|
67
|
-
this.started = wasStarted;
|
|
86
|
+
catch { /* already dead */ }
|
|
68
87
|
}
|
|
69
|
-
this.started = true;
|
|
70
|
-
await this._openConnection();
|
|
71
88
|
}
|
|
72
89
|
/** Queue an outbound message for sending (or send immediately if connected). */
|
|
73
90
|
queueOutbound(data) {
|
|
@@ -82,12 +99,19 @@ export class SocketModeClient {
|
|
|
82
99
|
}
|
|
83
100
|
}
|
|
84
101
|
async _openConnection() {
|
|
102
|
+
const myEpoch = this.epoch;
|
|
85
103
|
try {
|
|
86
104
|
const response = await this.apiClient.call('apps.connections.open', {}, { useAppToken: true });
|
|
105
|
+
if (myEpoch !== this.epoch)
|
|
106
|
+
return; // superseded while awaiting the API
|
|
87
107
|
if (!response.url) {
|
|
88
108
|
throw new Error('No WebSocket URL in apps.connections.open response');
|
|
89
109
|
}
|
|
90
110
|
this.connectionTime = response.approximate_connection_time ?? null;
|
|
111
|
+
// Invariant: at most one tracked socket. If something is still here,
|
|
112
|
+
// tear it down before replacing it rather than silently orphaning it.
|
|
113
|
+
if (this.ws)
|
|
114
|
+
this._teardownSocket(1000, 'superseded');
|
|
91
115
|
this._connectWebSocket(response.url);
|
|
92
116
|
this.consecutiveErrors = 0;
|
|
93
117
|
}
|
|
@@ -98,34 +122,51 @@ export class SocketModeClient {
|
|
|
98
122
|
return;
|
|
99
123
|
}
|
|
100
124
|
this.handlers.onError(err, false);
|
|
101
|
-
if (this.started) {
|
|
125
|
+
if (this.started && myEpoch === this.epoch) {
|
|
102
126
|
await this._backoffReconnect();
|
|
103
127
|
}
|
|
104
128
|
}
|
|
105
129
|
}
|
|
106
130
|
_connectWebSocket(url) {
|
|
107
|
-
this.ws
|
|
108
|
-
|
|
131
|
+
// Handlers are bound to THIS socket instance, not to `this.ws` — close
|
|
132
|
+
// (and open/message) events fire on a later tick, by which time the
|
|
133
|
+
// tracked socket may already be a replacement. Events from a socket that
|
|
134
|
+
// is no longer current are stale and must be ignored, otherwise a late
|
|
135
|
+
// close orphans the live replacement and double-reconnects (#1076).
|
|
136
|
+
const sock = new WebSocket(url);
|
|
137
|
+
this.ws = sock;
|
|
138
|
+
sock.addEventListener('open', () => {
|
|
139
|
+
if (this.ws !== sock)
|
|
140
|
+
return; // stale socket — replaced while connecting
|
|
109
141
|
this.lastEventAt = Date.now();
|
|
110
142
|
this._startHeartbeat();
|
|
111
143
|
this._drainQueue();
|
|
112
144
|
this.handlers.onConnected();
|
|
113
145
|
});
|
|
114
|
-
|
|
146
|
+
sock.addEventListener('message', (event) => {
|
|
147
|
+
if (this.ws !== sock)
|
|
148
|
+
return; // stale socket — never ack/process on it
|
|
115
149
|
this.lastEventAt = Date.now();
|
|
116
150
|
this._handleRawMessage(typeof event.data === 'string' ? event.data : String(event.data));
|
|
117
151
|
});
|
|
118
|
-
|
|
152
|
+
sock.addEventListener('close', (event) => {
|
|
153
|
+
if (this.ws !== sock)
|
|
154
|
+
return; // stale socket — its teardown already ran
|
|
155
|
+
// A natural close deliberately does NOT bump the epoch: if a backoff
|
|
156
|
+
// sleeper is already in flight, the call below returns early
|
|
157
|
+
// (reconnecting=true) and that sleeper must stay valid to perform the
|
|
158
|
+
// re-dial — bumping here would strand the only reconnect path.
|
|
119
159
|
this._clearHeartbeat();
|
|
120
160
|
this.ws = null;
|
|
121
161
|
this.handlers.onDisconnected(event.reason || 'connection closed');
|
|
122
162
|
if (this.started) {
|
|
163
|
+
const myEpoch = this.epoch;
|
|
123
164
|
this._backoffReconnect().catch((err) => {
|
|
124
165
|
console.error('[slack-socket] Reconnect failed:', err.message);
|
|
125
166
|
// Schedule one more attempt after MAX_BACKOFF to avoid permanent death
|
|
126
167
|
if (this.started) {
|
|
127
168
|
setTimeout(() => {
|
|
128
|
-
if (this.started && !this.reconnecting) {
|
|
169
|
+
if (this.started && !this.reconnecting && this.epoch === myEpoch) {
|
|
129
170
|
this._backoffReconnect().catch(() => { });
|
|
130
171
|
}
|
|
131
172
|
}, MAX_BACKOFF_MS);
|
|
@@ -133,7 +174,7 @@ export class SocketModeClient {
|
|
|
133
174
|
});
|
|
134
175
|
}
|
|
135
176
|
});
|
|
136
|
-
|
|
177
|
+
sock.addEventListener('error', () => {
|
|
137
178
|
// Error event is always followed by close event — handle reconnection there
|
|
138
179
|
});
|
|
139
180
|
}
|
|
@@ -186,18 +227,13 @@ export class SocketModeClient {
|
|
|
186
227
|
}
|
|
187
228
|
}
|
|
188
229
|
_handleDisconnect(reason) {
|
|
189
|
-
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
this.started = false;
|
|
193
|
-
if (this.ws) {
|
|
194
|
-
this.ws.close();
|
|
195
|
-
this.ws = null;
|
|
196
|
-
}
|
|
197
|
-
this.started = wasStarted;
|
|
230
|
+
// Deliberate teardown — bumps the epoch, so the socket's late close event
|
|
231
|
+
// (identity-guarded) and any in-flight open/backoff are all superseded.
|
|
232
|
+
this._teardownSocket(1000, reason);
|
|
198
233
|
this.handlers.onDisconnected(reason);
|
|
199
234
|
if (!this.started)
|
|
200
235
|
return;
|
|
236
|
+
const myEpoch = this.epoch;
|
|
201
237
|
if (reason === 'refresh_requested') {
|
|
202
238
|
// Slack container rotation — reconnect immediately
|
|
203
239
|
this._openConnection();
|
|
@@ -205,7 +241,7 @@ export class SocketModeClient {
|
|
|
205
241
|
else if (reason === 'too_many_websockets') {
|
|
206
242
|
// Wait 30s before reconnecting
|
|
207
243
|
setTimeout(() => {
|
|
208
|
-
if (this.started)
|
|
244
|
+
if (this.started && this.epoch === myEpoch)
|
|
209
245
|
this._openConnection();
|
|
210
246
|
}, TOO_MANY_WS_DELAY_MS);
|
|
211
247
|
}
|
|
@@ -216,13 +252,16 @@ export class SocketModeClient {
|
|
|
216
252
|
async _backoffReconnect() {
|
|
217
253
|
if (this.reconnecting || !this.started)
|
|
218
254
|
return;
|
|
255
|
+
const myEpoch = this.epoch;
|
|
219
256
|
this.reconnecting = true;
|
|
220
257
|
this.consecutiveErrors++;
|
|
221
258
|
// Exponential backoff from first attempt: 1s, 2s, 4s, 8s... max 60s
|
|
222
259
|
const delay = Math.min(1000 * Math.pow(2, this.consecutiveErrors - 1), MAX_BACKOFF_MS);
|
|
223
260
|
await new Promise(r => setTimeout(r, delay));
|
|
224
261
|
this.reconnecting = false;
|
|
225
|
-
|
|
262
|
+
// An explicit reconnect()/disconnect() may have superseded this sleeper —
|
|
263
|
+
// opening here anyway would create a second, untracked connection (#1076).
|
|
264
|
+
if (this.started && this.epoch === myEpoch) {
|
|
226
265
|
await this._openConnection();
|
|
227
266
|
}
|
|
228
267
|
}
|
|
@@ -258,20 +297,9 @@ export class SocketModeClient {
|
|
|
258
297
|
}, HEARTBEAT_INTERVAL_MS);
|
|
259
298
|
}
|
|
260
299
|
_forceReconnect() {
|
|
261
|
-
this.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
// triggering its own reconnect (same pattern as reconnect()).
|
|
265
|
-
const wasStarted = this.started;
|
|
266
|
-
this.started = false;
|
|
267
|
-
try {
|
|
268
|
-
this.ws.close();
|
|
269
|
-
}
|
|
270
|
-
catch { /* ok */ }
|
|
271
|
-
this.ws = null;
|
|
272
|
-
this.started = wasStarted;
|
|
273
|
-
}
|
|
274
|
-
// Trigger reconnect directly instead of relying on close event
|
|
300
|
+
this._teardownSocket(1000, 'force reconnect');
|
|
301
|
+
// Trigger reconnect directly instead of relying on close event — the
|
|
302
|
+
// torn-down socket's close is identity-guarded and will be ignored.
|
|
275
303
|
if (this.started && !this.reconnecting) {
|
|
276
304
|
this._backoffReconnect().catch(() => { });
|
|
277
305
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SocketModeClient.js","sourceRoot":"","sources":["../../../src/messaging/slack/SocketModeClient.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAkB,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAGpE,4DAA4D;AAC5D,IAAI,OAAO,UAAU,CAAC,SAAS,KAAK,WAAW,EAAE,CAAC;IAChD,MAAM,EAAE,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,CAAC;IAC9B,2EAA2E;IAC3E,UAAU,CAAC,SAAS,GAAG,EAAE,CAAC,OAAO,CAAC;AACpC,CAAC;AAeD,MAAM,kBAAkB,GAAG,GAAG,CAAC;AAC/B,MAAM,qBAAqB,GAAG,MAAM,CAAC,CAAG,oCAAoC;AAC5E,MAAM,eAAe,GAAG,OAAO,CAAC,CAAQ,6CAA6C;AACrF,MAAM,cAAc,GAAG,MAAM,CAAC;AAC9B,MAAM,oBAAoB,GAAG,MAAM,CAAC;AAEpC,MAAM,OAAO,gBAAgB;IACnB,SAAS,CAAiB;IAC1B,QAAQ,CAAqB;IAC7B,EAAE,GAAqB,IAAI,CAAC;IAC5B,OAAO,GAAG,KAAK,CAAC;IAChB,YAAY,GAAG,KAAK,CAAC;
|
|
1
|
+
{"version":3,"file":"SocketModeClient.js","sourceRoot":"","sources":["../../../src/messaging/slack/SocketModeClient.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./builtin-manifest.schema.json",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
|
-
"generatedAt": "2026-06-
|
|
5
|
-
"instarVersion": "1.3.
|
|
4
|
+
"generatedAt": "2026-06-12T20:01:24.742Z",
|
|
5
|
+
"instarVersion": "1.3.494",
|
|
6
6
|
"entryCount": 201,
|
|
7
7
|
"entries": {
|
|
8
8
|
"hook:session-start": {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
`SocketModeClient` leaked one live websocket on every deliberate `reconnect()` (JKHeadley/instar#1076). The old "temporarily clear `started`" save/restore was synchronous, but websocket close events fire on a later tick — by the time the old socket's close handler ran, the flag was restored and `this.ws` pointed at the freshly-opened replacement. The stale handler nulled `this.ws` (orphaning the replacement: still OPEN, untracked, counting against Slack's ~10-connection Socket Mode cap) and fired another reconnect. The SleepWake handler calls `reconnect()` on every wake, so after ~10 wakes the cap blew and the client churned with `too_many_websockets` permanently (observed live 2026-06-12: 5,075 disconnects in 73 minutes, plus sustained `apps.connections.open` rate-limiting; only a server restart cleared it). A second race let a sleeping backoff retry open a connection on top of one an explicit `reconnect()` had just opened.
|
|
9
|
+
|
|
10
|
+
The fix is structural, confined to `src/messaging/slack/SocketModeClient.ts`: (1) every socket's event handlers are closure-bound to that socket and ignore events when it is no longer the tracked connection (`this.ws !== sock`); (2) a connection epoch is bumped on every deliberate teardown, and every in-flight async path (awaited `apps.connections.open`, sleeping backoff, delayed `too_many_websockets` retry) captures the epoch at start and stands down if superseded. Invariant: at most one tracked, live connection regardless of how reconnect triggers interleave. The wake-detector trigger-happiness that amplified this (453 wake events/day on ~10s gaps) is tracked separately in JKHeadley/instar#1077.
|
|
11
|
+
|
|
12
|
+
## What to Tell Your User
|
|
13
|
+
|
|
14
|
+
- "Fixed a bug where laptop sleep/wake cycles slowly leaked Slack connections until Slack started kicking the agent off ('too many websockets') — Slack now stays cleanly connected through any number of sleep/wake cycles, no restart ritual needed."
|
|
15
|
+
|
|
16
|
+
## Summary of New Capabilities
|
|
17
|
+
|
|
18
|
+
| Capability | How to Use |
|
|
19
|
+
|-----------|-----------|
|
|
20
|
+
| Leak-free Slack reconnects across sleep/wake | Automatic |
|
|
21
|
+
|
|
22
|
+
## Evidence
|
|
23
|
+
|
|
24
|
+
- New behavioral test file `tests/unit/slack-socket-leak.test.ts` (fake-websocket harness, 5 tests) proven failing on the unfixed client first (3/5 fail — the stale-backoff race opened FOUR connections), all pass on the fix; includes regression guards for natural-close reconnect and deliberate disconnect.
|
|
25
|
+
- Updated source-assert tests in `slack-socket-reconnect.test.ts` / `slack-socket-heartbeat.test.ts` now pin the epoch + identity-guard pattern instead of the broken save/restore pattern.
|
|
26
|
+
- Live incident evidence in JKHeadley/instar#1076 (log counts, onset timeline, post-restart stability).
|
|
27
|
+
- Side-effects artifact: `upgrades/side-effects/socketmode-ws-leak-fix.md`.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Side-Effects Review — SocketModeClient websocket leak fix (#1076)
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `socketmode-ws-leak-fix`
|
|
4
|
+
**Date:** `2026-06-12`
|
|
5
|
+
**Author:** `Instar Agent (echo)`
|
|
6
|
+
**Second-pass reviewer:** `reviewer subagent (connection-recovery path — see appended response)`
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
Fixes JKHeadley/instar#1076: `SocketModeClient` leaked one live websocket per deliberate `reconnect()` because the "temporarily clear `started`" save/restore was synchronous while websocket close events fire on a later tick — the stale close handler orphaned the replacement socket and double-reconnected. Two mechanisms replace it, both confined to `src/messaging/slack/SocketModeClient.ts`: (1) socket event handlers are closure-bound to their own socket and ignore events when that socket is no longer `this.ws`; (2) a connection `epoch` is bumped on every deliberate teardown (`disconnect()`, `reconnect()`, `_forceReconnect()`, `_handleDisconnect()`, all funneled through a new `_teardownSocket()`), and every in-flight async path (awaited `apps.connections.open`, sleeping `_backoffReconnect`, delayed `too_many_websockets` retry, post-failure MAX_BACKOFF retry) captures the epoch at start and stands down if superseded. Tests: new behavioral file `tests/unit/slack-socket-leak.test.ts` (proven failing first), pattern updates in `slack-socket-reconnect.test.ts` and `slack-socket-heartbeat.test.ts`. No decision-point (gate/sentinel/block-allow) surface is touched; this is transport-lifecycle correctness.
|
|
11
|
+
|
|
12
|
+
## Decision-point inventory
|
|
13
|
+
|
|
14
|
+
No decision points touched. The change gates no information flow, blocks no actions, filters no messages, and constrains no agent behavior — it manages the lifecycle of a transport connection. The nearest decision-shaped logic is "should this stale event trigger a reconnect?", which is connection bookkeeping, not agent-behavior authority.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 1. Over-block
|
|
19
|
+
|
|
20
|
+
**What legitimate inputs does this change reject that it shouldn't?**
|
|
21
|
+
|
|
22
|
+
The new guards drop two classes of events: (a) events from a socket that is no longer tracked, (b) scheduled reconnects whose epoch was superseded. Class (a) risk: a `message` event from a just-replaced socket carrying a real Slack envelope is now ignored instead of processed. This is correct, not over-block: processing it would ack on a dead/stale socket (the un-acked event is redelivered by Slack on the live connection — Slack's documented Socket Mode behavior, and the adapter already dedupes redelivered events). Class (b) risk: a superseded backoff stands down — by construction another path has already opened (or is opening) the connection, so dropping the duplicate dial is the fix itself. No legitimate input is rejected.
|
|
23
|
+
|
|
24
|
+
## 2. Under-block
|
|
25
|
+
|
|
26
|
+
**What failure modes does this still miss?**
|
|
27
|
+
|
|
28
|
+
- A socket that dies without EVER emitting close (silent TCP death) is unchanged — that's the existing heartbeat/liveness-probe path, untouched and still covering it.
|
|
29
|
+
- The SleepWake detector's spurious ~10s-gap wakes still trigger frequent (now harmless) redials and tunnel restarts — explicitly out of scope, tracked in JKHeadley/instar#1077. <!-- tracked: JKHeadley/instar#1077 -->
|
|
30
|
+
- If Slack's `apps.connections.open` itself returns errors indefinitely, behavior is the existing backoff loop (unchanged semantics, now epoch-guarded).
|
|
31
|
+
|
|
32
|
+
## 3. Level-of-abstraction fit
|
|
33
|
+
|
|
34
|
+
**Is this at the right layer?**
|
|
35
|
+
|
|
36
|
+
Yes. The leak is intrinsic to the socket-lifecycle bookkeeping inside `SocketModeClient`; neither `SlackAdapter.reconnect()` (a one-line delegate) nor the SleepWake handler in `server.ts` can fix it — they cannot see which socket an event belongs to. Rate-limiting wake events at the server layer (#1077) is complementary, not a substitute: any caller may legitimately call `reconnect()` at any frequency and the client must stay leak-free.
|
|
37
|
+
|
|
38
|
+
## 4. Signal vs authority compliance
|
|
39
|
+
|
|
40
|
+
**Does this hold blocking authority with brittle logic?**
|
|
41
|
+
|
|
42
|
+
Not applicable in the gate sense — the change is not a detector or an authority over agent behavior (see decision-point inventory). Reference reviewed: `docs/signal-vs-authority.md`. The identity/epoch guards are deterministic bookkeeping over objects the module itself owns, the category where plain code is the correct tool.
|
|
43
|
+
|
|
44
|
+
## 5. Interactions
|
|
45
|
+
|
|
46
|
+
**Does it shadow another check, get shadowed, double-fire, race with adjacent cleanup?**
|
|
47
|
+
|
|
48
|
+
- The previous behavior DOUBLE-FIRED `handlers.onDisconnected` for Slack-initiated disconnect envelopes (once from `_handleDisconnect`, once from the late close handler) — visible as duplicated `[slack] Disconnected:` log lines in the incident. The identity guard removes the duplicate; `SlackAdapter`'s `onDisconnected` only stamps `_lastDisconnectedAt` and logs. Honest coverage note (raised by the second-pass reviewer): client-initiated teardowns (`reconnect()`, `_forceReconnect()`) now fire ZERO `onDisconnected` where the old code accidentally fired one via the late close — so `_lastDisconnectedAt` is no longer stamped on those cycles and `_recoverMissedMessages` won't trigger for them. Accepted: the old stamp landed at teardown time (never covering the actual outage window, so the "recovery" it triggered re-read a window with nothing in it), the dominant disconnect paths (natural close, Slack envelopes) still stamp, and Slack redelivers un-acked envelopes on the new connection.
|
|
49
|
+
- `_startHeartbeat()` already self-clears before starting; with the open-handler guard a stale socket can no longer restart the heartbeat for an orphan. The heartbeat's `_forceReconnect` now funnels through `_teardownSocket` like every other teardown — one code path, no divergent cleanup to race.
|
|
50
|
+
- `connect()` on an already-connected client now tears down a pre-existing socket via the `_openConnection` invariant check instead of silently orphaning it (defensive; no current caller does this).
|
|
51
|
+
|
|
52
|
+
## 6. External surfaces
|
|
53
|
+
|
|
54
|
+
**Anything visible to other agents/users/systems? Timing/state dependencies?**
|
|
55
|
+
|
|
56
|
+
No API, config, message-format, or scaffold/template surface changes; no migration needed (pure `src/` behavior fix shipped by version update). Externally visible effect on Slack's side: the app stops accumulating phantom Socket Mode connections and stops hammering `apps.connections.open` — strictly closer to intended behavior. The fix inherently depends on event ordering (that's its subject); the test harness models the real ordering (close events fired manually on a later tick) rather than assuming benign timing.
|
|
57
|
+
|
|
58
|
+
## 7. Rollback cost
|
|
59
|
+
|
|
60
|
+
**If this turns out wrong in production, what's the back-out?**
|
|
61
|
+
|
|
62
|
+
Revert the single source file (+ tests) and release a patch — no data, state, config, or migration coupling. Worst plausible failure mode of the fix itself would be an ignored close event on a socket that IS current (impossible by construction: the guard compares against `this.ws`, and only `_teardownSocket`/the close handler itself clear it), with the existing heartbeat as the independent backstop that force-reconnects any non-OPEN tracked socket within 30s. Cheap, clean rollback.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Second-pass review
|
|
67
|
+
|
|
68
|
+
**Reviewer:** independent reviewer subagent (id a494a8c1260cacf70), 2026-06-12
|
|
69
|
+
**Verdict (verbatim):** "Concur with the review." — after walking every requested interleaving class (dual dials at the same epoch resolve via the `_openConnection` invariant teardown; the `reconnecting` flag cannot stick true since `_backoffReconnect` resets it unconditionally after the sleep and before the epoch check; the natural-close-while-sleeper-in-flight case is safe precisely because natural closes don't bump the epoch).
|
|
70
|
+
|
|
71
|
+
Observations (all non-blocking), and disposition:
|
|
72
|
+
1. Section 5's onDisconnected claim was correct as scoped but incomplete — client-initiated teardowns now fire zero `onDisconnected`, reducing `_recoverMissedMessages` trigger coverage on those cycles. **Disposition: section 5 reworded honestly (above); behavior accepted for the reasons stated there.**
|
|
73
|
+
2. Theoretical permanent-death strand via a defensive double-`connect()` with a failing API. **Disposition: hardened — `connect()` now tears down any pre-existing socket first (see `connect()` comment).**
|
|
74
|
+
3. The epoch deliberately not bumping on natural closes is load-bearing for liveness. **Disposition: documented with a comment in the close handler so a future "bump everywhere for symmetry" refactor can't introduce the permanent-death path.**
|