Project

General

Profile

proposal-issue-critical-functional-1.txt

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

Download (12 KB)

 
1
================================================================================================
2
PROPOSAL - [CRITICAL] functional - WebClientProtocol.sendBinaryMessageStreamed / queueChunk
3
A MSG_CHUNK transfer is not bound to a session; its tail is delivered to the next one
4
================================================================================================
5

    
6
REVIEW FINDING (abridged)
7
   "A MSG_CHUNK transfer is bound to nothing but 'whatever session and worker are current right
8
   now', so a reconnect that straddles a transfer delivers its tail to the *new* browser session
9
   [...] the piece with flags bit 0 re-enters messageHandler with whole[0] = an arbitrary pixel or
10
   font byte. 0x85 is MSG_QUIT, which calls doRedirectToLogoutPage() - a spurious logout roughly one
11
   time in 256."
12

    
13
FILES
14
   src/com/goldencode/p2j/ui/client/driver/web/WebClientProtocol.java
15
   src/com/goldencode/p2j/ui/client/driver/web/PushMessagesWorker.java
16
   src/com/goldencode/p2j/ui/client/driver/web/WebClientMessageTypes.java
17
   src/com/goldencode/p2j/ui/client/driver/web/res/p2j.socket.js
18

    
19
------------------------------------------------------------------------------------------------
20
1. ROOT CAUSE
21
------------------------------------------------------------------------------------------------
22

    
23
queueChunk (WebClientProtocol.java:1003) validates each piece against the *current* session:
24

    
25
   if ((pushWorker == null) || !session.isOpen())
26

    
27
There is no record of which session the transfer began on. Two independent routes therefore carry
28
a transfer's tail onto a later connection:
29

    
30
   (1) The queue survives the swap. startPushWorker (:1962) reuses the existing worker
31
       (if (pushWorker == null)) and only calls setCurrentSession; onConnect (:444) never calls
32
       stopPushWorker - the sole caller is onClose's active-session branch (:811), which the
33
       session != this.session guard (:782) skips for a replaced session. Pieces already sitting in
34
       the deque are then written to the new socket by the worker.
35

    
36
   (2) The producer outlives the cleanup. Even where onClose does run stopPushWorker, the thread
37
       still inside the while (sent < bodyLen) loop (:958) simply observes the freshly created
38
       worker and open session on its next queueChunk call and queues the remainder there.
39

    
40
This is the *designed* ordering on a half-open link: the page gives up after maxLostPings periods,
41
roughly half the server idle timeout, so onConnect(new) precedes onClose(old). The endpoint is the
42
same object across a reconnect - GuiWebDriver creates one GuiWebSocket and GenericWebSocketCreator
43
returns that same instance for every upgrade, which is precisely why onConnect has to cope with a
44
pre-existing this.session.
45

    
46
On the page, handleCloseEvent ran resetChunkTransfers(), and collectChunk (p2j.socket.js:3197) keys
47
only on the transfer id with no notion of where a transfer starts:
48

    
49
   var parts = chunkTransfers[id];
50
   if (!parts) { parts = chunkTransfers[id] = []; }
51

    
52
so an arriving tail silently begins a fresh reassembly, and the piece carrying flags bit 0
53
concatenates it and re-enters messageHandler with whole[0] being raw pixel or font data.
54

    
55
Note the two routes need different remedies: (1) is about bytes *already queued* and can only be
56
caught at the receiver or by purging the queue; (2) is about bytes *not yet produced* and is caught
57
at the sender. Fixing only one leaves the other open.
58

    
59
------------------------------------------------------------------------------------------------
60
2. PROPOSED FIX
61
------------------------------------------------------------------------------------------------
62

    
63
Three changes. (a) and (b) are each sufficient against one route and are both required; (c) is a
64
bandwidth and latency cleanup that also removes the failure entirely in the common case.
65

    
66
2.1 (a) Bind the transfer to a connection generation  [stops route 2]
67
----------------------------------------------------------------------
68

    
69
A monotonic counter bumped on every accepted connection is enough; it needs no lock beyond the one
70
onConnect already holds.
71

    
72
   +  /**
73
   +   * Incremented on every accepted websocket connection.  A streamed transfer captures it when it
74
   +   * starts and abandons itself once it no longer matches, so a transfer can never span a
75
   +   * reconnect: its tail would arrive at a peer that never saw its head.
76
   +   */
