@oxidezap/baileyrs 0.0.35 → 0.1.0

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.
@@ -16,7 +16,7 @@ import { DEFAULT_CONNECTION_CONFIG } from '../Defaults/index.js';
16
16
  import { DisconnectReason } from '../Types/index.js';
17
17
  import { Boom } from '../Utils/boom.js';
18
18
  import { makeEventBuffer } from '../Utils/event-buffer.js';
19
- import { _registerActiveBridgeClient, downloadMediaMessage } from '../Utils/messages.js';
19
+ import { _registerActiveBridgeClient, _unregisterActiveBridgeClient, downloadMediaMessage } from '../Utils/messages.js';
20
20
  import { makeNativeCryptoProvider } from '../Utils/native-crypto-provider.js';
21
21
  import { wrapLegacyStore } from '../Utils/wrap-legacy-store.js';
22
22
  import { assertNodeErrorFree } from '../WABinary/generic-utils.js';
@@ -24,6 +24,8 @@ import { makeBlockingMethods } from './blocking.js';
24
24
  import { makeChatActionMethods } from './chat-actions.js';
25
25
  import { makeContactMethods } from './contacts.js';
26
26
  import { makeCommunityMethods } from './communities.js';
27
+ import { makeBridgeClientOwner } from './bridge-client-owner.js';
28
+ import { makeTerminalCloseReporter } from './terminal-close-reporter.js';
27
29
  import { makeEventHandlers } from './events.js';
28
30
  import { makeGroupMethods } from './groups.js';
29
31
  import { makeMessageMethods } from './messages.js';
