Skip to content

Commit ddf4047

Browse files
committed
fix(js client): interrupt reconnect backoff sleep on stop()
The JS reconnect loops (ZhubPublication/Connection/Exposure) slept via non-cancellable `setTimeout(resolve, backoff * 1000)` between failed sessions. A user calling stop() during the sleep saw runForever() resolve promptly, but the setTimeout kept a Node event-loop reference for up to 60s (max backoff), blocking a clean process exit and continuing to hold the reconnect task open. Python's matching path uses `asyncio.wait_for(_stop_event.wait(), timeout=backoff)`, which is interruptible — stop_event.set() cancels the wait immediately. Fix mirrors Python contract in all three classes: sleep() stores a canceller on the instance; stop() flips stopped, closes the ws, invokes the sleep canceller (clearing the timeout), and awaits the reconnect task so callers can observe wind-down.
1 parent 0ea9f55 commit ddf4047

1 file changed

Lines changed: 76 additions & 6 deletions

File tree

js/src/client.ts

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ export class ZhubPublication {
137137
private connections = new Map<string, { client_manifest: Record<string, unknown> | null }>();
138138
private stopped = false;
139139
private stopResolvers: Array<() => void> = [];
140+
private stopSleepResolver: (() => void) | null = null;
141+
private task: Promise<void> | null = null;
140142

141143
constructor(opts: PublishOptions, manifest: Manifest) {
142144
this.name = opts.name;
@@ -188,8 +190,14 @@ export class ZhubPublication {
188190
async stop(): Promise<void> {
189191
this.stopped = true;
190192
this.ws?.close();
193+
this.stopSleepResolver?.();
191194
const resolvers = this.stopResolvers.splice(0);
192195
for (const r of resolvers) r();
196+
const task = this.task;
197+
this.task = null;
198+
if (task) {
199+
try { await task; } catch { /* drained */ }
200+
}
193201
}
194202

195203
/** Block until stop() is called — mirror of Python's ZhubPublication.run_forever(). */
@@ -202,7 +210,25 @@ export class ZhubPublication {
202210

203211
/** Internal — start the connect loop. Called by the public publish() helper. */
204212
start(): void {
205-
void this.runReconnectLoop();
213+
this.task = this.runReconnectLoop();
214+
}
215+
216+
private sleep(ms: number): Promise<void> {
217+
// Mirror Python's `asyncio.wait_for(stop_event.wait(), timeout=backoff)`:
218+
// stop() must interrupt the in-flight backoff sleep, otherwise a JS
219+
// caller shutting down during a reconnect wait would keep an unbounded
220+
// Node timer alive and block a clean process exit for up to 60s.
221+
return new Promise<void>((resolve) => {
222+
const t = setTimeout(() => {
223+
this.stopSleepResolver = null;
224+
resolve();
225+
}, ms);
226+
this.stopSleepResolver = () => {
227+
clearTimeout(t);
228+
this.stopSleepResolver = null;
229+
resolve();
230+
};
231+
});
206232
}
207233

208234
private async runReconnectLoop(): Promise<void> {
@@ -219,7 +245,7 @@ export class ZhubPublication {
219245
// any other error → reconnect after backoff
220246
}
221247
if (this.stopped) return;
222-
await new Promise((r) => setTimeout(r, backoff * 1000));
248+
await this.sleep(backoff * 1000);
223249
backoff = Math.min(backoff * 2, 60);
224250
}
225251
}
@@ -505,6 +531,8 @@ export class ZhubConnection {
505531
private streams = new Map<string, (chunk: Record<string, unknown>) => void>();
506532
private stopped = false;
507533
private stopResolvers: Array<() => void> = [];
534+
private stopSleepResolver: (() => void) | null = null;
535+
private task: Promise<void> | null = null;
508536

509537
constructor(opts: ConnectOptions) {
510538
this.aiName = opts.aiName;
@@ -619,8 +647,14 @@ export class ZhubConnection {
619647
async stop(): Promise<void> {
620648
this.stopped = true;
621649
this.ws?.close();
650+
this.stopSleepResolver?.();
622651
const resolvers = this.stopResolvers.splice(0);
623652
for (const r of resolvers) r();
653+
const task = this.task;
654+
this.task = null;
655+
if (task) {
656+
try { await task; } catch { /* drained */ }
657+
}
624658
}
625659

626660
/** Block until stop() is called — mirror of Python's ZhubConnection.run_forever(). */
@@ -633,7 +667,21 @@ export class ZhubConnection {
633667

634668
/** Internal — call from connect(). */
635669
start(): void {
636-
void this.runReconnectLoop();
670+
this.task = this.runReconnectLoop();
671+
}
672+
673+
private sleep(ms: number): Promise<void> {
674+
return new Promise<void>((resolve) => {
675+
const t = setTimeout(() => {
676+
this.stopSleepResolver = null;
677+
resolve();
678+
}, ms);
679+
this.stopSleepResolver = () => {
680+
clearTimeout(t);
681+
this.stopSleepResolver = null;
682+
resolve();
683+
};
684+
});
637685
}
638686

639687
private async runReconnectLoop(): Promise<void> {
@@ -646,7 +694,7 @@ export class ZhubConnection {
646694
if (err instanceof AuthError) return;
647695
}
648696
if (this.stopped) return;
649-
await new Promise((r) => setTimeout(r, backoff * 1000));
697+
await this.sleep(backoff * 1000);
650698
backoff = Math.min(backoff * 2, 60);
651699
}
652700
}
@@ -791,6 +839,8 @@ export class ZhubExposure {
791839
private ws: WebSocket | null = null;
792840
private stopped = false;
793841
private stopResolvers: Array<() => void> = [];
842+
private stopSleepResolver: (() => void) | null = null;
843+
private task: Promise<void> | null = null;
794844

795845
constructor(opts: ExposeOptions, manifest: Manifest) {
796846
this.name = opts.name;
@@ -806,8 +856,14 @@ export class ZhubExposure {
806856
async stop(): Promise<void> {
807857
this.stopped = true;
808858
this.ws?.close();
859+
this.stopSleepResolver?.();
809860
const resolvers = this.stopResolvers.splice(0);
810861
for (const r of resolvers) r();
862+
const task = this.task;
863+
this.task = null;
864+
if (task) {
865+
try { await task; } catch { /* drained */ }
866+
}
811867
}
812868

813869
/** Block until stop() is called — mirror of Python's ZhubExposure.run_forever(). */
@@ -820,7 +876,21 @@ export class ZhubExposure {
820876

821877
/** Internal — call from expose(). */
822878
start(): void {
823-
void this.runReconnectLoop();
879+
this.task = this.runReconnectLoop();
880+
}
881+
882+
private sleep(ms: number): Promise<void> {
883+
return new Promise<void>((resolve) => {
884+
const t = setTimeout(() => {
885+
this.stopSleepResolver = null;
886+
resolve();
887+
}, ms);
888+
this.stopSleepResolver = () => {
889+
clearTimeout(t);
890+
this.stopSleepResolver = null;
891+
resolve();
892+
};
893+
});
824894
}
825895

826896
private async runReconnectLoop(): Promise<void> {
@@ -833,7 +903,7 @@ export class ZhubExposure {
833903
if (err instanceof AuthError) return;
834904
}
835905
if (this.stopped) return;
836-
await new Promise((r) => setTimeout(r, backoff * 1000));
906+
await this.sleep(backoff * 1000);
837907
backoff = Math.min(backoff * 2, 60);
838908
}
839909
}

0 commit comments

Comments
 (0)