|
1
|
================================================================================
|
|
2
|
PROPOSAL major-performance-1
|
|
3
|
discardQueued breaks the queued-bytes accounting and leaks a stale message
|
|
4
|
================================================================================
|
|
5
|
|
|
6
|
Branch : 11645a (task #11645), trunk base r16695
|
|
7
|
Files : src/com/goldencode/p2j/ui/client/driver/web/PushMessagesWorker.java
|
|
8
|
src/com/goldencode/p2j/ui/client/driver/web/WebClientProtocol.java
|
|
9
|
Severity : reported MAJOR, downgraded to MINOR in the challenge pass (impact
|
|
10
|
is bounded, see section 3); highest-ranked performance finding
|
|
11
|
Category : defect introduced by this branch
|
|
12
|
|
|
13
|
|
|
14
|
1. PROBLEM
|
|
15
|
--------------------------------------------------------------------------------
|
|
16
|
PushMessagesWorker.discardQueued() (:332-341) mutates the message deque and the
|
|
17
|
queuedBytes counter with no mutual exclusion against the worker thread's in-flight
|
|
18
|
send. onConnect holds WebClientProtocol.lock; the worker holds only sendLock.
|
|
19
|
Disjoint monitors, so there is no exclusion at all.
|
|
20
|
|
|
21
|
Two consequences:
|
|
22
|
|
|
23
|
(a) NEGATIVE ACCOUNTING. sendMessage captures
|
|
24
|
|
|
25
|
long msgSize = sizeOf(message); // :556
|
|
26
|
...
|
|
27
|
releaseCapacity(msgSize); // :672 -> addAndGet(-msgSize)
|
|
28
|
|
|
29
|
If queuedBytes.set(0L) (:335) lands between those two points, the counter
|
|
30
|
settles at -msgSize. Nothing floors it - the only writers are +sizeOf
|
|
31
|
(:226, :317), -msgSize (:352) and set(0) - so the offset persists until the
|
|
32
|
next discardQueued. Both awaitCapacity variants (:249, :256, :282, :289)
|
|
33
|
compare queuedBytes.get() against positive limits, so the effective high-
|
|
34
|
and low-water marks are raised by the offset.
|
|
35
|
|
|
36
|
The mirror case also exists: pushMessage does addAndGet(+size) at :226
|
|
37
|
BEFORE offerLast at :227, so an offer landing after clear() leaks the bytes
|
|
38
|
positive, permanently shrinking headroom until the next discard.
|
|
39
|
|
|
40
|
(b) A STALE MESSAGE SURVIVES THE DISCARD. On the send-failure path,
|
|
41
|
|
|
42
|
messages.offerFirst(message); // :666
|
|
43
|
|
|
44
|
runs AFTER messages.clear(). So one message built for the previous
|
|
45
|
connectionGeneration survives, sits at the head of the queue, and is the
|
|
46
|
first thing written to the fresh socket - ahead of the resync. The comment
|
|
47
|
at :665 ("its bytes remain accounted for") is false once a discard
|
|
48
|
intervened. This is precisely the invariant queuePartial's generation check
|
|
49
|
(WebClientProtocol.java:1063-1067) exists to enforce.
|
|
50
|
|
|
51
|
Trigger: onConnect's session-replacement branch firing while the push worker is
|
|
52
|
inside sendMessage - the same page-reload / half-open-socket race the
|
|
53
|
discardQueued() call was added for. The window is wide and is the expected state:
|
|
54
|
onConnect closes the old session at :463 while the worker is parked in
|
|
55
|
latch.await() on that half-open socket. The counter survives the reconnect because
|
|
56
|
startPushWorker (:2017) reuses the existing worker and the replaced session's
|
|
57
|
onClose takes the session != this.session branch (:809), skipping stopPushWorker().
|
|
58
|
|
|
59
|
|
|
60
|
2. WHY IT IS NOT WORSE THAN IT LOOKS
|
|
61
|
--------------------------------------------------------------------------------
|
|
62
|
Recorded so the fix is not over-engineered. The original report claimed the skew
|
|
63
|
is "permanently negative and cumulative across reconnects". It is not:
|
|
64
|
|
|
65
|
- There is exactly one worker thread, so at most one msgSize can race per
|
|
66
|
discard.
|
|
67
|
- Every subsequent discardQueued re-bases the counter with an absolute set(0).
|
|
68
|
- Magnitude is bounded: streamed transfers - the only large payloads - are
|
|
69
|
chunked to PARTIAL_PAYLOAD_SIZE = 64 KB (WebClientProtocol.java:324), and
|
|
70
|
ordinary drawing / hoverable batches are KB-scale. So the 32 MB guard is
|
|
71
|
perturbed by tens of KB, not progressively raised.
|
|
72
|
|
|
73
|
Consequence (b) is also largely absorbed by the peer: collectChunk drops an
|
|
74
|
orphaned piece whose PARTIAL_FLAG_FIRST is unset ("Discarding orphaned message
|
|
75
|
piece", p2j.socket.js:3205-3212) and handleOpenEvent calls resetChunkTransfers()
|
|
76
|
(:5712). Residual exposure is a stale non-partial message rendered ahead of the
|
|
77
|
resync, or a stale FIRST piece opening a transfer that never completes.
|
|
78
|
|
|
79
|
That is why this is MINOR, not MAJOR. It is still worth fixing: the counter is the
|
|
80
|
only memory guard on the push queue, and a silently wrong counter is the kind of
|
|
81
|
defect that only shows up as an OOM under load months later.
|
|
82
|
|
|
83
|
|
|
84
|
3. RELATIONSHIP TO proposal-major-functional-2
|
|
85
|
--------------------------------------------------------------------------------
|
|
86
|
functional-2 recommends removing the discardQueued() call from onConnect entirely,
|
|
87
|
because the GUI never resyncs after it. If that is done, this defect becomes
|
|
88
|
unreachable from onConnect - but the method stays broken and will bite the next
|
|
89
|
caller. Fix or remove it in the same change. Settle functional-2 first, then apply
|
|
90
|
whichever branch below matches the decision.
|
|
91
|
|
|
92
|
|
|
93
|
4. PROPOSED FIX
|
|
94
|
--------------------------------------------------------------------------------
|
|
95
|
IF discardQueued() IS REMOVED (functional-2 recommended option):
|
|
96
|
Delete the method along with its only call site, so the broken accounting cannot
|
|
97
|
be reintroduced. Nothing further needed.
|
|
98
|
|
|
99
|
IF discardQueued() IS RETAINED (functional-2 alternative B, or a future caller):
|
|
100
|
|
|
101
|
Fix 1 - drain and subtract instead of set(0). Only ever subtract what was
|
|
102
|
actually removed, which fixes both the negative skew and the positive leak in
|
|
103
|
one stroke:
|
|
104
|
|
|
105
|
Object msg;
|
|
106
|
|
|
107
|
while ((msg = messages.poll()) != null)
|
|
108
|
{
|
|
109
|
queuedBytes.addAndGet(-sizeOf(msg));
|
|
110
|
}
|
|
111
|
|
|
112
|
The in-flight message the worker already polled is never in the deque, so its
|
|
113
|
bytes are never subtracted here - and the worker's own releaseCapacity(msgSize)
|
|
114
|
remains correct. The pushMessage(+size before offerLast) ordering also becomes
|
|
115
|
harmless: an offer that lands after the drain leaves both the entry and its bytes
|
|
116
|
present, which is consistent.
|
|
117
|
|
|
118
|
Fix 2 - do not re-queue a message from a superseded generation. Give the worker
|
|
119
|
the generation it polled under and drop the message on the failure path when the
|
|
120
|
generation has moved on:
|
|
121
|
|
|
122
|
if (!msgSent)
|
|
123
|
{
|
|
124
|
if (generation == callbacks.currentGeneration())
|
|
125
|
{
|
|
126
|
messages.offerFirst(message); // bytes still accounted
|
|
127
|
}
|
|
128
|
else
|
|
129
|
{
|
|
130
|
releaseCapacity(msgSize); // superseded: drop and account
|
|
131
|
}
|
|
132
|
}
|
|
133
|
|
|
134
|
If threading a generation through is unwelcome, a cheaper equivalent is a
|
|
135
|
volatile discardEpoch incremented by discardQueued(); the worker captures it
|
|
136
|
before the send and skips the offerFirst if it changed. Either way the rule is:
|
|
137
|
a message must not survive a discard.
|
|
138
|
|
|
139
|
Fix 3 - correct the comment at :665, which currently asserts an invariant the
|
|
140
|
code does not maintain.
|
|
141
|
|
|
142
|
Fix 4 (assertion, optional but cheap) - after the drain, assert or log at FINE
|
|
143
|
when queuedBytes.get() is negative. The counter should never be negative; making
|
|
144
|
that visible turns any future recurrence into a log line instead of a mystery.
|
|
145
|
|
|
146
|
|
|
147
|
5. ADJACENT CHEAP WIN - NOT PART OF THIS DEFECT
|
|
148
|
--------------------------------------------------------------------------------
|
|
149
|
Surfaced while verifying the same code path, one-line fix, worth folding into the
|
|
150
|
same commit:
|
|
151
|
|
|
152
|
PARTIAL_PAYLOAD_SIZE is 64 * 1024 (WebClientProtocol.java:324), so each piece is
|
|
153
|
6 + 65536 = 65542 bytes on the wire. Jetty 12.0.34 defaults
|
|
154
|
DEFAULT_MAX_FRAME_SIZE = 65536 with DEFAULT_AUTO_FRAGMENT = true, and FWD calls
|
|
155
|
neither setMaxFrameSize nor setAutoFragment. Every piece therefore crosses the
|
|
156
|
frame boundary by exactly 6 bytes and is auto-fragmented into a full 65536-byte
|
|
157
|
frame plus a 6-byte continuation frame - roughly 500 extra near-empty frames per
|
|
158
|
4K image.
|
|
159
|
|
|
160
|
Fix: make the payload (64 * 1024) - PARTIAL_HEADER_SIZE so a piece is exactly one
|
|
161
|
frame.
|
|
162
|
|
|
163
|
Separately, the choice of 64 KB itself is worth revisiting: the pre-change path
|
|
164
|
used ChunkTransferDaemon.getChunkSizeCached() (1 MB default) with a single reused
|
|
165
|
buffer, and nothing forces 64 KB - the server's setMaxBinaryMessageSize is
|
|
166
|
inbound-only and defaults to -1, the browser's maxBinaryMessage governs only
|
|
167
|
client-to-server pieces, and collectChunk has no per-piece cap. A larger piece
|
|
168
|
reduces the per-piece cost (a lock acquisition on the same monitor as
|
|
169
|
GuiWebEmulatedWindow.offer, two CAS ops, a deque node, a monitor notify, a latch
|
|
170
|
+ Callback + park/unpark round trip) by the same factor. The stated constraint -
|
|
171
|
a piece must stay well under the echo deadline of 3 ping intervals - is met by a
|
|
172
|
far larger piece: 1 MB only breaches a 90 s deadline below ~93 KB/s. That is a
|
|
173
|
tuning decision, not a defect; raise it with the team rather than changing it
|
|
174
|
unilaterally.
|
|
175
|
|
|
176
|
|
|
177
|
6. RISK
|
|
178
|
--------------------------------------------------------------------------------
|
|
179
|
Low. Fix 1 is strictly more conservative than set(0) - it cannot subtract bytes
|
|
180
|
that were not removed. Fix 2 changes what happens to one message on a failure path
|
|
181
|
that only runs during a reconnect.
|
|
182
|
|
|
183
|
Watch for: sizeOf(msg) must be the same function used at enqueue time, or the drain
|
|
184
|
introduces its own skew. Verify there is exactly one sizeOf implementation.
|
|
185
|
|
|
186
|
|
|
187
|
7. TEST PLAN
|
|
188
|
--------------------------------------------------------------------------------
|
|
189
|
1. Instrument queuedBytes (a FINE log or a JMX counter) and confirm it returns to
|
|
190
|
exactly 0 when activity settles - before any reconnect, after a clean reconnect,
|
|
191
|
and after a half-open-socket reconnect during heavy drawing.
|
|
192
|
2. Force the race deliberately: hold the worker in sendMessage (breakpoint or an
|
|
193
|
injected delay on the dead socket) and drive onConnect's replacement branch.
|
|
194
|
Confirm the counter is non-negative and consistent afterwards.
|
|
195
|
3. Confirm no stale pre-reconnect message is rendered on the new socket ahead of
|
|
196
|
the resync (drive a distinguishable draw op just before the outage).
|
|
197
|
4. Load test: sustained large-image streaming across repeated forced reconnects;
|
|
198
|
watch for the high-water guard still engaging at the configured 32 MB.
|
|
199
|
5. If the adjacent frame-size fix is taken: confirm on the wire (browser devtools
|
|
200
|
frame view or a Jetty frame log) that a piece is one frame, not two.
|