|
1
|
================================================================================================
|
|
2
|
PROPOSAL - [MAJOR] functional - p2j.socket.js handleCloseEvent
|
|
3
|
resetChunkTransfers() is unreachable on every client-initiated close
|
|
4
|
================================================================================================
|
|
5
|
|
|
6
|
REVIEW FINDING (abridged)
|
|
7
|
"resetChunkTransfers() is only reachable on the this == ws path [...] closeWebSocketSafely calls
|
|
8
|
ws.close(...) and then sets ws = null synchronously while the close event is delivered
|
|
9
|
asynchronously afterwards [...] the incomplete chunkTransfers entries and their chunkBytes
|
|
10
|
accounting survive into the next socket permanently."
|
|
11
|
|
|
12
|
FILES
|
|
13
|
src/com/goldencode/p2j/ui/client/driver/web/res/p2j.socket.js
|
|
14
|
|
|
15
|
------------------------------------------------------------------------------------------------
|
|
16
|
1. ROOT CAUSE
|
|
17
|
------------------------------------------------------------------------------------------------
|
|
18
|
|
|
19
|
handleCloseEvent (p2j.socket.js:5868) opens with
|
|
20
|
|
|
21
|
if (this != ws)
|
|
22
|
{
|
|
23
|
... log ...
|
|
24
|
if (!ws) { socketOpenedAt = 0; }
|
|
25
|
return; // <-- returns here
|
|
26
|
}
|
|
27
|
...
|
|
28
|
socketOpenedAt = 0;
|
|
29
|
resetChunkTransfers(); // <-- :5889, only on the this == ws path
|
|
30
|
attemptToRestoreConnection();
|
|
31
|
|
|
32
|
closeWebSocketSafely (:6231) does
|
|
33
|
|
|
34
|
ws.close(ceCodes.Normal_Closure, "Closing websocket due to " + reason);
|
|
35
|
ws = null;
|
|
36
|
|
|
37
|
synchronously, and never detaches ws.onclose. The close event is delivered later, by which time
|
|
38
|
this is the dying socket and ws is null (or already its replacement), so the handler takes the early
|
|
39
|
return and the chunk state is never cleared.
|
|
40
|
|
|
41
|
That the branch already special-cases exactly this situation for a different variable - the
|
|
42
|
socketOpenedAt = 0 with its comment "a client-initiated close nulled ws before this event landed" -
|
|
43
|
shows the path is known-live and that the new cleanup was simply added to the wrong side of it.
|
|
44
|
|
|
45
|
Reachability is not in question: handleMessageEvent zeroes lostPings on every inbound message, so
|
|
46
|
checkLostPings can only fire once pieces have *stopped* arriving - i.e. a stalled link during a
|
|
47
|
large image or font push, which is exactly when a transfer is incomplete. onServerSilenceExpired
|
|
48
|
and createWebSocket's own closeWebSocketSafely reach the same path.
|
|
49
|
|
|
50
|
Two consequences:
|
|
51
|
|
|
52
|
(1) An unconditional leak. The pieces are never freed and chunkBytes never decreases for them, so
|
|
53
|
repeated mid-transfer reconnects ratchet it upward until the 64 MB MAX_CHUNK_BYTES cap trips
|
|
54
|
inside collectChunk - which then wipes the pieces of the *in-flight, healthy* transfer too
|
|
55
|
and lets its remainder assemble into a tail-only message. A leak in the failure path thus
|
|
56
|
corrupts an unrelated later transfer.
|
|
57
|
|
|
58
|
(2) A same-id join across the reconnect. onClose calls stopPushWorker() while holding lock, and
|
|
59
|
stopPushing() joins for up to WAIT_TO_DIE = 5000 ms; a producer woken from awaitCapacity()
|
|
60
|
blocks on lock inside queueChunk for that window, during which the browser's reconnect can
|
|
61
|
bring onConnect onto the same unfair monitor. If onConnect wins, the producer's next
|
|
62
|
queueChunk succeeds on the new socket with the old transfer id, and the pieces the dead
|
|
63
|
worker dropped become a silent gap in the middle of a message that still reassembles under a
|
|
64
|
valid leading type byte.
|
|
65
|
|
|
66
|
------------------------------------------------------------------------------------------------
|
|
67
|
2. PROPOSED FIX
|
|
68
|
------------------------------------------------------------------------------------------------
|
|
69
|
|
|
70
|
Moving the call is necessary but NOT sufficient - see 2.3.
|
|
71
|
|
|
72
|
2.1 Clear the chunk state where the socket is actually abandoned
|
|
73
|
------------------------------------------------------------------
|
|
74
|
|
|
75
|
closeWebSocketSafely is the single funnel for client-initiated closes, and it is the point at which
|
|
76
|
the page decides the pieces can never be completed. Put the cleanup there:
|
|
77
|
|
|
78
|
function closeWebSocketSafely(reason)
|
|
79
|
{
|
|
80
|
try
|
|
81
|
{
|
|
82
|
if (ws && ws.readyState != wsStates.CLOSED && ws.readyState != wsStates.CLOSING &&
|
|
83
|
ws.readyState != wsStates.CONNECTING)
|
|
84
|
{
|
|
85
|
ws.close(ceCodes.Normal_Closure, "Closing websocket due to " + reason);
|
|
86
|
ws = null;
|
|
87
|
+ // the close event will land with ws already nulled and take handleCloseEvent's
|
|
88
|
+ // this != ws early return, so the pieces have to be dropped here
|
|
89
|
+ resetChunkTransfers();
|
|
90
|
}
|
|
91
|
}
|
|
92
|
|
|
93
|
Keep the existing call in handleCloseEvent (:5889): it covers the transport-death path, where
|
|
94
|
closeWebSocketSafely never runs.
|
|
95
|
|
|
96
|
Note the guard in closeWebSocketSafely skips a CONNECTING socket, so a close requested while the
|
|
97
|
socket is still opening leaves ws non-null and the cleanup unrun - correct, because that socket
|
|
98
|
never carried any pieces.
|
|
99
|
|
|
100
|
2.2 Also clear it on a fresh connection, as a backstop
|
|
101
|
--------------------------------------------------------
|
|
102
|
|
|
103
|
Any path that reaches a new socket without either cleanup having run still starts clean:
|
|
104
|
|
|
105
|
in handleOpenEvent (:5669), with the other per-socket resets
|
|
106
|
connected = true;
|
|
107
|
lostPings = 0;
|
|
108
|
+ resetChunkTransfers();
|
|
109
|
socketOpenedAt = (new Date()).getTime();
|
|
110
|
|
|
111
|
This is cheap and makes the invariant "a new socket begins with no partial transfers"
|
|
112
|
unconditional, rather than dependent on which of three close paths ran.
|
|
113
|
|
|
114
|
2.3 REQUIRED companion: reject a reassembly that does not start at a first piece
|
|
115
|
----------------------------------------------------------------------------------
|
|
116
|
|
|
117
|
Consequence (2) above is not addressed by clearing state - it is the opposite problem. After a
|
|
118
|
reset, the surviving suffix of the old transfer arrives at an *empty* table and starts a brand-new
|
|
119
|
reassembly, which is precisely the arbitrary-first-byte hazard. The remedy is the CHUNK_FLAG_FIRST
|
|
120
|
check specified in proposal-issue-critical-functional-1 section 2.2:
|
|
121
|
|
|
122
|
if (!parts)
|
|
123
|
{
|
|
124
|
if (!first) { drop and log; return null; }
|
|
125
|
parts = chunkTransfers[id] = [];
|
|
126
|
}
|
|
127
|
|
|
128
|
Do not land 2.1/2.2 as a standalone "fix" for this finding: on their own they convert a corrupt
|
|
129
|
message into a differently corrupt message. The pair (2.1 + first-piece flag) is what makes the
|
|
130
|
close path safe.
|
|
131
|
|
|
132
|
------------------------------------------------------------------------------------------------
|
|
133
|
3. RELATIONSHIP TO OTHER PROPOSALS
|
|
134
|
------------------------------------------------------------------------------------------------
|
|
135
|
|
|
136
|
proposal-issue-critical-functional-1 supplies CHUNK_FLAG_FIRST and the server-side generation
|
|
137
|
check. Hard prerequisite for 2.3; its section 2.3
|
|
138
|
(discardQueued on session replacement) also removes the
|
|
139
|
leak's main source.
|
|
140
|
proposal-issue-minor-functional-1 the MAX_CHUNK_BYTES abandon path that consequence (1)
|
|
141
|
feeds into; fixing this leak makes that cap far harder to
|
|
142
|
reach in practice.
|
|
143
|
|
|
144
|
------------------------------------------------------------------------------------------------
|
|
145
|
4. RISK AND VERIFICATION
|
|
146
|
------------------------------------------------------------------------------------------------
|
|
147
|
|
|
148
|
Risk: low. resetChunkTransfers() is idempotent and touches only the two chunk variables, so calling
|
|
149
|
it from three places is harmless. The only judgement call is 2.1's placement inside the readyState
|
|
150
|
guard rather than outside it; outside would also clear when a CONNECTING socket is abandoned, which
|
|
151
|
is equally correct but marginally more surprising to read.
|
|
152
|
|
|
153
|
Verification
|
|
154
|
1. Instrument collectChunk to log chunkBytes on every piece. Start a large image transfer, kill
|
|
155
|
the link mid-transfer so checkLostPings closes the socket, let it reconnect, and repeat five
|
|
156
|
times. Expected: chunkBytes returns to 0 after each cycle. Pre-fix it climbs monotonically.
|
|
157
|
2. Repeat until the pre-fix build crosses MAX_CHUNK_BYTES and logs "Abandoning incomplete message
|
|
158
|
transfers"; confirm the fixed build never reaches it.
|
|
159
|
3. With the first-piece check in place, confirm the page logs "Discarding orphaned message piece"
|
|
160
|
rather than dispatching, for any suffix that does slip through on the new socket.
|
|
161
|
4. Confirm the ordinary transport-death path (server process killed) still clears state, i.e.
|
|
162
|
the handleCloseEvent call was not removed by mistake.
|
|
163
|
5. Confirm a normal logout still works - closeWebSocketSafely is on that path too, and the added
|
|
164
|
call must not disturb the "logout" reason string that WebClientProtocol.onClose matches on.
|