Project

General

Profile

proposal-major-performance-1.txt

Sergey Ivanovskiy, 08/14/2026 05:13 AM

Download (10.1 KB)

 
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.