Skip to content

Commit 483012b

Browse files
committed
refactor: extract WindowBroadcast as generic cross-window channel
WindowBroadcast<T> provides typed pub/sub over a single SecretStorage key. It handles key isolation, JSON serialization, type validation, and change events — the reusable primitive for any transient cross-window messaging. WindowIpc now creates two WindowBroadcast channels (requests and responses) instead of accessing SecretStorage directly. IPC-specific concerns (staleness filtering, ping/pong correlation) stay in WindowIpc. Tests are split accordingly: WindowBroadcast tests cover the generic channel behavior, WindowIpc tests cover IPC-specific logic only.
1 parent 02d920a commit 483012b

4 files changed

Lines changed: 186 additions & 134 deletions

File tree

src/windowBroadcast.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { Disposable, SecretStorage } from "vscode";
2+
3+
import type { Logger } from "./logging/logger";
4+
5+
/**
6+
* Typed pub/sub over a single SecretStorage key.
7+
*
8+
* SecretStorage.onDidChange fires across all VS Code windows, so each
9+
* WindowBroadcast instance acts as a cross-window channel for messages
10+
* of type T.
11+
*/
12+
export class WindowBroadcast<T> {
13+
constructor(
14+
private readonly secrets: SecretStorage,
15+
private readonly key: string,
16+
private readonly validate: (value: unknown) => value is T,
17+
private readonly logger: Logger,
18+
) {}
19+
20+
async send(msg: T): Promise<void> {
21+
await this.secrets.store(this.key, JSON.stringify(msg));
22+
}
23+
24+
onReceive(handler: (msg: T) => void | Promise<void>): Disposable {
25+
return this.secrets.onDidChange(async (e) => {
26+
if (e.key !== this.key) {
27+
return;
28+
}
29+
try {
30+
const raw = await this.secrets.get(this.key);
31+
if (!raw) {
32+
return;
33+
}
34+
const parsed: unknown = JSON.parse(raw);
35+
if (!this.validate(parsed)) {
36+
return;
37+
}
38+
await handler(parsed);
39+
} catch (err) {
40+
this.logger.error(`Error handling broadcast on ${this.key}`, err);
41+
}
42+
});
43+
}
44+
}

src/windowIpc.ts

Lines changed: 34 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import crypto from "node:crypto";
22

3+
import { WindowBroadcast } from "./windowBroadcast";
4+
35
import type { Disposable, SecretStorage } from "vscode";
46

57
import type { Logger } from "./logging/logger";
68

7-
const REQUEST_KEY = "coder.ipc.req";
8-
const RESPONSE_KEY = "coder.ipc.res";
9-
109
const MESSAGE_MAX_AGE_MS = 5000;
1110
const DEFAULT_PING_TIMEOUT_MS = 1000;
1211

@@ -65,15 +64,31 @@ function isPongMessage(msg: unknown): msg is PongMessage {
6564
);
6665
}
6766

68-
/** Cross-window IPC built on SecretStorage.onDidChange. */
67+
/** Cross-window IPC built on WindowBroadcast channels. */
6968
export class WindowIpc {
69+
private readonly requests: WindowBroadcast<RequestMessage>;
70+
private readonly responses: WindowBroadcast<PongMessage>;
71+
7072
constructor(
71-
private readonly secrets: SecretStorage,
73+
secrets: SecretStorage,
7274
private readonly logger: Logger,
73-
) {}
75+
) {
76+
this.requests = new WindowBroadcast(
77+
secrets,
78+
"coder.ipc.req",
79+
isRequestMessage,
80+
logger,
81+
);
82+
this.responses = new WindowBroadcast(
83+
secrets,
84+
"coder.ipc.res",
85+
isPongMessage,
86+
logger,
87+
);
88+
}
7489

