Skip to content

Commit 980cd6e

Browse files
Fix live listener attach lifecycle
Prevent overlapping attach calls from leaking listeners and clear stale timers when snapshots switch active sessions.
1 parent 26a64b8 commit 980cd6e

4 files changed

Lines changed: 161 additions & 10 deletions

File tree

apps/desktop/src/routes/app/-note.$sessionId.test.tsx

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import { resetTabsStore } from "~/store/zustand/tabs/test-utils";
66
const mocks = vi.hoisted(() => ({
77
attachLiveSession: vi.fn(),
88
close: vi.fn(),
9+
listenerState: {
10+
attachLiveSession: vi.fn(),
11+
live: {
12+
eventUnlistenersBySession: {} as Record<string, (() => void)[]>,
13+
},
14+
},
915
}));
1016

1117
vi.mock("@tauri-apps/api/window", () => ({
@@ -15,11 +21,8 @@ vi.mock("@tauri-apps/api/window", () => ({
1521
}));
1622

1723
vi.mock("~/stt/contexts", () => ({
18-
useListener: (
19-
selector: (state: {
20-
attachLiveSession: typeof mocks.attachLiveSession;
21-
}) => unknown,
22-
) => selector({ attachLiveSession: mocks.attachLiveSession }),
24+
useListener: (selector: (state: typeof mocks.listenerState) => unknown) =>
25+
selector(mocks.listenerState),
2326
}));
2427

2528
import {
@@ -32,6 +35,8 @@ import { useTabs } from "~/store/zustand/tabs";
3235

3336
describe("standalone note window route", () => {
3437
beforeEach(() => {
38+
mocks.listenerState.attachLiveSession = mocks.attachLiveSession;
39+
mocks.listenerState.live.eventUnlistenersBySession = {};
3540
mocks.attachLiveSession.mockClear();
3641
mocks.close.mockClear();
3742
resetTabsStore();
@@ -88,6 +93,23 @@ describe("standalone note window route", () => {
8893

8994
expect(mocks.attachLiveSession).toHaveBeenCalledWith("session-1");
9095
});
96+
97+
it("reattaches after standalone live session events are removed", () => {
98+
const { rerender } = renderHook(() =>
99+
useAttachStandaloneNoteToLiveSession("session-1"),
100+
);
101+
expect(mocks.attachLiveSession).toHaveBeenCalledTimes(1);
102+
103+
mocks.listenerState.live.eventUnlistenersBySession = {
104+
"session-1": [vi.fn()],
105+
};
106+
rerender();
107+
expect(mocks.attachLiveSession).toHaveBeenCalledTimes(1);
108+
109+
mocks.listenerState.live.eventUnlistenersBySession = {};
110+
rerender();
111+
expect(mocks.attachLiveSession).toHaveBeenCalledTimes(2);
112+
});
91113
});
92114

93115
function dispatchKeyDown(key: string) {

apps/desktop/src/routes/app/note.$sessionId.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,17 @@ function Component() {
3131

3232
export function useAttachStandaloneNoteToLiveSession(sessionId: string) {
3333
const attachLiveSession = useListener((state) => state.attachLiveSession);
34+
const hasLiveSessionEvents = useListener((state) =>
35+
Boolean(state.live.eventUnlistenersBySession[sessionId]),
36+
);
3437

3538
useEffect(() => {
39+
if (hasLiveSessionEvents) {
40+
return;
41+
}
42+
3643
void attachLiveSession(sessionId);
37-
}, [attachLiveSession, sessionId]);
44+
}, [attachLiveSession, hasLiveSessionEvents, sessionId]);
3845
}
3946

4047
export function useStandaloneNoteTab(sessionId: string) {

apps/desktop/src/store/zustand/listener/general-live.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -357,10 +357,25 @@ export const attachLiveSession = <T extends LiveStore>(
357357
return Promise.resolve();
358358
}
359359

360+
const pendingUnlisteners: (() => void)[] = [];
361+
let registeredUnlisteners = pendingUnlisteners;
362+
setLiveState(set, (live) => {
363+
live.eventUnlistenersBySession[targetSessionId] = pendingUnlisteners;
364+
});
365+
360366
const handlers = createSessionEventHandlers(set, get, targetSessionId);
361367

362368
const program = Effect.gen(function* () {
363369
const unlisteners = yield* listenToAllSessionEvents(handlers);
370+
if (
371+
get().live.eventUnlistenersBySession[targetSessionId] !==
372+
pendingUnlisteners
373+
) {
374+
clearLiveEventUnlisteners(unlisteners);
375+
return;
376+
}
377+
378+
registeredUnlisteners = unlisteners;
364379
setLiveState(set, (live) => {
365380
live.eventUnlistenersBySession[targetSessionId] = unlisteners;
366381
});
@@ -373,11 +388,14 @@ export const attachLiveSession = <T extends LiveStore>(
373388
Exit.match(exit, {
374389
onFailure: (cause) => {
375390
console.error("[listener] failed to attach live session:", cause);
376-
clearLiveEventUnlisteners(
377-
get().live.eventUnlistenersBySession[targetSessionId],
378-
);
391+
clearLiveEventUnlisteners(registeredUnlisteners);
379392
setLiveState(set, (live) => {
380-
delete live.eventUnlistenersBySession[targetSessionId];
393+
if (
394+
live.eventUnlistenersBySession[targetSessionId] ===
395+
registeredUnlisteners
396+
) {
397+
delete live.eventUnlistenersBySession[targetSessionId];
398+
}
381399
});
382400
},
383401
onSuccess: () => undefined,
@@ -396,6 +414,10 @@ function applyCaptureSnapshot<T extends GeneralState>(
396414
snapshot.activeSessionId === targetSessionId
397415
) {
398416
const currentLive = get().live;
417+
if (currentLive.sessionId !== targetSessionId) {
418+
clearLiveInterval(currentLive.intervalId);
419+
}
420+
399421
const intervalId =
400422
currentLive.sessionId === targetSessionId && currentLive.intervalId
401423
? currentLive.intervalId
@@ -428,6 +450,9 @@ function applyCaptureSnapshot<T extends GeneralState>(
428450
snapshot.finalizingSessionIds.includes(targetSessionId)
429451
) {
430452
setLiveState(set, (live) => {
453+
if (!live.sessionId) {
454+
live.sessionId = targetSessionId;
455+
}
431456
markLiveFinalizing(live, targetSessionId);
432457
});
433458
}

apps/desktop/src/store/zustand/listener/general.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,46 @@ describe("General Listener Slice", () => {
676676
expect(listenCaptureDataMock).toHaveBeenCalledTimes(1);
677677
});
678678

679+
test("attachLiveSession ignores overlapping attaches for the same session", async () => {
680+
await Promise.all([
681+
store.getState().attachLiveSession("session-a"),
682+
store.getState().attachLiveSession("session-a"),
683+
]);
684+
685+
expect(listenCaptureLifecycleMock).toHaveBeenCalledTimes(1);
686+
expect(listenCaptureStatusMock).toHaveBeenCalledTimes(1);
687+
expect(listenCaptureDataMock).toHaveBeenCalledTimes(1);
688+
expect(getCaptureSnapshotMock).toHaveBeenCalledTimes(1);
689+
});
690+
691+
test("attachLiveSession clears the previous session interval before applying an active snapshot", async () => {
692+
const intervalId = setInterval(() => {}, 1000);
693+
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
694+
getCaptureSnapshotMock.mockResolvedValueOnce({
695+
status: "ok",
696+
data: {
697+
activeSessionId: "session-b",
698+
finalizingSessionIds: [],
699+
liveTranscriptionActive: true,
700+
requestedLiveTranscription: true,
701+
state: "active",
702+
},
703+
});
704+
store.setState((state) =>
705+
mutate(state, (draft) => {
706+
markLiveActive(draft.live, "session-a", intervalId, true, true, null);
707+
}),
708+
);
709+
710+
await store.getState().attachLiveSession("session-b");
711+
712+
expect(clearIntervalSpy).toHaveBeenCalledWith(intervalId);
713+
expect(store.getState().live.sessionId).toBe("session-b");
714+
715+
clearInterval(store.getState().live.intervalId);
716+
clearIntervalSpy.mockRestore();
717+
});
718+
679719
test("attachLiveSession does not mark a different active native capture as current", async () => {
680720
getCaptureSnapshotMock.mockResolvedValueOnce({
681721
status: "ok",
@@ -694,6 +734,63 @@ describe("General Listener Slice", () => {
694734
expect(store.getState().live.sessionId).toBeNull();
695735
});
696736

737+
test("attachLiveSession hydrates finalizing native capture for the same session", async () => {
738+
let dataHandler:
739+
| ((event: {
740+
payload: {
741+
session_id: string;
742+
type: "transcript_segment_delta";
743+
delta: unknown;
744+
};
745+
}) => void)
746+
| undefined;
747+
listenCaptureDataMock.mockImplementationOnce((handler) => {
748+
dataHandler = handler;
749+
return Promise.resolve(() => {});
750+
});
751+
getCaptureSnapshotMock.mockResolvedValueOnce({
752+
status: "ok",
753+
data: {
754+
activeSessionId: null,
755+
finalizingSessionIds: ["session-a"],
756+
liveTranscriptionActive: null,
757+
requestedLiveTranscription: null,
758+
state: "finalizing",
759+
},
760+
});
761+
762+
await store.getState().attachLiveSession("session-a");
763+
764+
expect(store.getState().live.sessionId).toBe("session-a");
765+
expect(store.getState().getSessionMode("session-a")).toBe("finalizing");
766+
767+
dataHandler?.({
768+
payload: {
769+
delta: {
770+
removed_ids: [],
771+
upserts: [
772+
{
773+
end_ms: 1000,
774+
id: "segment-1",
775+
key: {
776+
channel: "DirectMic",
777+
},
778+
start_ms: 0,
779+
text: "hello",
780+
words: [],
781+
},
782+
],
783+
},
784+
session_id: "session-a",
785+
type: "transcript_segment_delta",
786+
},
787+
});
788+
789+
expect(store.getState().liveSegments).toMatchObject([
790+
{ id: "segment-1", text: "hello" },
791+
]);
792+
});
793+
697794
test("start returns false while another session is active", async () => {
698795
store.setState((state) =>
699796
mutate(state, (draft) => {

0 commit comments

Comments
 (0)