Skip to content

Commit beef385

Browse files
kubeclaude
andcommitted
Fix worker race conditions, use addEventListener, and clean up turbo config
- Add message queue in useLanguageClient so messages sent before async worker init are drained once the worker is ready - Add terminated flag to both worker hooks to prevent leaking workers if the component unmounts before the dynamic import resolves - Reject pending init promise on teardown in useSimulationWorker - Switch worker.onmessage/onerror to addEventListener for eslint compliance - Extract createSimulationWorker into its own module for clean test mocking - Update test suite to use async mocks via vi.mock instead of vi.stubGlobal - Set sideEffects: ["*.css"] instead of false to prevent CSS tree-shaking - Remove stale build:site task from turbo.json Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 75c8c6c commit beef385

6 files changed

Lines changed: 222 additions & 186 deletions

File tree

libs/@hashintel/petrinaut/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
"package.json"
2020
],
2121
"type": "module",
22-
"sideEffects": false,
22+
"sideEffects": [
23+
"*.css"
24+
],
2325
"main": "dist/main.js",
2426
"types": "dist/main.d.ts",
2527
"scripts": {

libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.ts

Lines changed: 50 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -60,42 +60,58 @@ export function useLanguageClient(): LanguageClientApi {
6060
const workerRef = useRef<Worker | null>(null);
6161
const pendingRef = useRef(new Map<number, Pending>());
6262
const nextId = useRef(0);
63+
const queueRef = useRef<object[]>([]);
6364
const diagnosticsCallbackRef = useRef<
6465
((params: PublishDiagnosticsParams[]) => void) | null
6566
>(null);
6667

6768
useEffect(() => {
68-
void (async () => {
69-
const worker = await createLanguageServerWorker();
69+
let terminated = false;
7070

71-
worker.onmessage = (event: MessageEvent<ServerMessage>) => {
72-
const msg = event.data;
73-
74-
if ("id" in msg) {
75-
// Response to a request
76-
const pending = pendingRef.current.get(msg.id);
77-
if (!pending) {
78-
return;
79-
}
80-
pendingRef.current.delete(msg.id);
71+
void createLanguageServerWorker().then((worker) => {
72+
if (terminated) {
73+
worker.terminate();
74+
return;
75+
}
8176

82-
if ("error" in msg) {
83-
pending.reject(new Error(msg.error.message));
84-
} else {
85-
pending.resolve(msg.result as never);
77+
worker.addEventListener(
78+
"message",
79+
(event: MessageEvent<ServerMessage>) => {
80+
const msg = event.data;
81+
82+
if ("id" in msg) {
83+
// Response to a request
84+
const pending = pendingRef.current.get(msg.id);
85+
if (!pending) {
86+
return;
87+
}
88+
pendingRef.current.delete(msg.id);
89+
90+
if ("error" in msg) {
91+
pending.reject(new Error(msg.error.message));
92+
} else {
93+
pending.resolve(msg.result as never);
94+
}
95+
} else if ("method" in msg) {
96+
// Server-pushed notification
97+
diagnosticsCallbackRef.current?.(msg.params);
8698
}
87-
} else if ("method" in msg) {
88-
// Server-pushed notification
89-
diagnosticsCallbackRef.current?.(msg.params);
90-
}
91-
};
99+
},
100+
);
92101

93102
workerRef.current = worker;
94-
})();
103+
104+
// Drain any messages queued before the worker was ready
105+
for (const message of queueRef.current) {
106+
worker.postMessage(message);
107+
}
108+
queueRef.current = [];
109+
});
95110

96111
const pending = pendingRef.current;
97112

98113
return () => {
114+
terminated = true;
99115
workerRef.current?.terminate();
100116
workerRef.current = null;
101117
for (const entry of pending.values()) {
@@ -108,7 +124,12 @@ export function useLanguageClient(): LanguageClientApi {
108124
// --- Notifications (fire-and-forget) ---
109125

110126
const sendNotification = useCallback((message: Omit<ClientMessage, "id">) => {
111-
workerRef.current?.postMessage(message);
127+
const worker = workerRef.current;
128+
if (worker) {
129+
worker.postMessage(message);
130+
} else {
131+
queueRef.current.push(message);
132+
}
112133
}, []);
113134

114135
const initialize = useCallback(
@@ -147,17 +168,17 @@ export function useLanguageClient(): LanguageClientApi {
147168
// --- Requests (return Promise) ---
148169

149170
const sendRequest = useCallback(<T>(message: ClientMessage): Promise<T> => {
150-
const worker = workerRef.current;
151-
if (!worker) {
152-
return Promise.reject(new Error("Worker not initialized"));
153-
}
154-
155171
return new Promise<T>((resolve, reject) => {
156172
pendingRef.current.set((message as { id: number }).id, {
157173
resolve: resolve as (result: never) => void,
158174
reject,
159175
});
160-
worker.postMessage(message);
176+
const worker = workerRef.current;
177+
if (worker) {
178+
worker.postMessage(message);
179+
} else {
180+
queueRef.current.push(message);
181+
}
161182
});
162183
}, []);
163184

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/** Dynamically import and instantiate the simulation worker (inlined as blob URL). */
2+
export async function createSimulationWorker(): Promise<Worker> {
3+
const SimulationWorker = await import("./simulation.worker.ts?worker&inline");
4+
// eslint-disable-next-line new-cap
5+
return new SimulationWorker.default();
6+
}

0 commit comments

Comments
 (0)