|
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.
|