77
   +  private volatile int connectionGeneration;
78

    
79
in onConnect (:456), alongside the session assignment:
80

    
81
           this.session = session;
82
   +       connectionGeneration++;
83

    
84
sendBinaryMessageStreamed captures it once and threads it through:
85

    
86
      public void sendBinaryMessageStreamed(byte[] header, InputStream body, long bodyLen)
87
      {
88
         int transferId = nextTransferId.incrementAndGet();
89
   +     int generation = connectionGeneration;
90
         ...
91
   -     if (!queueChunk(transferId, first, header.length, !hasBody) || !hasBody)
92
   +     if (!queueChunk(generation, transferId, first, header.length, true, !hasBody) || !hasBody)
93

    
94
and queueChunk gains the check, inside the existing synchronized (lock) block so it is consistent
95
with the session read next to it:
96

    
97
   -  private boolean queueChunk(int transferId, byte[] msg, int payloadLen, boolean last)
98
   +  private boolean queueChunk(int generation, int transferId, byte[] msg, int payloadLen,
99
   +                             boolean first, boolean last)
100
      {
101
         awaitSendCapacity();
102

    
103
         synchronized (lock)
104
         {
105
   -        if ((pushWorker == null) || !session.isOpen())
106
   +        // the generation test must come first: on a reconnect the new session is open, so an
107
   +        // isOpen() check alone would happily accept this transfer's tail onto it
108
   +        if ((generation != connectionGeneration) || (pushWorker == null) || !session.isOpen())
109
            {
110
               return false;
111
            }
112

    
113
            msg[0] = MSG_CHUNK;
114
            writeMessageInt32(msg, 1, transferId);
115
   -        msg[5] = (byte) (last ? 1 : 0);
116
   +        msg[5] = (byte) ((last ? CHUNK_FLAG_LAST : 0) | (first ? CHUNK_FLAG_FIRST : 0));
117

    
118
The existing callers already treat a false return as "abandon the transfer" (:950, :975), so the
119
loop unwinds correctly with no further change. The early-EOF and IOException paths (:970, :983) pass
120
first = false.
121

    
122
2.2 (b) Refuse to start a reassembly mid-stream  [stops route 1]
123
------------------------------------------------------------------
124

    
125
Flags bit 1 marks the piece that opens a transfer. In WebClientMessageTypes, beside MSG_CHUNK:
126

    
127
   +  /** {@link #MSG_CHUNK} flags bit 0: this piece completes the transfer. */
128
   +  public static final byte CHUNK_FLAG_LAST = 0x01;
129
   +
130
   +  /**
131
   +   * {@link #MSG_CHUNK} flags bit 1: this piece opens the transfer.  The peer refuses to begin a
132
   +   * reassembly without it, so a tail that outlived its connection is discarded rather than
133
   +   * concatenated into a message whose first byte is then arbitrary payload.
134
   +   */
135
   +  public static final byte CHUNK_FLAG_FIRST = 0x02;
136

    
137
and update the MSG_CHUNK javadoc, which currently documents only bit 0.
138

    
139
In p2j.socket.js, collectChunk:
140

    
141
      function collectChunk(message)
142
      {
143
         var id = me.readInt32BinaryMessage(message, 1);
144
         var last = (message[5] & 1) !== 0;
145
   +     var first = (message[5] & 2) !== 0;
146
         var piece = message.subarray(6);
147
         var parts = chunkTransfers[id];
148

    
149
         if (!parts)
150
         {
151
   +        if (!first)
152
   +        {
153
   +           // a tail whose head went to a previous socket: dispatching it would run an arbitrary
154
   +           // payload byte as a message type, so drop it and wait for a real transfer start
155
   +           p2j.logger.error("Discarding orphaned message piece, transfer id " + id);
156
   +           return null;
157
   +        }
158
            parts = chunkTransfers[id] = [];
159
         }
160

    
161
This is the load-bearing half of the fix: it is the only thing that protects against pieces the
162
worker had already accepted before the swap, which no sender-side check can recall.
163

    
164
2.3 (c) Purge queued output on session replacement  [optional, recommended]
165
----------------------------------------------------------------------------
166

    
167
With (a) and (b) the tail is harmless, but it is still transmitted and still discarded at the peer.
168
Dropping it at the swap saves the bandwidth and shortens the reconnect. In PushMessagesWorker:
169

    
170
   +  /**
171
   +   * Discard everything still queued and release any throttled producer.
172
   +   * <p>
173
   +   * Called when the session is replaced: the queued output was addressed to a socket that is
174
   +   * gone, and {@code onConnect} resynchronizes the peer through {@code initRemoteClient} anyway.
175
   +   */
176
   +  public void discardQueued()
177
   +  {
178
   +     messages.clear();
179
   +     queuedBytes.set(0L);
180
   +
181
   +     synchronized (capacityGuard)
182
   +     {
183
   +        capacityGuard.notifyAll();
184
   +     }
185
   +  }
186

    
187
called from onConnect immediately before startPushWorker(session), i.e. after the generation bump:
188

    
189
   +       if (pushWorker != null)
190
   +       {
191
   +          pushWorker.discardQueued();
192
   +       }
193
           startWebWorker();
194
           startPushWorker(session);
195

    
196
CAVEAT worth weighing before taking (c): a reconnect does not reload the page, so the canvas keeps
197
its contents, and this drops draw output that the peer has not yet received. onConnect calls
198
callbacks.initRemoteClient() to resynchronize, so this should be safe, but it is the one part of
199
this proposal that changes behaviour for non-chunk traffic. If that resync is not trusted to be
200
complete, take (a) and (b) only - they are sufficient for correctness on their own.
201

    
202
------------------------------------------------------------------------------------------------
203
3. RELATIONSHIP TO OTHER PROPOSALS
204
------------------------------------------------------------------------------------------------
205

    
206
   proposal-issue-major-functional-1   the page-side cleanup gap (resetChunkTransfers unreachable
207
                                       on client-initiated closes).  Its "same-id join" case is
208
                                       closed by 2.2 here; that proposal states the dependency.
209
   proposal-issue-minor-functional-1   the MAX_CHUNK_BYTES abandon path, which has the same
210
                                       arbitrary-first-byte hazard and is likewise closed by 2.2.
211

    
212
2.2 is the common remedy for all three findings. If only one change from this whole review is
213
taken, take that one.
214

    
215
------------------------------------------------------------------------------------------------
216
4. RISK AND VERIFICATION
217
------------------------------------------------------------------------------------------------
218

    
219
Risk: low-to-moderate. (a) and (b) are additive guards on a path that currently has none; the wire
220
format changes only in the interpretation of previously unused flag bits, and server and page ship
221
together, so there is no mixed-version window. (c) is the only behavioural change and is the part
222
to drop if the resync assumption does not hold.
223

    
224
Verification
225
   1. Stream a large image (a maximized window with a full-size background, so bodyLen is several
226
      MB), and drop the network at the OS level mid-transfer (iptables DROP) so the page reconnects
227
      while the producer is still looping. Expected: server log shows the transfer abandoned at the
228
      generation check; page log shows no "Discarding orphaned message piece" (2.3 purged them) or
229
      a bounded number of them (without 2.3); no spurious logout, no canvas corruption.
230
   2. Same, with 2.3 omitted, to confirm 2.2 alone contains the damage.
231
   3. Force the 1-in-256 case deliberately: temporarily make the peer log whole[0] instead of
232
      dispatching, run test 1 repeatedly, and confirm the pre-fix build produces MSG_QUIT-valued
233
      first bytes while the fixed build produces none.
234
   4. Confirm a normal (non-interrupted) large image and a custom font still render, i.e. the
235
      first-piece flag is actually set on the opening piece of both callers - GuiWebSocket
236
      defineImageStreamed and createFont.
237
   5. Confirm the header-only case (body == null) still works: it sends one piece with both the
238
      first and last bits set.