@@ -72,10 +74,160 @@ const makeWASocket = (config) => {
72
74
  // this first so `ev.on('creds.update', saveCreds)` persists the merged state
73
75
  // rather than the pre-pair placeholder.
74
76
  ev.on('creds.update', update => Object.assign(auth.creds, update));
75
- let client;
76
- let readyClient;
77
77
  let user;
78
- const ws = new WebSocketClient(fullConfig.waWebSocketUrl, fullConfig, () => client);
78
+ /** True once `init()` has finished wiring the client and started its read loop. */
79
+ let initialized = false;
80
+ /** True while the socket's end handlers run — see `end`. */
81
+ let runningEndHandlers = false;
82
+ /**
83
+ * Consumer teardown hooks, plus the socket's own. Declared here rather than
84
+ * beside their registrations so the owner below can close over the list
85
+ * before anything fills it.
86
+ */
87
+ const socketEndHandlers = [];
88
+ /**
89
+ * Drain both auth stores, returning the FIRST failure rather than throwing
90
+ * so the caller can finish the rest of its work and still report it.
91
+ *
92
+ * Called through their owners on purpose: collecting the two methods into
93
+ * an array and invoking them bare drops the receiver, so a consumer store
94
+ * whose `flush()` touches `this` throws on `undefined` and the teardown
95
+ * publishes a close with the auth writes unpersisted.
96
+ */
97
+ const flushStores = async () => {
98
+ let firstError;
99
+ try {
100
+ await auth.store?.flush?.();
101
+ }
102
+ catch (e) {
103
+ firstError ?? (firstError = e);
104
+ }
105
+ try {
106
+ await autoWrappedStore?.flush?.();
107
+ }
108
+ catch (e) {
109
+ firstError ?? (firstError = e);
110
+ }
111
+ return firstError;
112
+ };
113
+ /**
114
+ * Single home for the bridge client's lifetime. `ws` below reads the current
115
+ * client from it, and this teardown closes `ws` — the cycle is fine because
116
+ * both directions only run once the socket is live.
117
+ */
118
+ const owner = makeBridgeClientOwner({
119
+ logger,
120
+ /**
121
+ * Everything the socket owns beyond the client itself. Runs once, with
122
+ * the client still usable, whether or not one was ever adopted — a
123
+ * teardown that landed mid-init still has a transport to close and a
124
+ * store to drain.
125
+ */
126
+ teardown: async (client, error) => {
127
+ try {
128
+ await ws.close();
129
+ }
130
+ catch {
131
+ // The transport refused to close cleanly. Go straight at the
132
+ // client so the disconnect still happens *before* the barrier
133
+ // and flush below: `release` retries it, but that runs after the
134
+ // flush, and the closing-session ratchet writes a disconnect
135
+ // enqueues would then have nothing left to persist them.
136
+ try {
137
+ await client?.disconnect();
138
+ }
139
+ catch {
140
+ /* ignore */
141
+ }
142
+ }
143
+ if (client) {
144
+ // Barrier: bridge cleanup paths fired during `disconnect()` may
145
+ // emit `set()` calls that are still queued as microtasks /
146
+ // `setImmediate` callbacks at this point. Two yields to the
147
+ // event loop drain (1) the microtask queue and (2) the next
148
+ // macrotask tick where wasm-bindgen async callbacks land.
149
+ // Without this barrier the flushes below run before the bridge
150
+ // has finished writing — a race that loses the last few sets
151
+ // (typically the closing-session ratchet step).
152
+ await new Promise(resolve => setImmediate(resolve));
153
+ await new Promise(resolve => setImmediate(resolve));
154
+ }
155
+ const firstFlushError = await flushStores();
156
+ // End handlers run before the flush error is rethrown: they are the
157
+ // consumer's teardown hook, and a corrupt-on-shutdown auth store is
158
+ // exactly when they most need to run.
159
+ //
160
+ // The flag makes a re-entrant `end()` from inside one of them a
161
+ // no-op instead of a deadlock: shared cleanup used both directly and
162
+ // as an end hook would otherwise be handed the very promise that is
163
+ // waiting for it to return, and nothing would ever settle — no
164
+ // release, and no terminal close until the watchdog.
165
+ runningEndHandlers = true;
166
+ try {
167
+ for (const handler of socketEndHandlers) {
168
+ try {
169
+ await handler(error);
170
+ }
171
+ catch (handlerError) {
172
+ logger.error({ err: handlerError }, 'error in socket end handler');
173
+ }
174
+ }
175
+ }
176
+ finally {
177
+ runningEndHandlers = false;
178
+ }
179
+ if (firstFlushError)
180
+ throw firstFlushError;
181
+ },
182
+ release: async (client) => {
183
+ // `disconnect()` before `free()` is defence in depth, not a fix for a
184
+ // reproduced bug on this path.
185
+ //
186
+ // The hazard is real and reproducible at the bridge: freeing a client
187
+ // with any call still pending corrupts the wasm heap — dlmalloc trips
188
+ // `assertion failed: psize <= size + max_overhead` and the process
189
+ // dies on `RuntimeError: unreachable`, from a microtask no try/catch
190
+ // here can reach, since `free()` itself returns normally.
191
+ // `logout()`, `disconnect()` and a plain `fetchBlocklist()` all
192
+ // reproduce it — see `__tests__/bridge-free-safety.test.ts`.
193
+ //
194
+ // What keeps teardown off that path is the `ws.close()` above, which
195
+ // is itself a `client.disconnect()` (`Compatibility/websocket-client.ts`).
196
+ // This is the belt to that braces, and the gap it closes is
197
+ // `WebSocketClient.close()`'s early return when `closing`/`closed` is
198
+ // already set: that path does NOT await the disconnect it skipped, so
199
+ // `void sock.ws.close(); await sock.end()` could otherwise reach
200
+ // `free()` with the first disconnect still running.
201
+ let disconnected = true;
202
+ try {
203
+ await client.disconnect();
204
+ }
205
+ catch {
206
+ disconnected = false;
207
+ }
208
+ // Teardown already flushed, but only after its own disconnect
209
+ // attempts. If those all failed and this one succeeded, the
210
+ // closing-session ratchet writes it enqueues arrived after that
211
+ // flush — with nothing left to persist them. Cheap enough to just
212
+ // drain again.
213
+ if (disconnected) {
214
+ const lateFlushError = await flushStores();
215
+ if (lateFlushError)
216
+ logger.error({ err: lateFlushError }, 'failed to flush after the final disconnect');
217
+ }
218
+ // Unregister before freeing: `free()` is swallowed, so ordering it
219
+ // last would leave the module-level pointer aimed at a client that is
220
+ // already gone if anything between them threw.
221
+ _unregisterActiveBridgeClient(client);
222
+ try {
223
+ client.free();
224
+ }
225
+ catch {
226
+ /* ignore */
227
+ }
228
+ }
229
+ });
230
+ const ws = new WebSocketClient(fullConfig.waWebSocketUrl, fullConfig, () => owner.peek());
79
231
  let tagEpoch = 0;
80
232
  // Per-socket random prefix avoids collisions between sockets created
81
233
  // in the same millisecond. Date.now()-based prefixes (the previous
@@ -91,6 +243,12 @@ const makeWASocket = (config) => {
91
243
  // `end()` to drain the debounced `saveCreds` timer — `auth.store?.flush?.()`
92
244
  // covers the explicit-store path but not this one.
93
245
  let autoWrappedStore;
246
+ // Mirrors the engine's `enable_auto_reconnect`, which defaults to on. Only
247
+ // `sock.setAutoReconnect()` moves it, and the dispatcher reads it to tell a
248
+ // transient drop from a terminal one.
249
+ let autoReconnectEnabled = true;
250
+ /** Owns reporting the terminal close: once, after teardown, never not at all. */
251
+ const terminalClose = makeTerminalCloseReporter({ logger });
94
252
  const ctx = {
95
253
  ev,
96
254
  logger,
@@ -108,21 +266,58 @@ const makeWASocket = (config) => {
108
266
  user = u;
109
267
  },
110
268
  getClient: () => {
111
- if (readyClient)
112
- return readyClient;
269
+ // `peek()` keeps returning the client through `closing` so teardown
270
+ // can still close the transport with it — but that is teardown's
271
+ // client, not everyone's. Handing it to an ordinary call racing
272
+ // shutdown, or made from an end handler, starts a bridge operation
273
+ // while the client is being disconnected and freed, which is the
274
+ // heap-corruption hazard the whole teardown ordering exists to
275
+ // avoid. Refuse from the moment `close()` is called.
276
+ if (owner.isClosing()) {
277
+ return Promise.reject(new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }));
278
+ }
279
+ // Otherwise gated on `initialized`, not merely on the client
280
+ // existing. `adopt()` publishes it several awaits before
281
+ // `setDeviceProps`, the account lookups and `run()`, so keying off
282
+ // `peek()` alone would hand ordinary calls like `sendMessage()` a
283
+ // half-built client whose read loop has not started — and skip the
284
+ // `initError` check when startup later fails.
285
+ if (initialized) {
286
+ const ready = owner.peek();
287
+ if (ready)
288
+ return Promise.resolve(ready);
289
+ }
113
290
  return initPromise.then(() => {
291
+ // Rechecked after the await: a close landing while startup was
292
+ // still running would otherwise be handed the client anyway.
293
+ //
294
+ // The window between handing a client back and the call reaching
295
+ // wasm cannot be closed here — that needs in-flight call
296
+ // tracking. What covers it is `release`, which awaits
297
+ // `client.disconnect()` before `free()`; the corruption comes
298
+ // from freeing with a call pending, and the disconnect drains
299
+ // those first (`__tests__/bridge-free-safety.test.ts`).
300
+ if (owner.isClosing()) {
301
+ throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed });
302
+ }
114
303
  if (initError) {
115
304
  throw new Boom('Bridge client failed to initialize: ' + initError.message, { statusCode: 500 });
116
305
  }
117
- if (!client)
306
+ const built = owner.peek();
307
+ if (!built)
118
308
  throw new Boom('Client not initialized', { statusCode: 500 });
119
- return client;
309
+ return built;
120
310
  });
