Skip to content

Commit a7c1e8a

Browse files
authored
Make sure WebSocket state is cleaned up when "close" and "connect" events race (karthikv#37)
1 parent 6a45b80 commit a7c1e8a

File tree

4 files changed

+118
-19
lines changed

4 files changed

+118
-19
lines changed

appveyor.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
environment:
22
matrix:
3-
- nodejs_version: "10"
3+
- nodejs_version: "16"
44
cache: [node_modules]
55
install:
66
- ps: Install-Product node $env:nodejs_version

package-lock.json

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/purview.ts

+53-17
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,9 @@ interface WebSocketStateNoCSS extends BaseWebSocketState {
6565
interface BaseWebSocketState {
6666
ws: WebSocket
6767
roots: ConnectedRoot[]
68-
connected: boolean
68+
connectionState: null | "connecting" | "connected"
6969
mounted: boolean
70+
closing: boolean
7071
seenEventNames: Set<string>
7172
}
7273

@@ -301,8 +302,9 @@ export function handleWebSocket(
301302
const wsStateBase: WebSocketState = {
302303
ws,
303304
roots: [] as ConnectedRoot[],
304-
connected: false,
305+
connectionState: null,
305306
mounted: false,
307+
closing: false,
306308
seenEventNames: new Set(),
307309
hasCSS: false,
308310
}
@@ -321,19 +323,22 @@ export function handleWebSocket(
321323
})
322324

323325
ws.on("close", async () => {
324-
const promises = wsState.roots.map(async root => {
325-
const stateTree = makeStateTree(root.component, true)
326-
await reloadOptions.saveStateTree(root.component._id, stateTree)
327-
await root.component._triggerUnmount(root.allComponentsMap)
328-
})
329-
if (wsState.hasCSS) {
330-
const cssPromise = reloadOptions.saveCSSState(
331-
wsState.cssState.id,
332-
wsState.cssState,
333-
)
334-
promises.push(cssPromise)
326+
wsState.closing = true
327+
// Because both the "close" and "connect" events are async, we check if
328+
// `connectionState` is set to "connected" because it could be the case
329+
// that the "close" event fires just after the "connect" event (e.g., on
330+
// page refresh), and the "close" event will see that the `wsState.roots`
331+
// is an empty array due to the "connect" event still being in progress.
332+
// This would result in an incomplete clean up of the previous
333+
// connection's state. Hence, we return early, set the `closing` flag, and
334+
// let the "connect" event clean up the existing state by signaling with
335+
// `closing`.
336+
if (wsState.connectionState !== "connected") {
337+
return
335338
}
336-
await Promise.all(promises)
339+
340+
await cleanUpWebSocketState(wsState)
341+
wsState.closing = false
337342
})
338343
})
339344

@@ -372,10 +377,10 @@ async function handleMessage(
372377
): Promise<void> {
373378
switch (message.type) {
374379
case "connect": {
375-
if (wsState.connected) {
380+
if (wsState.connectionState !== null) {
376381
break
377382
}
378-
wsState.connected = true
383+
wsState.connectionState = "connecting"
379384

380385
const cssStateID = message.cssStateID
381386
if (cssStateID) {
@@ -442,7 +447,7 @@ async function handleMessage(
442447
type: "update",
443448
componentID: root.component._id,
444449
pNode: toLatestPNode(root.component._pNode),
445-
newEventNames: Array.from(root!.eventNames),
450+
newEventNames: Array.from(root.eventNames),
446451
})
447452

448453
// Don't wait for this, since we want wsState.mounted and wsState.roots
@@ -458,6 +463,21 @@ async function handleMessage(
458463
deletePromises.push(reloadOptions.deleteCSSState(cssStateID))
459464
}
460465
await Promise.all(deletePromises)
466+
467+
wsState.connectionState = "connected"
468+
// Because both the "close" and "connect" events are async, we check if
469+
// `closing` is set because it could be the case that the "close" event
470+
// fires just after the "connect" event (e.g., on page refresh), and the
471+
// "close" event will see that the `wsState.roots` is an empty array due
472+
// to the "connect" event still being in progress. This would result in an
473+
// incomplete cleanup of the previous connection's state. Hence, we check
474+
// the `closing` flag and clean up any existing state that the "closing"
475+
// event could not clean up if needed.
476+
if (wsState.closing) {
477+
await cleanUpWebSocketState(wsState)
478+
wsState.closing = false
479+
}
480+
461481
break
462482
}
463483

@@ -1087,6 +1107,22 @@ function unalias(id: string, root: ConnectedRoot): string {
10871107
return id
10881108
}
10891109

1110+
async function cleanUpWebSocketState(wsState: WebSocketState): Promise<void> {
1111+
const promises = wsState.roots.map(async root => {
1112+
const stateTree = makeStateTree(root.component, true)
1113+
await reloadOptions.saveStateTree(root.component._id, stateTree)
1114+
await root.component._triggerUnmount(root.allComponentsMap)
1115+
})
1116+
if (wsState.hasCSS) {
1117+
const cssPromise = reloadOptions.saveCSSState(
1118+
wsState.cssState.id,
1119+
wsState.cssState,
1120+
)
1121+
promises.push(cssPromise)
1122+
}
1123+
await Promise.all(promises)
1124+
}
1125+
10901126
const globalStateTrees: Record<string, StateTree | undefined> = {}
10911127
const globalCSSState: Record<string, CSSState | undefined> = {}
10921128
const DELETE_INTERVAL = 60 * 1000 // 60 seconds

test/purview_test.tsx

+63
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import Purview, {
1717
css,
1818
RENDER_CSS_ORDERING_ERROR,
1919
styledTag,
20+
reloadOptions,
2021
} from "../src/purview"
2122
import { parseHTML, concretize, STYLE_TAG_ID } from "../src/helpers"
2223
import {
@@ -1907,6 +1908,68 @@ test("reconnect early", async () => {
19071908
})
19081909
})
19091910

1911+
test("cleanup happens when 'close' and 'connect' race", async () => {
1912+
let mountCount = 0
1913+
let unmountCount = 0
1914+
1915+
class Foo extends TestComponent<{}, {}> {
1916+
constructor(props: {}) {
1917+
super(props)
1918+
}
1919+
1920+
componentDidMount(): void {
1921+
mountCount++
1922+
}
1923+
1924+
componentWillUnmount(): void {
1925+
unmountCount++
1926+
}
1927+
1928+
render(): JSX.Element {
1929+
return <div />
1930+
}
1931+
}
1932+
1933+
await renderAndConnect(<Foo />, async conn => {
1934+
expect(mountCount).toBe(1)
1935+
expect(unmountCount).toBe(0)
1936+
conn.ws.close()
1937+
1938+
// Wait for state to be saved and unmount to occur.
1939+
await new Promise(resolve => setTimeout(resolve, 25))
1940+
expect(mountCount).toBe(1)
1941+
expect(unmountCount).toBe(1)
1942+
1943+
const origin = `http://localhost:${conn.port}`
1944+
const ws = new WebSocket(`ws://localhost:${conn.port}`, { origin })
1945+
await new Promise(resolve => ws.addEventListener("open", resolve))
1946+
1947+
// To avoid infinite recursion, we only mock the first call to
1948+
// `getStateTree`. To allow jest to clean up the mock implementation, we use
1949+
// `spyOn` and call `spy.mockRestore()` once test execution has completed.
1950+
// This prevent subsequnt tests from using the mock implementation.
1951+
const spy = jest.spyOn(reloadOptions, "getStateTree")
1952+
spy.mockImplementationOnce(async rootID => {
1953+
// Create an artificial delay when fetching state trees so that the
1954+
// "close" event executes before the "connect" event finishes.
1955+
await new Promise(resolve => setTimeout(resolve, 200))
1956+
return await reloadOptions.getStateTree(rootID)
1957+
})
1958+
const connect: ClientMessage = {
1959+
type: "connect",
1960+
rootIDs: [conn.rootID],
1961+
}
1962+
ws.send(JSON.stringify(connect))
1963+
ws.close()
1964+
1965+
// Wait for state to be saved and unmount to occur.
1966+
await new Promise(resolve => setTimeout(resolve, 250))
1967+
expect(mountCount).toBe(2)
1968+
expect(unmountCount).toBe(2)
1969+
spy.mockRestore()
1970+
})
1971+
})
1972+
19101973
test("setState() after unmount", async () => {
19111974
let instance: Foo = null as any
19121975
class Foo extends TestComponent<{}, {}> {

0 commit comments

Comments
 (0)