@floegence/flowersec-core 0.22.1 → 0.23.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.
@@ -77,19 +77,19 @@ Source: `flowersec-ts/src/yamux/constants.ts`, `flowersec-ts/src/yamux/header.ts
77
77
  The TS Yamux implementation is exercised in real interop scenarios:
78
78
 
79
79
  - **TS client ↔ Go server (minimal Yamux over TCP)**:
80
- - Test: `flowersec-ts/src/e2e/yamux_interop.test.ts` (minimal tcp mode)
81
- - Go harness: `flowersec-go/internal/cmd/flowersec-yamux-harness/main.go`
80
+ - Test: `flowersec-go/internal/cmd/flowersec-interop` with the TypeScript JSONL harness
81
+ - Go reference: production `client`, `endpoint`, and Yamux-backed session APIs
82
82
  - Covers: window update race, RST handling, concurrent open/close, session close.
83
83
  - Notes: opt-in via `YAMUX_INTEROP=1` (runs Go harnesses).
84
84
  - Notes: sizes scale via `YAMUX_INTEROP_SCALE` (e.g. `2` => 20 streams / 1 MiB per stream).
85
85
  - Notes: client-initiated RST scenarios run when `YAMUX_INTEROP_CLIENT_RST=1`.
86
86
  - Notes: window-update and concurrent-open/close stress runs when `YAMUX_INTEROP_STRESS=1`.
87
87
  - **TS client ↔ Go server (full chain: E2EE + tunnel + Yamux)**:
88
- - Test: `flowersec-ts/src/e2e/yamux_interop.test.ts` (full chain mode)
88
+ - Test: the Direct/Tunnel variants in `stability/interop_matrix.json`
89
89
  - Go harness: `flowersec-go/internal/cmd/flowersec-e2e-harness/main.go` with `-scenario`
90
90
  - Notes: reduced stream counts/payload sizes to keep end-to-end runtime bounded.
91
91
  - **Layered close/reset probes (memory + WS + full chain)**:
92
- - Test: `flowersec-ts/src/e2e/yamux_interop_layers.test.ts`
92
+ - Test: the Go-reference smoke and stress profiles in `testdata/interop/v1/profiles.json`
93
93
  - Covers: session-close wakeup, FIN/RST delivery across SecureChannel + WebSocketBinaryTransport,
94
94
  and a full-chain FIN/RST probe on `rst_mid_write_go`.
95
95
  - Notes: gated by `YAMUX_INTEROP=1`; the full-chain probe additionally requires
@@ -14,7 +14,7 @@ import { prepareChannelId } from "./contract.js";
14
14
  import { isTunnelAttachCloseReason } from "./tunnelAttachCloseReason.js";
15
15
  import { enforceTransportSecurity } from "./transportSecurity.js";
16
16
  import { maxPlaintextBytes } from "../e2ee/record.js";
17
- import { isYamuxResourceExhaustedError } from "../yamux/errors.js";
17
+ import { isYamuxPingTimeoutError, isYamuxResourceExhaustedError } from "../yamux/errors.js";
18
18
  import { registerClientTermination } from "./termination.js";
19
19
  export async function connectCore(args) {
20
20
  const observer = normalizeObserver(args.opts.observer, { path: args.path });
@@ -143,12 +143,6 @@ export async function connectCore(args) {
143
143
  catch (err) {
144
144
  const reason = classifyConnectError(err);
145
145
  observer.onConnect(args.path, "fail", reason, nowSeconds() - connectStart);
146
- try {
147
- transport.close();
148
- }
149
- catch {
150
- // ignore
151
- }
152
146
  const code = reason === "timeout" ? "timeout" : reason === "canceled" ? "canceled" : "dial_failed";
153
147
  throw new FlowersecError({
154
148
  path: args.path,
@@ -167,12 +161,6 @@ export async function connectCore(args) {
167
161
  catch (err) {
168
162
  observer.onAttach("fail", "send_failed");
169
163
  attachState = "failed";
170
- try {
171
- transport.close();
172
- }
173
- catch {
174
- // ignore
175
- }
176
164
  throw new FlowersecError({ path: args.path, stage: "attach", code: "attach_failed", message: "attach failed", cause: err });
177
165
  }
178
166
  }
@@ -202,7 +190,6 @@ export async function connectCore(args) {
202
190
  catch (err) {
203
191
  const handshakeElapsedSeconds = nowSeconds() - handshakeStart;
204
192
  const handshakeCode = classifyHandshakeError(err);
205
- transport.close();
206
193
  if (args.path === "tunnel" && err instanceof WsCloseError) {
207
194
  const reason = err.reason;
208
195
  if (isTunnelAttachCloseReason(reason)) {
@@ -233,17 +220,20 @@ export async function connectCore(args) {
233
220
  });
234
221
  let terminationReported = false;
235
222
  let closeAll = () => {
236
- try {
237
- secure.close();
238
- }
239
- catch { /* ignore */ }
223
+ runSyncCleanups([() => secure.close()]);
240
224
  };
241
225
  const reportTermination = (error) => {
242
226
  if (terminationReported)
243
227
  return;
244
228
  terminationReported = true;
245
- closeAll();
246
- resolveTermination({ error });
229
+ let terminalError = error;
230
+ try {
231
+ closeAll();
232
+ }
233
+ catch (cleanupError) {
234
+ terminalError = errorWithCleanup(error, cleanupError, "session termination cleanup failed");
235
+ }
236
+ resolveTermination({ error: terminalError });
247
237
  };
248
238
  const mux = new YamuxSession(conn, {
249
239
  client: true,
@@ -261,23 +251,21 @@ export async function connectCore(args) {
261
251
  }),
262
252
  });
263
253
  closeAll = () => {
264
- try {
265
- mux.close();
266
- }
267
- catch { /* ignore */ }
268
- try {
269
- secure.close();
270
- }
271
- catch { /* ignore */ }
254
+ runSyncCleanups([() => mux.close(), () => secure.close()]);
272
255
  };
273
256
  let rpcStream;
274
257
  try {
275
258
  rpcStream = await mux.openStream(signal === undefined ? {} : { signal });
276
259
  }
277
260
  catch (e) {
278
- mux.close();
279
- secure.close();
280
- throw new FlowersecError({ path: args.path, stage: "yamux", code: "open_stream_failed", message: "open rpc stream failed", cause: e });
261
+ const cleanupError = captureSyncCleanup(() => runSyncCleanups([() => mux.close(), () => secure.close()]));
262
+ throw new FlowersecError({
263
+ path: args.path,
264
+ stage: "yamux",
265
+ code: "open_stream_failed",
266
+ message: "open rpc stream failed",
267
+ cause: causeWithCleanup(e, cleanupError, "RPC stream setup cleanup failed"),
268
+ });
281
269
  }
282
270
  const reader = new ByteReader(() => rpcStream.read());
283
271
  const readExactly = (n) => reader.readExactly(n);
@@ -286,20 +274,19 @@ export async function connectCore(args) {
286
274
  await writeStreamHello(write, "rpc");
287
275
  }
288
276
  catch (e) {
289
- try {
290
- await rpcStream.close();
291
- }
292
- catch {
293
- // ignore
294
- }
295
- mux.close();
296
- secure.close();
277
+ const cleanupError = await captureAsyncCleanup(async () => {
278
+ const streamCloseError = await captureAsyncCleanup(() => rpcStream.close());
279
+ const stackCloseError = captureSyncCleanup(() => runSyncCleanups([() => mux.close(), () => secure.close()]));
280
+ const combined = errorsFrom([streamCloseError, stackCloseError]);
281
+ if (combined != null)
282
+ throw combined;
283
+ });
297
284
  throw new FlowersecError({
298
285
  path: args.path,
299
286
  stage: "rpc",
300
287
  code: "stream_hello_failed",
301
288
  message: "rpc stream hello failed",
302
- cause: e,
289
+ cause: causeWithCleanup(e, cleanupError, "RPC bootstrap cleanup failed"),
303
290
  });
304
291
  }
305
292
  const rpc = new RpcClient(readExactly, write, { observer, onTerminal: reportTermination });
@@ -316,22 +303,12 @@ export async function connectCore(args) {
316
303
  return await mux.probeLiveness(liveness.timeoutMs);
317
304
  }
318
305
  catch (e) {
319
- if (String(e?.message).includes("ping timeout")) {
306
+ if (isYamuxPingTimeoutError(e)) {
320
307
  emitObserverDiagnostic(args.opts.observer, { path: args.path, stage: "yamux", code_domain: "event", code: "liveness_timeout", result: "fail" });
321
308
  }
322
- try {
323
- rpc.close();
324
- }
325
- catch { /* ignore */ }
326
- try {
327
- mux.close();
328
- }
329
- catch { /* ignore */ }
330
- try {
331
- secure.close();
332
- }
333
- catch { /* ignore */ }
334
- throw new FlowersecError({ path: args.path, stage: "yamux", code: "ping_failed", message: "liveness probe failed", cause: e });
309
+ const error = new FlowersecError({ path: args.path, stage: "yamux", code: "ping_failed", message: "liveness probe failed", cause: e });
310
+ reportTermination(error);
311
+ throw error;
335
312
  }
336
313
  };
337
314
  let livenessTimer;
@@ -344,24 +321,7 @@ export async function connectCore(args) {
344
321
  };
345
322
  closeAll = () => {
346
323
  stopLiveness();
347
- try {
348
- rpc.close();
349
- }
350
- catch {
351
- // ignore
352
- }
353
- try {
354
- mux.close();
355
- }
356
- catch {
357
- // ignore
358
- }
359
- try {
360
- secure.close();
361
- }
362
- catch {
363
- // ignore
364
- }
324
+ runSyncCleanups([() => rpc.close(), () => mux.close(), () => secure.close()]);
365
325
  };
366
326
  if (liveness.intervalMs > 0) {
367
327
  livenessTimer = setInterval(() => {
@@ -385,6 +345,14 @@ export async function connectCore(args) {
385
345
  mux,
386
346
  rpc,
387
347
  ping,
348
+ rekey: async () => {
349
+ try {
350
+ await secure.rekeyNow();
351
+ }
352
+ catch (error) {
353
+ throw new FlowersecError({ path: args.path, stage: "secure", code: "rekey_failed", message: "rekey failed", cause: error });
354
+ }
355
+ },
388
356
  probeLiveness,
389
357
  openStream: async (kind, opts = {}) => {
390
358
  if (kind == null || kind === "")
@@ -408,6 +376,7 @@ export async function connectCore(args) {
408
376
  };
409
377
  const signal = opts.signal;
410
378
  let abortListener;
379
+ let abortReset;
411
380
  let s;
412
381
  try {
413
382
  s = await mux.openStream(signal === undefined ? {} : { signal });
@@ -421,12 +390,7 @@ export async function connectCore(args) {
421
390
  }
422
391
  if (signal != null) {
423
392
  abortListener = () => {
424
- try {
425
- s.reset(abortReason(signal));
426
- }
427
- catch {
428
- // ignore
429
- }
393
+ abortReset ??= captureAsyncCleanup(() => Promise.resolve(s.reset(abortReason(signal))));
430
394
  };
431
395
  signal.addEventListener("abort", abortListener, { once: true });
432
396
  if (signal.aborted)
@@ -435,18 +399,19 @@ export async function connectCore(args) {
435
399
  if (signal?.aborted) {
436
400
  if (abortListener != null)
437
401
  signal.removeEventListener("abort", abortListener);
438
- try {
439
- await s.close();
440
- }
441
- catch {
442
- // ignore
443
- }
402
+ const cleanupError = await captureAsyncCleanup(async () => {
403
+ const resetError = abortReset == null ? undefined : await abortReset;
404
+ const closeError = await captureAsyncCleanup(() => s.close());
405
+ const combined = errorsFrom([resetError, closeError]);
406
+ if (combined != null)
407
+ throw combined;
408
+ });
444
409
  throw new FlowersecError({
445
410
  path: args.path,
446
411
  stage: "yamux",
447
412
  code: "canceled",
448
413
  message: "open stream aborted",
449
- cause: signal.reason,
414
+ cause: causeWithCleanup(signal.reason, cleanupError, "aborted stream cleanup failed"),
450
415
  });
451
416
  }
452
417
  try {
@@ -456,34 +421,30 @@ export async function connectCore(args) {
456
421
  if (signal?.aborted) {
457
422
  if (abortListener != null)
458
423
  signal.removeEventListener("abort", abortListener);
459
- try {
460
- await s.close();
461
- }
462
- catch {
463
- // ignore
464
- }
424
+ const cleanupError = await captureAsyncCleanup(async () => {
425
+ const resetError = abortReset == null ? undefined : await abortReset;
426
+ const closeError = await captureAsyncCleanup(() => s.close());
427
+ const combined = errorsFrom([resetError, closeError]);
428
+ if (combined != null)
429
+ throw combined;
430
+ });
465
431
  throw new FlowersecError({
466
432
  path: args.path,
467
433
  stage: "yamux",
468
434
  code: "canceled",
469
435
  message: "open stream aborted",
470
- cause: signal.reason,
436
+ cause: causeWithCleanup(signal.reason, cleanupError, "aborted stream cleanup failed"),
471
437
  });
472
438
  }
473
439
  if (signal != null && abortListener != null)
474
440
  signal.removeEventListener("abort", abortListener);
475
- try {
476
- await s.close();
477
- }
478
- catch {
479
- // ignore
480
- }
441
+ const cleanupError = await captureAsyncCleanup(() => s.close());
481
442
  throw new FlowersecError({
482
443
  path: args.path,
483
444
  stage: "rpc",
484
445
  code: "stream_hello_failed",
485
446
  message: "stream hello failed",
486
- cause: err,
447
+ cause: causeWithCleanup(err, cleanupError, "stream hello cleanup failed"),
487
448
  });
488
449
  }
489
450
  if (signal != null && abortListener != null) {
@@ -499,14 +460,65 @@ export async function connectCore(args) {
499
460
  return client;
500
461
  }
501
462
  catch (e) {
463
+ const cleanupError = captureSyncCleanup(() => transport.close());
464
+ throw cleanupError == null ? e : errorWithCleanup(e, cleanupError, "transport cleanup failed");
465
+ }
466
+ }
467
+ function runSyncCleanups(actions) {
468
+ const failures = [];
469
+ for (const action of actions) {
502
470
  try {
503
- transport.close();
471
+ action();
504
472
  }
505
- catch {
506
- // ignore
473
+ catch (error) {
474
+ failures.push(errorValue(error));
507
475
  }
508
- throw e;
509
476
  }
477
+ if (failures.length > 0)
478
+ throw new AggregateError(failures, "Flowersec cleanup failed");
479
+ }
480
+ function captureSyncCleanup(action) {
481
+ try {
482
+ action();
483
+ return undefined;
484
+ }
485
+ catch (error) {
486
+ return error;
487
+ }
488
+ }
489
+ async function captureAsyncCleanup(action) {
490
+ try {
491
+ await action();
492
+ return undefined;
493
+ }
494
+ catch (error) {
495
+ return error;
496
+ }
497
+ }
498
+ function causeWithCleanup(primary, cleanup, message) {
499
+ if (cleanup === undefined)
500
+ return primary;
501
+ return new AggregateError([errorValue(primary), errorValue(cleanup)], message);
502
+ }
503
+ function errorWithCleanup(primary, cleanup, message) {
504
+ const cause = causeWithCleanup(primary, cleanup, message);
505
+ if (primary instanceof FlowersecError) {
506
+ return new FlowersecError({
507
+ path: primary.path,
508
+ stage: primary.stage,
509
+ code: primary.code,
510
+ message,
511
+ cause,
512
+ });
513
+ }
514
+ return new AggregateError([errorValue(primary), errorValue(cleanup)], message);
515
+ }
516
+ function errorsFrom(values) {
517
+ const failures = values.filter((value) => value !== undefined).map(errorValue);
518
+ return failures.length === 0 ? undefined : new AggregateError(failures, "Flowersec cleanup failed");
519
+ }
520
+ function errorValue(error) {
521
+ return error instanceof Error ? error : new Error(String(error));
510
522
  }
511
523
  function validateLimitObject(input, name, invalid) {
512
524
  if (input === undefined)
package/dist/client.d.ts CHANGED
@@ -11,6 +11,8 @@ export type Client = Readonly<{
11
11
  signal?: AbortSignal;
12
12
  }>) => Promise<YamuxStream>;
13
13
  ping: () => Promise<void>;
14
+ /** Emits an authenticated rekey record and advances the E2EE send key. */
15
+ rekey: () => Promise<void>;
14
16
  /** Performs an acknowledged Yamux round trip and returns RTT in milliseconds. */
15
17
  probeLiveness: () => Promise<number>;
16
18
  close: () => void;
@@ -77,7 +77,9 @@ async function readControlplaneText(response, maxBytes) {
77
77
  try {
78
78
  await reader.cancel();
79
79
  }
80
- catch { }
80
+ catch {
81
+ // The size violation is authoritative; cancellation is secondary cleanup.
82
+ }
81
83
  throw new ControlplaneResponseTooLargeError(maxBytes);
82
84
  }
83
85
  text += decoder.decode(chunk.value, { stream: true });
@@ -70,6 +70,7 @@ export declare class Session {
70
70
  signal?: AbortSignal;
71
71
  }>): Promise<void>;
72
72
  probeLiveness(timeoutMs?: 10000): Promise<number>;
73
+ rekey(): Promise<void>;
73
74
  close(): void;
74
75
  private pushStream;
75
76
  private acceptRawStream;
@@ -85,6 +85,14 @@ export class Session {
85
85
  probeLiveness(timeoutMs = SDK_DEFAULTS.transport.handshakeTimeoutMs) {
86
86
  return this.mux.probeLiveness(timeoutMs);
87
87
  }
88
+ async rekey() {
89
+ try {
90
+ await this.secure.rekeyNow();
91
+ }
92
+ catch (error) {
93
+ throw new FlowersecError({ path: this.path, stage: "secure", code: "rekey_failed", message: "endpoint rekey failed", cause: error });
94
+ }
95
+ }
88
96
  close() {
89
97
  this.fail(new Error("endpoint session closed"));
90
98
  this.mux.close();
@@ -78,11 +78,10 @@ function bridgeWebSocket(runtime, msg, port) {
78
78
  terminal = true;
79
79
  acceptingWrites = false;
80
80
  const err = error instanceof Error ? error : new Error(String(error));
81
- try {
82
- stream?.reset(err);
83
- }
84
- catch {
85
- // Best-effort.
81
+ if (stream != null) {
82
+ void Promise.resolve(stream.reset(err)).catch(() => {
83
+ // The bridge error is already delivered through the terminal response.
84
+ });
86
85
  }
87
86
  try {
88
87
  ac.abort(err.message);
@@ -416,13 +416,13 @@ export function createProxyRuntime(opts) {
416
416
  }
417
417
  if (!respMeta.ok) {
418
418
  const msg = respMeta.error?.message ?? "upstream error";
419
- port.postMessage({ type: "flowersec-proxy:response_error", status: 502, message: msg });
420
- try {
421
- stream.reset(new Error(msg));
422
- }
423
- catch {
424
- // Best-effort.
425
- }
419
+ port.postMessage({
420
+ type: "flowersec-proxy:response_error",
421
+ status: 502,
422
+ ...(respMeta.error?.code === undefined ? {} : { code: respMeta.error.code }),
423
+ message: msg,
424
+ });
425
+ await stream.reset(new Error(msg));
426
426
  stream = null;
427
427
  return;
428
428
  }
@@ -455,14 +455,10 @@ export function createProxyRuntime(opts) {
455
455
  port.postMessage({
456
456
  type: "flowersec-proxy:response_error",
457
457
  status,
458
+ ...(code === undefined ? {} : { code }),
458
459
  message: msg,
459
460
  });
460
- try {
461
- stream?.reset(new Error(msg));
462
- }
463
- catch {
464
- // Best-effort.
465
- }
461
+ await stream?.reset(new Error(msg));
466
462
  }
467
463
  finally {
468
464
  wakeResponseCreditWaiter();
@@ -494,12 +490,7 @@ export function createProxyRuntime(opts) {
494
490
  const resp = (await readJsonFrame(reader, maxJsonFrameBytes));
495
491
  if (resp.v !== PROXY_PROTOCOL_VERSION || resp.ok !== true) {
496
492
  const msg = resp.error?.message ?? "upstream ws open failed";
497
- try {
498
- stream.reset(new Error(msg));
499
- }
500
- catch {
501
- // Best-effort.
502
- }
493
+ await stream.reset(new Error(msg));
503
494
  throw new Error(msg);
504
495
  }
505
496
  return { stream, protocol: resp.protocol ?? "" };
@@ -3,24 +3,30 @@ import { emitObserverDiagnostic, withObserverContext } from "../observability/ob
3
3
  import { SDK_DEFAULTS } from "../defaults.js";
4
4
  export { createArtifactResolver, createControlplaneArtifactSource } from "./artifactControlplane.js";
5
5
  function normalizeAutoReconnect(cfg) {
6
- if (!cfg?.enabled) {
7
- return {
8
- enabled: false,
9
- maxAttempts: 1,
10
- initialDelayMs: SDK_DEFAULTS.reconnect.initialDelayMs,
11
- maxDelayMs: SDK_DEFAULTS.reconnect.maxDelayMs,
12
- factor: SDK_DEFAULTS.reconnect.factor,
13
- jitterRatio: SDK_DEFAULTS.reconnect.jitterRatio,
14
- };
15
- }
16
- return {
17
- enabled: true,
18
- maxAttempts: Math.max(1, cfg.maxAttempts ?? SDK_DEFAULTS.reconnect.maxAttempts),
19
- initialDelayMs: Math.max(0, cfg.initialDelayMs ?? SDK_DEFAULTS.reconnect.initialDelayMs),
20
- maxDelayMs: Math.max(0, cfg.maxDelayMs ?? SDK_DEFAULTS.reconnect.maxDelayMs),
21
- factor: Math.max(1, cfg.factor ?? SDK_DEFAULTS.reconnect.factor),
22
- jitterRatio: Math.max(0, cfg.jitterRatio ?? SDK_DEFAULTS.reconnect.jitterRatio),
6
+ const settings = {
7
+ enabled: cfg?.enabled === true,
8
+ maxAttempts: cfg?.enabled === true ? (cfg.maxAttempts ?? SDK_DEFAULTS.reconnect.maxAttempts) : 1,
9
+ initialDelayMs: cfg?.initialDelayMs ?? SDK_DEFAULTS.reconnect.initialDelayMs,
10
+ maxDelayMs: cfg?.maxDelayMs ?? SDK_DEFAULTS.reconnect.maxDelayMs,
11
+ factor: cfg?.factor ?? SDK_DEFAULTS.reconnect.factor,
12
+ jitterRatio: cfg?.jitterRatio ?? SDK_DEFAULTS.reconnect.jitterRatio,
23
13
  };
14
+ if (!Number.isSafeInteger(settings.maxAttempts) || settings.maxAttempts < 1) {
15
+ throw new TypeError("autoReconnect.maxAttempts must be a positive integer");
16
+ }
17
+ if (!Number.isFinite(settings.initialDelayMs) || settings.initialDelayMs < 0) {
18
+ throw new TypeError("autoReconnect.initialDelayMs must be a non-negative finite number");
19
+ }
20
+ if (!Number.isFinite(settings.maxDelayMs) || settings.maxDelayMs < 0) {
21
+ throw new TypeError("autoReconnect.maxDelayMs must be a non-negative finite number");
22
+ }
23
+ if (!Number.isFinite(settings.factor) || settings.factor < 1) {
24
+ throw new TypeError("autoReconnect.factor must be a finite number greater than or equal to one");
25
+ }
26
+ if (!Number.isFinite(settings.jitterRatio) || settings.jitterRatio < 0 || settings.jitterRatio > 1) {
27
+ throw new TypeError("autoReconnect.jitterRatio must be a finite number between zero and one");
28
+ }
29
+ return settings;
24
30
  }
25
31
  function backoffDelayMs(attemptIndex, cfg) {
26
32
  const base = Math.min(cfg.maxDelayMs, cfg.initialDelayMs * Math.pow(cfg.factor, attemptIndex));
@@ -73,13 +79,19 @@ export function createReconnectManager() {
73
79
  retryResolve = null;
74
80
  };
75
81
  const abortActiveAttempt = () => {
82
+ attemptAbort?.abort("canceled");
83
+ attemptAbort = null;
84
+ };
85
+ const closeClient = (value) => {
86
+ if (value == null)
87
+ return null;
76
88
  try {
77
- attemptAbort?.abort("canceled");
89
+ value.close();
90
+ return null;
78
91
  }
79
- catch {
80
- // ignore
92
+ catch (error) {
93
+ return asError(error);
81
94
  }
82
- attemptAbort = null;
83
95
  };
84
96
  const sleep = (ms) => new Promise((resolve) => {
85
97
  retryResolve = resolve;
@@ -96,13 +108,10 @@ export function createReconnectManager() {
96
108
  activeConnectPromise = null;
97
109
  token += 1;
98
110
  attemptSeq = 0;
99
- if (s.client) {
100
- try {
101
- s.client.close();
102
- }
103
- catch {
104
- // ignore
105
- }
111
+ const closeError = closeClient(s.client);
112
+ if (closeError != null) {
113
+ setState({ status: "error", error: closeError, client: null });
114
+ throw closeError;
106
115
  }
107
116
  setState({ status: "disconnected", error: null, client: null });
108
117
  };
@@ -115,27 +124,24 @@ export function createReconnectManager() {
115
124
  return;
116
125
  const ar = normalizeAutoReconnect(cfg.autoReconnect);
117
126
  if (!ar.enabled) {
118
- if (s.client) {
119
- try {
120
- s.client.close();
121
- }
122
- catch {
123
- // ignore
124
- }
125
- }
126
- setState({ status: "error", error, client: null });
127
+ const closeError = closeClient(s.client);
128
+ const finalError = closeError == null
129
+ ? error
130
+ : new AggregateError([error, closeError], "reconnect termination and client close failed");
131
+ setState({ status: "error", error: finalError, client: null });
127
132
  return;
128
133
  }
129
134
  // Restart the connection loop in the background.
130
135
  cancelRetrySleep();
131
136
  abortActiveAttempt();
132
- if (s.client) {
133
- try {
134
- s.client.close();
135
- }
136
- catch {
137
- // ignore
138
- }
137
+ const closeError = closeClient(s.client);
138
+ if (closeError != null) {
139
+ setState({
140
+ status: "error",
141
+ error: new AggregateError([error, closeError], "reconnect termination and client close failed"),
142
+ client: null,
143
+ });
144
+ return;
139
145
  }
140
146
  token += 1;
141
147
  const nextToken = token;
@@ -198,21 +204,15 @@ export function createReconnectManager() {
198
204
  try {
199
205
  const client = await connectOnce(t, cfg, attemptSeq);
200
206
  if (t !== token) {
201
- try {
202
- client.close();
203
- }
204
- catch {
205
- // ignore
206
- }
207
+ const closeError = closeClient(client);
208
+ if (closeError != null)
209
+ throw new ReconnectCleanupError(closeError);
207
210
  return;
208
211
  }
209
212
  if (active !== cfg) {
210
- try {
211
- client.close();
212
- }
213
- catch {
214
- // ignore
215
- }
213
+ const closeError = closeClient(client);
214
+ if (closeError != null)
215
+ throw new ReconnectCleanupError(closeError);
216
216
  return;
217
217
  }
218
218
  setState({ status: "connected", client, error: null });
@@ -222,6 +222,10 @@ export function createReconnectManager() {
222
222
  if (s.client !== client)
223
223
  return;
224
224
  startReconnect(t, cfg, error);
225
+ }, (error) => {
226
+ if (s.client !== client)
227
+ return;
228
+ startReconnect(t, cfg, asError(error));
225
229
  });
226
230
  }
227
231
  emitObserverDiagnostic(withObserverContext(cfg.observer, { attemptSeq }), {
@@ -234,6 +238,8 @@ export function createReconnectManager() {
234
238
  return;
235
239
  }
236
240
  catch (err) {
241
+ if (err instanceof ReconnectCleanupError)
242
+ throw err.cleanupError;
237
243
  const e = err instanceof Error ? err : new Error(String(err));
238
244
  if (t !== token)
239
245
  return;
@@ -281,19 +287,18 @@ export function createReconnectManager() {
281
287
  return promise;
282
288
  };
283
289
  const connect = async (cfg) => {
290
+ normalizeAutoReconnect(cfg.autoReconnect);
284
291
  cancelRetrySleep();
285
292
  abortActiveAttempt();
286
293
  token += 1;
287
294
  const t = token;
288
295
  active = cfg;
289
296
  attemptSeq = 0;
290
- if (s.client) {
291
- try {
292
- s.client.close();
293
- }
294
- catch {
295
- // ignore
296
- }
297
+ const closeError = closeClient(s.client);
298
+ if (closeError != null) {
299
+ active = null;
300
+ setState({ status: "error", error: closeError, client: null });
301
+ throw closeError;
297
302
  }
298
303
  const connectPromise = startConnectLoop(t, cfg);
299
304
  setState({ status: "connecting", error: null, client: null });
@@ -333,3 +338,14 @@ export function createReconnectManager() {
333
338
  disconnect,
334
339
  };
335
340
  }
341
+ class ReconnectCleanupError extends Error {
342
+ cleanupError;
343
+ constructor(cleanupError) {
344
+ super("reconnect cleanup failed", { cause: cleanupError });
345
+ this.cleanupError = cleanupError;
346
+ this.name = "ReconnectCleanupError";
347
+ }
348
+ }
349
+ function asError(value) {
350
+ return value instanceof Error ? value : new Error(String(value));
351
+ }
@@ -32,6 +32,7 @@ export declare class RpcServer {
32
32
  private readonly terminalSignal;
33
33
  private signalTerminal;
34
34
  private transportClosed;
35
+ private admittedRequests;
35
36
  constructor(transport: RpcServerTransport, options?: RpcServerOptions, router?: RpcRouter);
36
37
  register(typeId: number, h: RpcHandler): void;
37
38
  notify(typeId: number, payload: unknown): Promise<void>;
@@ -31,6 +31,7 @@ export class RpcServer {
31
31
  terminalSignal;
32
32
  signalTerminal;
33
33
  transportClosed = false;
34
+ admittedRequests = 0;
34
35
  constructor(transport, options = {}, router = new RpcRouter()) {
35
36
  this.transport = transport;
36
37
  this.router = router;
@@ -57,11 +58,15 @@ export class RpcServer {
57
58
  }
58
59
  // serve handles request/response frames until closed or aborted.
59
60
  async serve(signal) {
60
- const supervise = (worker) => worker.catch((err) => {
61
- this.fail(err);
62
- });
63
- const workers = Array.from({ length: this.options.maxConcurrentRequests }, () => supervise(this.requestWorker()));
64
- workers.push(supervise(this.notificationWorker()));
61
+ const workers = Array.from({ length: this.options.maxConcurrentRequests }, () => this.requestWorker());
62
+ workers.push(this.notificationWorker());
63
+ const workerFailure = Promise.race(workers.map(async (worker) => {
64
+ await worker;
65
+ if (!this.closed)
66
+ throw new Error("rpc worker ended before server shutdown");
67
+ return await new Promise(() => undefined);
68
+ }));
69
+ let failure;
65
70
  try {
66
71
  while (!this.closed) {
67
72
  if (signal?.aborted)
@@ -69,6 +74,7 @@ export class RpcServer {
69
74
  const next = await Promise.race([
70
75
  readJsonFrame(this.transport.readExactly, DEFAULT_MAX_JSON_FRAME_BYTES),
71
76
  this.terminalSignal.then((error) => { throw error; }),
77
+ workerFailure,
72
78
  ]);
73
79
  const v = assertRpcEnvelope(next);
74
80
  if (v.response_to !== 0)
@@ -81,23 +87,38 @@ export class RpcServer {
81
87
  this.wakeOne(this.notificationWaiters);
82
88
  continue;
83
89
  }
84
- if (this.requests.length >= this.options.maxQueuedRequests) {
90
+ if (this.admittedRequests >= this.options.maxConcurrentRequests + this.options.maxQueuedRequests) {
85
91
  await this.writeResponse(v, { payload: null, error: { code: 429, message: "server overloaded" } });
86
92
  continue;
87
93
  }
94
+ this.admittedRequests += 1;
88
95
  this.requests.push({ envelope: v });
89
96
  this.wakeOne(this.requestWaiters);
90
97
  }
91
98
  }
92
99
  catch (err) {
100
+ failure = err;
93
101
  this.terminalError = err;
94
- this.close(err);
95
- throw err;
96
102
  }
97
- finally {
98
- this.close(this.terminalError ?? new Error("rpc server closed"));
99
- void Promise.allSettled(workers);
103
+ let closeError;
104
+ try {
105
+ this.close(failure ?? this.terminalError ?? new Error("rpc server closed"));
106
+ }
107
+ catch (error) {
108
+ closeError = error;
100
109
  }
110
+ const settled = await Promise.allSettled(workers);
111
+ const workerErrors = settled
112
+ .filter((result) => result.status === "rejected")
113
+ .map((result) => result.reason)
114
+ .filter((error) => error !== failure);
115
+ const errors = [failure, closeError, ...workerErrors].filter((error) => error !== undefined);
116
+ if (errors.length === 1)
117
+ throw errors[0];
118
+ if (errors.length > 1)
119
+ throw new AggregateError(errors, "rpc server and cleanup failed");
120
+ if (this.terminalError !== undefined)
121
+ throw this.terminalError;
101
122
  }
102
123
  // close stops the serve loop and closes the underlying RPC stream.
103
124
  close(error = new Error("rpc server closed")) {
@@ -138,9 +159,14 @@ export class RpcServer {
138
159
  out = { payload: null, error: { code: 500, message: "internal error" } };
139
160
  }
140
161
  }
141
- if (this.closed)
142
- return;
143
- await this.writeResponse(v, out);
162
+ try {
163
+ if (this.closed)
164
+ return;
165
+ await this.writeResponse(v, out);
166
+ }
167
+ finally {
168
+ this.admittedRequests = Math.max(0, this.admittedRequests - 1);
169
+ }
144
170
  }
145
171
  }
146
172
  async notificationWorker() {
@@ -152,10 +178,7 @@ export class RpcServer {
152
178
  const h = this.router.handler(v.type_id);
153
179
  if (h == null)
154
180
  continue;
155
- try {
156
- await h(v.payload);
157
- }
158
- catch { /* Notification failures are isolated. */ }
181
+ await h(v.payload);
159
182
  }
160
183
  }
161
184
  async nextWork(queue, waiters) {
@@ -182,8 +205,14 @@ export class RpcServer {
182
205
  }
183
206
  async writeEnvelope(envelope) {
184
207
  const write = this.writeChain.then(() => writeJsonFrame(this.transport.write, envelope));
185
- this.writeChain = write.catch(() => { });
186
- await write;
208
+ this.writeChain = write;
209
+ try {
210
+ await write;
211
+ }
212
+ catch (error) {
213
+ this.fail(error);
214
+ throw error;
215
+ }
187
216
  }
188
217
  }
189
218
  function positiveInteger(value, name) {
@@ -11,12 +11,9 @@ function abortReasonToError(signal) {
11
11
  }
12
12
  function bindAbortToStream(stream, signal) {
13
13
  const onAbort = () => {
14
- try {
15
- stream.reset(abortReasonToError(signal));
16
- }
17
- catch {
18
- // Best-effort cancel.
19
- }
14
+ void Promise.resolve(stream.reset(abortReasonToError(signal))).catch(() => {
15
+ // The read/write operation reports the authoritative abort to its caller.
16
+ });
20
17
  };
21
18
  if (signal.aborted) {
22
19
  onAbort();
@@ -6,7 +6,7 @@ export declare class AbortError extends Error {
6
6
  }
7
7
  export type FlowersecPath = "auto" | "tunnel" | "direct";
8
8
  export type FlowersecStage = "validate" | "connect" | "attach" | "handshake" | "secure" | "yamux" | "rpc" | "close";
9
- export type FlowersecErrorCode = "timeout" | "canceled" | "invalid_version" | "invalid_input" | "invalid_option" | "invalid_endpoint_instance_id" | "invalid_psk" | "invalid_suite" | "missing_grant" | "missing_connect_info" | "missing_conn" | "missing_handler" | "missing_stream_kind" | "role_mismatch" | "missing_tunnel_url" | "missing_ws_url" | "missing_origin" | "missing_channel_id" | "missing_token" | "missing_init_exp" | "timestamp_after_init_exp" | "timestamp_out_of_skew" | "auth_tag_mismatch" | "resolve_failed" | "transport_policy_denied" | "credential_commit_failed" | "random_failed" | "upgrade_failed" | "dial_failed" | "attach_failed" | "too_many_connections" | "expected_attach" | "invalid_attach" | "invalid_token" | "channel_mismatch" | "init_exp_mismatch" | "idle_timeout_mismatch" | "token_replay" | "tenant_mismatch" | "policy_denied" | "policy_error" | "replace_rate_limited" | "handshake_failed" | "ping_failed" | "mux_failed" | "accept_stream_failed" | "open_stream_failed" | "stream_hello_failed" | "rpc_failed" | "resource_exhausted" | "not_connected";
9
+ export type FlowersecErrorCode = "timeout" | "canceled" | "invalid_version" | "invalid_input" | "invalid_option" | "invalid_endpoint_instance_id" | "invalid_psk" | "invalid_suite" | "missing_grant" | "missing_connect_info" | "missing_conn" | "missing_handler" | "missing_stream_kind" | "role_mismatch" | "missing_tunnel_url" | "missing_ws_url" | "missing_origin" | "missing_channel_id" | "missing_token" | "missing_init_exp" | "timestamp_after_init_exp" | "timestamp_out_of_skew" | "auth_tag_mismatch" | "resolve_failed" | "transport_policy_denied" | "credential_commit_failed" | "random_failed" | "upgrade_failed" | "dial_failed" | "attach_failed" | "too_many_connections" | "expected_attach" | "invalid_attach" | "invalid_token" | "channel_mismatch" | "init_exp_mismatch" | "idle_timeout_mismatch" | "token_replay" | "tenant_mismatch" | "policy_denied" | "policy_error" | "replace_rate_limited" | "handshake_failed" | "ping_failed" | "rekey_failed" | "mux_failed" | "accept_stream_failed" | "open_stream_failed" | "stream_hello_failed" | "rpc_failed" | "resource_exhausted" | "not_connected";
10
10
  export declare class FlowersecError extends Error {
11
11
  readonly code: FlowersecErrorCode;
12
12
  readonly stage: FlowersecStage;
@@ -2,6 +2,10 @@ export declare class StreamEOFError extends Error {
2
2
  constructor(message?: string);
3
3
  }
4
4
  export declare function isStreamEOFError(e: unknown): e is StreamEOFError;
5
+ export declare class YamuxStreamResetError extends Error {
6
+ constructor();
7
+ }
8
+ export declare function isYamuxStreamResetError(error: unknown): error is YamuxStreamResetError;
5
9
  export declare class YamuxResourceExhaustedError extends Error {
6
10
  readonly resource: string;
7
11
  readonly current: number;
@@ -9,3 +13,7 @@ export declare class YamuxResourceExhaustedError extends Error {
9
13
  constructor(resource: string, current: number, limit: number);
10
14
  }
11
15
  export declare function isYamuxResourceExhaustedError(error: unknown): error is YamuxResourceExhaustedError;
16
+ export declare class YamuxPingTimeoutError extends Error {
17
+ constructor();
18
+ }
19
+ export declare function isYamuxPingTimeoutError(error: unknown): error is YamuxPingTimeoutError;
@@ -8,6 +8,15 @@ export class StreamEOFError extends Error {
8
8
  export function isStreamEOFError(e) {
9
9
  return e instanceof StreamEOFError;
10
10
  }
11
+ export class YamuxStreamResetError extends Error {
12
+ constructor() {
13
+ super("yamux stream reset");
14
+ this.name = "YamuxStreamResetError";
15
+ }
16
+ }
17
+ export function isYamuxStreamResetError(error) {
18
+ return error instanceof YamuxStreamResetError;
19
+ }
11
20
  export class YamuxResourceExhaustedError extends Error {
12
21
  resource;
13
22
  current;
@@ -23,3 +32,12 @@ export class YamuxResourceExhaustedError extends Error {
23
32
  export function isYamuxResourceExhaustedError(error) {
24
33
  return error instanceof YamuxResourceExhaustedError;
25
34
  }
35
+ export class YamuxPingTimeoutError extends Error {
36
+ constructor() {
37
+ super("yamux ping timeout");
38
+ this.name = "YamuxPingTimeoutError";
39
+ }
40
+ }
41
+ export function isYamuxPingTimeoutError(error) {
42
+ return error instanceof YamuxPingTimeoutError;
43
+ }
@@ -2,7 +2,7 @@ import { ByteReader } from "./byteReader.js";
2
2
  import { decodeHeader, encodeHeader, HEADER_LEN } from "./header.js";
3
3
  import { FLAG_ACK, FLAG_RST, FLAG_SYN, TYPE_DATA, TYPE_GO_AWAY, TYPE_PING, TYPE_WINDOW_UPDATE, YAMUX_VERSION } from "./constants.js";
4
4
  import { YamuxStream } from "./stream.js";
5
- import { YamuxResourceExhaustedError } from "./errors.js";
5
+ import { YamuxPingTimeoutError, YamuxResourceExhaustedError } from "./errors.js";
6
6
  import { SDK_DEFAULTS } from "../defaults.js";
7
7
  export const DEFAULT_YAMUX_LIMITS = Object.freeze({
8
8
  maxActiveStreams: SDK_DEFAULTS.yamux.maxActiveStreams,
@@ -129,7 +129,7 @@ export class YamuxSession {
129
129
  return await new Promise((resolve, reject) => {
130
130
  const timer = setTimeout(() => {
131
131
  this.pingWaiters.delete(opaque);
132
- const error = new Error("yamux ping timeout");
132
+ const error = new YamuxPingTimeoutError();
133
133
  reject(error);
134
134
  this.fail(error);
135
135
  }, timeoutMs);
@@ -229,8 +229,11 @@ export class YamuxSession {
229
229
  this.pingWaiters.clear();
230
230
  const streams = Array.from(this.streams.values());
231
231
  this.streams.clear();
232
- for (const s of streams)
233
- s.reset(new Error("session closed"));
232
+ for (const s of streams) {
233
+ void Promise.resolve(s.reset(new Error("session closed"))).catch(() => {
234
+ // Session shutdown has already made the stream terminal.
235
+ });
236
+ }
234
237
  }
235
238
  wakeSendWindowWaiters() {
236
239
  for (const [streamId, ws] of this.sendWindowWaiters) {
@@ -23,7 +23,7 @@ export declare class YamuxStream {
23
23
  write(data: Uint8Array): Promise<void>;
24
24
  private writeSerial;
25
25
  close(): Promise<void>;
26
- reset(err: Error): Promise<void>;
26
+ reset(err?: Error): Promise<void>;
27
27
  private fail;
28
28
  bufferedReceiveBytes(): number;
29
29
  releaseBufferedReceiveBytes(): number;
@@ -1,7 +1,7 @@
1
1
  import { concatBytes } from "../utils/bin.js";
2
2
  import { encodeHeader } from "./header.js";
3
3
  import { DEFAULT_MAX_STREAM_WINDOW, FLAG_ACK, FLAG_FIN, FLAG_RST, FLAG_SYN, TYPE_DATA, TYPE_WINDOW_UPDATE } from "./constants.js";
4
- import { YamuxResourceExhaustedError } from "./errors.js";
4
+ import { YamuxResourceExhaustedError, YamuxStreamResetError } from "./errors.js";
5
5
  // YamuxStream manages per-stream flow control and state transitions.
6
6
  export class YamuxStream {
7
7
  // Stream identifier within the session.
@@ -152,7 +152,7 @@ export class YamuxStream {
152
152
  }
153
153
  }
154
154
  // reset tears down the stream and notifies the peer.
155
- reset(err) {
155
+ reset(err = new Error("stream reset")) {
156
156
  if (this.resetTask != null)
157
157
  return this.resetTask;
158
158
  if (!this.fail(err))
@@ -206,7 +206,7 @@ export class YamuxStream {
206
206
  w();
207
207
  }
208
208
  if ((flags & FLAG_RST) !== 0) {
209
- if (this.fail(new Error("rst")))
209
+ if (this.fail(new YamuxStreamResetError()))
210
210
  this.session.onStreamClosed(this.id);
211
211
  }
212
212
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "description": "Flowersec core TypeScript library (browser-friendly E2EE + multiplexing over WebSocket).",
5
5
  "license": "MIT",
6
6
  "repository": {