121
311
  },
122
312
  getClientSync: () => {
123
- if (!client)
313
+ // Same rule as `getClient` — see there.
314
+ if (owner.isClosing()) {
315
+ throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed });
316
+ }
317
+ const built = owner.peek();
318
+ if (!built)
124
319
  throw new Boom('Client not initialized', { statusCode: 500 });
125
- return client;
320
+ return built;
126
321
  }
127
322
  };
128
323
  // The native repository delegates Signal state directly to the core and does
@@ -140,6 +335,7 @@ const makeWASocket = (config) => {
140
335
  const appStatePatchMutex = makeMutex();
141
336
  const notificationMutex = makeMutex();
142
337
  const activeCallContexts = new Map();
338
+ socketEndHandlers.push(() => activeCallContexts.clear());
143
339
  const groupMethods = makeGroupMethods(ctx);
144
340
  const communityMethods = makeCommunityMethods(ctx, groupMethods);
145
341
  const refreshParticipating = makeParticipatingRefreshHandler(ctx, {
@@ -149,7 +345,8 @@ const makeWASocket = (config) => {
149
345
  const eventHandlers = makeEventHandlers(ctx, {
150
346
  onPairSuccess: data => {
151
347
  pairedAccount = data;
152
- client
348
+ owner
349
+ .peek()
153
350
  ?.getAccount?.()
154
351
  .then((acc) => {
155
352
  cachedAccount = acc ?? undefined;
@@ -165,7 +362,34 @@ const makeWASocket = (config) => {
165
362
  activeCallContexts.set(callId, { peer: event.from, callCreator });
166
363
  }
167
364
  },
168
- onDirtyState: event => refreshParticipating(event.dirtyType)
365
+ onDirtyState: event => refreshParticipating(event.dirtyType),
366
+ /**
367
+ * The engine has stopped reconnecting, so this client is dead weight
368
+ * that only `free()` reclaims — `run()` returns `void`, so its loop
369
+ * exiting is otherwise invisible from here.
370
+ *
371
+ * Tearing down and reporting are both handed to the reporter: the close
372
+ * has to reach the consumer exactly once and only after this socket has
373
+ * released what it owns, or a replacement built in response overlaps it
374
+ * on the same auth folder.
375
+ */
376
+ onTerminalClose: (error, publish) => {
377
+ // `owner.close()`, not `end()`. `end()` short-circuits when called
378
+ // from inside an end handler — it has to, or the handler awaits the
379
+ // teardown waiting for it — and a terminal event raised from one of
380
+ // those would then publish against an already-resolved promise,
381
+ // letting a close listener build a replacement while the old client
382
+ // is still owned. This waits for the real teardown, and cannot
383
+ // deadlock because `reportAfter` runs it detached; nothing in the
384
+ // teardown is waiting on this.
385
+ terminalClose.reportAfter(() => owner.close(error).finally(() => initPromise), publish);
386
+ },
387
+ isAutoReconnectEnabled: () => autoReconnectEnabled,
388
+ // Timers the dispatcher armed outlive the events that armed them, and
389
+ // only the terminal-close path clears them. Ending the socket any other
390
+ // way — `sock.end()`, an `await using` scope exiting — has to as well,
391
+ // or one fires from a socket whose client is already freed.
392
+ onCleanup: cleanup => socketEndHandlers.push(cleanup)
169
393
  });
170
394
  const init = async () => {
171
395
  if (!wasmInitialized) {
@@ -211,25 +435,50 @@ const makeWASocket = (config) => {
211
435
  }
212
436
  if (useNativeMemory)
213
437
  logger.debug('auth: using socket-local native memory backend');
214
- client = await createWhatsAppClient(makeTransport(fullConfig), makeHttpClient(fullConfig), eventHandlers, bridgeStore, fullConfig.cache ?? null, fullConfig.version, fullConfig.wantedPreKeyCount ?? null);
438
+ const created = await createWhatsAppClient(makeTransport(fullConfig), makeHttpClient(fullConfig), eventHandlers, bridgeStore, fullConfig.cache ?? null, fullConfig.version, fullConfig.wantedPreKeyCount ?? null);
439
+ // `end()` can land while the client is still being built — a `sock.end()`
440
+ // or `await using` right after `makeWASocket()` does exactly that. When
441
+ // it has, `adopt` frees this client and tells us to stop: nothing else
442
+ // would ever own it, and `run()` below would reconnect it forever
443
+ // against a socket the caller already disposed.
444
+ // `adopt` starts releasing the refused client; joining it here keeps
445
+ // that work inside `initPromise`, which `Symbol.asyncDispose` awaits.
446
+ if (!owner.adopt(created))
447
+ return owner.settled();
448
+ // Fallback for standalone helpers like `downloadContentFromMessage`
449
+ // that carry no socket reference.
450
+ _registerActiveBridgeClient(created, logger);
451
+ // Replay a preference set before the client existed. `setAutoReconnect`
452
+ // forwards through `client?.`, so `makeWASocket(cfg).setAutoReconnect(false)`
453
+ // — the idiomatic first line — used to move only the JS mirror and leave
454
+ // the engine retrying.
455
+ if (!autoReconnectEnabled)
456
+ created.setAutoReconnect(false);
457
+ // Everything below talks to `created`, which stays valid for the whole
458
+ // body, and re-checks `isClosing()` between awaits: once teardown has
459
+ // started it owns this client, and issuing more bridge calls against it
460
+ // races the release.
215
461
  if (fullConfig.pushName) {
216
- await client.setInitialPushName(fullConfig.pushName);
462
+ await created.setInitialPushName(fullConfig.pushName);
217
463
  }
218
- // Make this client the fallback for standalone helpers like
219
- // downloadContentFromMessage that have no socket reference.
220
- _registerActiveBridgeClient(client, logger);
464
+ if (owner.isClosing())
465
+ return;
221
466
  const [osName, browserName] = fullConfig.browser;
222
467
  const deviceOs = browserName === 'Android' ? 'Android' : osName;
223
- await client.setDeviceProps({
468
+ await created.setDeviceProps({
224
469
  os: deviceOs,
225
470
  platformType: browserToPlatformType(browserName),
226
471
  ...fullConfig.deviceProps
227
472
  });
473
+ if (owner.isClosing())
474
+ return;
228
475
  const [jid, lid, account] = await Promise.all([
229
- client.getJid(),
230
- client.getLid(),
231
- client.getAccount().catch(() => undefined)
476
+ created.getJid(),
477
+ created.getLid(),
478
+ created.getAccount().catch(() => undefined)
232
479
  ]);
480
+ if (owner.isClosing())
481
+ return;
233
482
  if (jid) {
234
483
  user = { id: jid, lid: lid ?? undefined };
235
484
  }
@@ -240,129 +489,145 @@ const makeWASocket = (config) => {
240
489
  // `UserAgent.platform = ANDROID` (no `web_info`), mirroring upstream
241
490
  // Baileys PR #2201. Required for the server to deliver view_once payloads.
242
491
  if (browserName === 'Android') {
243
- await client.setClientProfile({ preset: 'android', osVersion: osName });
492
+ await created.setClientProfile({ preset: 'android', osVersion: osName });
493
+ if (owner.isClosing())
494
+ return;
244
495
  }
245
496
  if (isRawNodeForwardingEnabled(ws)) {
246
- client.setRawNodeForwarding(true);
247
- }
248
- // `run()` is fire-and-forget by design (the bridge runs the read
249
- // loop until disconnect/free) but typed `Promise<void>`. A late
250
- // rejection (lost connection during cleanup, etc) without a
251
- // `.catch` would escape to `process.on('unhandledRejection')`.
252
- // Funnel into the connection.update channel so consumers' regular
253
- // reconnect/diagnostic plumbing handles it like any other close.
254
- const runPromise = client.run();
255
- if (runPromise && typeof runPromise.catch === 'function') {
256
- runPromise.catch(err => {
257
- logger.error({ err }, 'bridge client.run() rejected');
258
- ev.emit('connection.update', {
259
- connection: 'close',
260
- lastDisconnect: {
261
- // restartRequired (515) signals "Rust read loop crashed
262
- // unrecoverably, restart the sock". Previously mapped to
263
- // 500, which collided with the server-side <stream:error
264
- // code="500"> path and made it impossible for consumers
265
- // to tell the two apart.
266
- error: err instanceof Error ? err : new Boom(String(err), { statusCode: DisconnectReason.restartRequired }),
267
- date: new Date()
268
- }
269
- });
270
- });
497
+ created.setRawNodeForwarding(true);
271
498
  }
499
+ // Same race as above: teardown already owns and releases this client, so
500
+ // starting the read loop now would run against a handle about to go.
501
+ if (owner.isClosing())
502
+ return;
503
+ // `run()` spawns the connect/handshake/read/reconnect loop as a
504
+ // background task and returns `void` — it deliberately is not `async`,
505
+ // so that it does not hold a wasm-bindgen borrow on `self` that would
506
+ // block `disconnect()`.
507
+ //
508
+ // Consequence: the loop's exit is not observable from here. The engine
509
+ // clears `enable_auto_reconnect` and breaks out on every terminal
510
+ // disconnect (conflict/401/409/516, and any `<failure>` whose reason is
511
+ // not 500/503), and when it does, the `WasmWhatsAppClient` is dead
512
+ // weight that only `sock.end()` can free — nothing else can, because
513
+ // the bridge holds the JS event callbacks as wasm-bindgen externrefs,
514
+ // those close over `ctx`, and `ctx` closes over `client`, so the cycle
515
+ // crosses the JS/wasm boundary and no `FinalizationRegistry` fires.
516
+ // Freeing that automatically needs the bridge to expose loop completion
517
+ // (a terminal callback or an awaitable handle); until it does, the
518
+ // consumer has to call `sock.end()` on a terminal close.
519
+ created.run();
520
+ initialized = true;
521
+ };
522
+ /**
523
+ * Joins startup too, not just the teardown.
524
+ *
525
+ * `owner.close()` covers the client it can see. A close landing while
526
+ * `createWhatsAppClient()` is still pending sees none — the client arrives
527
+ * afterwards, `adopt()` refuses it, and the release runs detached. Without
528
+ * waiting for `init()` to finish, `await sock.end()` therefore returns while
529
+ * that client is still disconnecting and being freed, and the replacement
530
+ * socket the consumer builds next overlaps it on the same auth folder.
531
+ *
532
+ * `initPromise` is declared below and swallows its own failures, so this
533
+ * neither hits its TDZ (nothing can call `end` during the synchronous
534
+ * construction below) nor masks the teardown error.
535
+ */
536
+ const end = (error) => {
537
+ // Called from inside an end handler, the teardown is already running and
538
+ // is waiting for that handler to return. Handing back its promise would
539
+ // have the handler await itself.
540
+ if (runningEndHandlers)
541
+ return Promise.resolve();
542
+ return owner.close(error).finally(() => initPromise);
272
543
  };
273
544
  let initError;
274
- const initPromise = init()
275
- .then(() => {
276
- if (client)
277
- readyClient = Promise.resolve(client);
278
- })
279
- .catch(err => {
545
+ // Started only once `end` exists. `init()`'s synchronous prefix reaches
546
+ // `await createWhatsAppClient(...)` before the bridge can dispatch anything,
547
+ // so today nothing can call `onTerminalClose` — and therefore `end` — that
548
+ // early. But that is an argument about the current shape of `init()`, not a
549
+ // rule the code enforces: dispatch a terminal close any sooner and line 401
550
+ // becomes a `ReferenceError` inside a bridge callback, where no `try/catch`
551
+ // of ours can reach it. Ordering it here makes the dependency structural.
552
+ const initPromise = init().catch(err => {
280
553
  initError = err instanceof Error ? err : new Error(String(err));
281
554
  logger.error({ err }, 'failed to initialize bridge client');
555
+ // A client adopted before the failure outlives a read loop that never
556
+ // started: `getClient()` correctly rejects, but the standalone
557
+ // helpers bypass it and would keep reaching the half-built client.
558
+ return owner.discard();
282
559
  });
283
- let ended = false;
284
- const socketEndHandlers = [
285
- () => activeCallContexts.clear()
286
- ];
287
- const end = async (error) => {
288
- if (ended) {
289
- logger.trace({ trace: error?.stack }, 'connection already closed');
290
- return;
291
- }
292
- ended = true;
293
- const c = client;
294
- if (c) {
295
- try {
296
- await ws.close();
297
- }
298
- catch {
299
- /* ignore */
300
- }
301
- client = undefined;
302
- readyClient = undefined;
303
- // Barrier: bridge cleanup paths fired during `disconnect()` may
304
- // emit `set()` calls that are still queued as microtasks /
305
- // `setImmediate` callbacks at this point. Two yields to the
306
- // event loop drain (1) microtask queue and (2) the next
307
- // macrotask tick where wasm-bindgen async callbacks land.
308
- // Without this barrier, the flushes below run before the bridge
309
- // has finished writing — a race that loses the last few sets
310
- // (typically the closing-session ratchet step).
311
- await new Promise(resolve => setImmediate(resolve));
312
- await new Promise(resolve => setImmediate(resolve));
313
- // Capture the FIRST flush failure so a corrupt-on-shutdown auth
314
- // state surfaces to the caller. Always finish the rest of
315
- // cleanup; rethrow at the end so c.free() still runs.
316
- let firstFlushError;
317
- try {
318
- await auth.store?.flush?.();
319
- }
320
- catch (e) {
321
- firstFlushError ?? (firstFlushError = e);
322
- }
323
- try {
324
- await autoWrappedStore?.flush?.();
325
- }
326
- catch (e) {
327
- firstFlushError ?? (firstFlushError = e);
328
- }
329
- try {
330
- c.free();
331
- }
332
- catch {
333
- /* ignore */
334
- }
335
- if (firstFlushError)
336
- throw firstFlushError;
337
- }
338
- for (const handler of socketEndHandlers) {
339
- try {
340
- await handler(error);
341
- }
342
- catch (handlerError) {
343
- logger.error({ err: handlerError }, 'error in socket end handler');
344
- }
345
- }
346
- };
560
+ /**
561
+ * Idempotent shutdown that later callers can actually await — the socket
562
+ * now ends *itself* on a terminal disconnect, so a consumer's
563
+ * `await sock.end()` is usually the second call. Resolving it early while
564
+ * the first was still flushing turned
565
+ * `close` → `await sock.end()` → `makeWASocket()` into two writers on one
566
+ * auth folder.
567
+ */
347
568
  const logout = async (msg) => {
348
569
  user = undefined;
349
- if (client) {
570
+ const logoutError = new Boom(msg || 'Logged out', { statusCode: DisconnectReason.loggedOut });
571
+ // `Client::logout()` dispatches `LoggedOut` itself, which the dispatcher
572
+ // turns into the terminal close. Reporting our own on top of that gave
573
+ // consumers two `close` events for one logout, and upstream guarantees
574
+ // at most one — so watch for the dispatcher's instead of assuming
575
+ // either way. Counting rather than flagging, because a terminal close
576
+ // may already have happened earlier in this socket's life.
577
+ // `Client::logout()` dispatches `LoggedOut` itself, which the dispatcher
578
+ // turns into the terminal close. Reporting our own on top of that gave
579
+ // consumers two closes for one logout, and upstream guarantees at most
580
+ // one — so watch for the dispatcher's instead of assuming either way.
581
+ const reportedBefore = terminalClose.hasReported();
582
+ const live = owner.peek();
583
+ if (live) {
350
584
  try {
351
- await client.logout();
585
+ await live.logout();
352
586
  }
353
587
  catch {
354
588
  /* ignore */
355
589
  }
356
590
  }
357
- const logoutError = new Boom(msg || 'Logged out', { statusCode: DisconnectReason.loggedOut });
358
- ev.emit('connection.update', {
359
- connection: 'close',
360
- lastDisconnect: {
361
- error: logoutError,
362
- date: new Date()
591
+ // Nothing announced it: no client at all, or `logout()` threw before the
592
+ // bridge got to it. Upstream always reports exactly one close for a
593
+ // logout, so emitting none would be worse than emitting one too many.
594
+ // Keyed on a close having been *reported*, not on the socket closing —
595
+ // a plain `end()` reports nothing, so keying off that would let a logout
596
+ // racing one finish with no close at all.
597
+ const mayNeedFallback = !reportedBefore && !terminalClose.hasReported();
598
+ try {
599
+ await end(logoutError);
600
+ }
601
+ finally {
602
+ // After the teardown, like the dispatcher's own close: doing it
603
+ // before would hand a logged-out handler a socket still flushing the
604
+ // auth folder it is about to delete. In a `finally` because `end()`
605
+ // rethrows the first flush failure and the owner releases the client
606
+ // regardless — on that path listeners would otherwise see no
607
+ // terminal close at all for a socket that has definitely ended.
608
+ // Rechecked here, not just before the await: a dispatcher close
609
+ // arriving while `end()` ran would otherwise be followed by this
610
+ // stale decision, giving consumers two closes for one logout.
611
+ if (mayNeedFallback && !terminalClose.hasReported()) {
612
+ terminalClose.reportNow(() => ev.emit('connection.update', {
613
+ connection: 'close',
614
+ lastDisconnect: {
615
+ error: logoutError,
616
+ date: new Date()
617
+ }
618
+ }));
363
619
  }
364
- });
365
- await end(logoutError);
620
+ }
621
+ // The close is published on a chain one hop further out than the
622
+ // teardown both paths await, so without this `await sock.logout()`
623
+ // returns just before the event it caused.
624
+ //
625
+ // Skipped when re-entered from an end handler, for the same reason
626
+ // `end()` short-circuits there: the publish waits for the teardown, the
627
+ // teardown waits for the handler, and the handler would be waiting here
628
+ // — stalled until the watchdog fires.
629
+ if (!runningEndHandlers)
630
+ await terminalClose.published();
366
631
  };
367
632
  const registerSocketEndHandler = (handler) => {
368
633
  socketEndHandlers.push(handler);
@@ -428,6 +693,39 @@ const makeWASocket = (config) => {
428
693
  logger,
429
694
  ws,
430
695
  type: 'md',
696
+ /**
697
+ * `await using sock = makeWASocket(config)` — frees the wasm client on
698
+ * scope exit. Nothing else can: the bridge holds the JS event callbacks
699
+ * as wasm-bindgen externrefs and those callbacks reach back to this
700
+ * closure, so the reference cycle crosses the JS/wasm boundary and no
701
+ * `FinalizationRegistry` will ever fire for the client.
702
+ *
703
+ * Delegates to `end()`, so it flushes the auth store and is idempotent.
704
+ *
705
+ * Then awaits `initPromise`, because `end()` alone does not satisfy the
706
+ * async-disposal contract: called before `createWhatsAppClient()`
707
+ * settles, it sees `client === undefined`, frees nothing, and resolves
708
+ * while initialization is still running. What actually disposes that
709
+ * client is the `ended` guard inside `init()` — so code after the
710
+ * `await using` scope would otherwise overlap with bridge construction,
711
+ * event callbacks, and store access. `end()` runs first because it sets
712
+ * `ended` synchronously, which is what makes `init()` bail out early.
713
+ *
714
+ * The `finally` is load-bearing: `end()` rethrows the first auth-store
715
+ * flush failure, and letting that propagate directly would skip the wait
716
+ * and resolve the disposer with `init()` still in flight — losing the
717
+ * one guarantee it exists to provide, in exactly the situation where
718
+ * cleanup already went wrong. `initPromise` swallows its own failures,
719
+ * so awaiting it here cannot mask the flush error.
720
+ */
721
+ async [Symbol.asyncDispose]() {
722
+ try {
723
+ await end(undefined);
724
+ }
725
+ finally {
726
+ await initPromise;
727
+ }
728
+ },
431
729
  // Upstream `socket.ts:1106-1108` returns `authState.creds.me`, which
432
730
  // carries `{ id, lid, name, verifiedName, ... }` — full Contact
433
731
  // shape. Returning the bare `{id, lid}` like before broke
@@ -446,13 +744,13 @@ const makeWASocket = (config) => {
446
744
  };
447
745
  },
448
746
  get waClient() {
449
- return client;
747
+ return owner.peek();
450
748
  },
451
749
  get isConnected() {
452
- return client?.isConnected() ?? false;
750
+ return owner.peek()?.isConnected() ?? false;
453
751
  },
454
752
  get isLoggedIn() {
455
- return client?.isLoggedIn() ?? false;
753
+ return owner.peek()?.isLoggedIn() ?? false;
456
754
  },
457
755
  get authState() {
458
756
  return {
@@ -496,7 +794,10 @@ const makeWASocket = (config) => {
496
794
  registerSocketEndHandler,
497
795
  waitForConnectionUpdate,
498
796
  setAutoReconnect: (enabled) => {
499
- client?.setAutoReconnect(enabled);
797
+ // Mirrored locally because the dispatcher has to know: with this off,
798
+ // a plain drop is terminal rather than the start of a backoff.
799
+ autoReconnectEnabled = enabled;
800
+ owner.peek()?.setAutoReconnect(enabled);
500
801
  },
501
802
  /**
502
803
  * Update presence either globally (`available`/`unavailable`) or per-chat