Project

General

Profile

proposal-issue-major-functional-1.txt

Sergey Ivanovskiy, 08/12/2026 04:15 PM

Download (8.6 KB)

 
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.