7590
/** Send a PING and wait for a PONG within the timeout. */
76-
async sendPing(
91+
sendPing(
7792
authority: string,
7893
timeoutMs = DEFAULT_PING_TIMEOUT_MS,
7994
): Promise<PongMessage | undefined> {
@@ -92,29 +107,16 @@ export class WindowIpc {
92107
resolve(result);
93108
};
94109

95-
const listener = this.secrets.onDidChange(async (e) => {
96-
if (e.key !== RESPONSE_KEY || settled) {
97-
return;
98-
}
99-
try {
100-
const raw = await this.secrets.get(RESPONSE_KEY);
101-
if (!raw) {
102-
return;
103-
}
104-
const parsed: unknown = JSON.parse(raw);
105-
if (isPongMessage(parsed) && parsed.id === id) {
106-
settle(parsed);
107-
}
108-
} catch (err) {
109-
this.logger.error("Failed to parse IPC response", err);
110+
const listener = this.responses.onReceive((msg) => {
111+
if (msg.id === id) {
112+
settle(msg);
110113
}
111114
});
112115

113116
const timer = setTimeout(() => settle(undefined), timeoutMs);
114117

115-
const msg: PingMessage = { type: "ping", id, authority, ts: Date.now() };
116-
this.secrets
117-
.store(REQUEST_KEY, JSON.stringify(msg))
118+
this.requests
119+
.send({ type: "ping", id, authority, ts: Date.now() })
118120
.then(undefined, (err: unknown) => {
119121
this.logger.error("Failed to send IPC ping", err);
120122
settle(undefined);
@@ -127,50 +129,33 @@ export class WindowIpc {
127129
sessionId: string,
128130
folder: string,
129131
): Promise<void> {
130-
const msg: PongMessage = {
132+
await this.responses.send({
131133
type: "pong",
132134
id: pingId,
133135
sessionId,
134136
folder,
135137
ts: Date.now(),
136-
};
137-
await this.secrets.store(RESPONSE_KEY, JSON.stringify(msg));
138+
});
138139
}
139140

140141
async sendDuplicate(targetSessionId: string): Promise<void> {
141-
const msg: DuplicateMessage = {
142+
await this.requests.send({
142143
type: "duplicate",
143144
id: crypto.randomUUID(),
144145
targetSessionId,
145146
ts: Date.now(),
146-
};
147-
await this.secrets.store(REQUEST_KEY, JSON.stringify(msg));
147+
});
148148
}
149149

150150
/** Listen for incoming requests. Stale messages are ignored. */
151151
onRequest(
152152
handler: (msg: RequestMessage) => void | Promise<void>,
153153
): Disposable {
154-
return this.secrets.onDidChange(async (e) => {
155-
if (e.key !== REQUEST_KEY) {
154+
return this.requests.onReceive((msg) => {
155+
if (Date.now() - msg.ts > MESSAGE_MAX_AGE_MS) {
156156
return;
157157
}
158-
try {
159-
const raw = await this.secrets.get(REQUEST_KEY);
160-
if (!raw) {
161-
return;
162-
}
163-
const parsed: unknown = JSON.parse(raw);
164-
if (!isRequestMessage(parsed)) {
165-
return;
166-
}
167-
if (Date.now() - parsed.ts > MESSAGE_MAX_AGE_MS) {
168-
return;
169-
}
170-
await handler(parsed);
171-
} catch (err) {
172-
this.logger.error("Error handling IPC request", err);
173-
}
158+
return handler(msg);
174159
});
175160
}
176161
}

test/unit/windowBroadcast.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
3+
import { WindowBroadcast } from "../../src/windowBroadcast";
4+
import { InMemorySecretStorage, createMockLogger } from "../mocks/testHelpers";
5+
6+
interface TestMessage {
7+
kind: string;
8+
value: number;
9+
}
10+
11+
function isTestMessage(v: unknown): v is TestMessage {
12+
if (typeof v !== "object" || v === null) {
13+
return false;
14+
}
15+
const obj = v as Record<string, unknown>;
16+
return typeof obj.kind === "string" && typeof obj.value === "number";
17+
}
18+
19+
function createBroadcast(key = "test.channel") {
20+
const secrets = new InMemorySecretStorage();
21+
const logger = createMockLogger();
22+
const broadcast = new WindowBroadcast(secrets, key, isTestMessage, logger);
23+
return { secrets, logger, broadcast };
24+
}
25+
26+
describe("WindowBroadcast", () => {
27+
beforeEach(() => vi.useFakeTimers());
28+
afterEach(() => vi.useRealTimers());
29+
30+
it("delivers sent messages to receivers", async () => {
31+
const { broadcast } = createBroadcast();
32+
const handler = vi.fn();
33+
broadcast.onReceive(handler);
34+
35+
await broadcast.send({ kind: "greeting", value: 42 });
36+
await vi.advanceTimersByTimeAsync(10);
37+
38+
expect(handler).toHaveBeenCalledWith({ kind: "greeting", value: 42 });
39+
});
40+
41+
it("ignores messages on other keys", async () => {
42+
const { secrets } = createBroadcast("my.key");
43+
const other = new WindowBroadcast(
44+
secrets,
45+
"other.key",
46+
isTestMessage,
47+
createMockLogger(),
48+
);
49+
50+
const handler = vi.fn();
51+
other.onReceive(handler);
52+
53+
await secrets.store("my.key", JSON.stringify({ kind: "x", value: 1 }));
54+
await vi.advanceTimersByTimeAsync(10);
55+
56+
expect(handler).not.toHaveBeenCalled();
57+
});
58+
59+
it("ignores messages that fail validation", async () => {
60+
const { secrets, broadcast } = createBroadcast();
61+
const handler = vi.fn();
62+
broadcast.onReceive(handler);
63+
64+
await secrets.store("test.channel", JSON.stringify({ bad: "shape" }));
65+
await vi.advanceTimersByTimeAsync(10);
66+
67+
expect(handler).not.toHaveBeenCalled();
68+
});
69+
70+
it("ignores malformed JSON without crashing", async () => {
71+
const { secrets, broadcast } = createBroadcast();
72+
const handler = vi.fn();
73+
broadcast.onReceive(handler);
74+
75+
await secrets.store("test.channel", "{not json");
76+
await vi.advanceTimersByTimeAsync(10);
77+
78+
expect(handler).not.toHaveBeenCalled();
79+
});
80+
81+
it("stops delivering after dispose", async () => {
82+
const { broadcast } = createBroadcast();
83+
const handler = vi.fn();
84+
broadcast.onReceive(handler).dispose();
85+
86+
await broadcast.send({ kind: "late", value: 0 });
87+
await vi.advanceTimersByTimeAsync(10);
88+
89+
expect(handler).not.toHaveBeenCalled();
90+
});
91+
});

0 commit comments

Comments
 (0)