Project

General

Profile

proposal-issue-minor-performance-1.txt

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

Download (10.4 KB)

 
1
================================================================================================
2
PROPOSAL - [MINOR] performance - WebClientProtocol.sendBinaryMessageStreamed
3
The whole payload now accumulates in the push queue, defeating the method's purpose
4
================================================================================================
5

    
6
REVIEW FINDING (abridged)
7
   "The transfer's whole payload now accumulates in the push queue, weakening the bounded-footprint
8
   property the method exists for [...] queueChunk gates only on PushMessagesWorker's 32 MiB *byte*
9
   high-water mark [...] All ~128 freshly allocated 64 KiB arrays are therefore live in messages at
10
   once on top of the source pixels, where the old path held one reused
11
   min(getChunkSizeCached()=1 MiB, bodyLen) buffer."
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/gui/driver/web/GuiWebSocket.java
17

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

    
22
The pre-rewrite method was strictly bounded: one reused buffer, and a synchronous per-frame
23
CountDownLatch that stopped the producer until the frame was away. The rewrite had to stop reusing
24
the buffer - the send is now asynchronous and the queue owns the bytes until the worker writes them,
25
which the comment at :960-962 states correctly - but it replaced the per-frame stop with the queue's
26
existing byte budget, and that budget is far larger than one transfer.
27

    
28
queueChunk calls awaitSendCapacity() per piece, which reaches PushMessagesWorker.awaitCapacity():
29

    
30
   if (queuedBytes.get() < highWater())     // DEFAULT_HIGH_WATER = 32 MB
31
   {
32
      return;
33
   }
34

    
35
so the producer does not park until 32 MB is outstanding. The producer is a bulk copy out of
36
VirtualScreen's int[] running at memory speed; the consumer does one latch-synchronised sendBinary
37
per message. The producer wins by orders of magnitude, so for any transfer below the high-water mark
38
the queue accumulates the entire payload and backpressure never engages once.
39

    
40
Peak transient heap for one streamed transfer therefore goes from ~1 MiB to min(bodyLen, ~32 MiB).
41

    
42
Scale, honestly stated: this is bounded, and it is infrequent. defineImageStreamed runs once per
43
*distinct* image per session behind GuiWebDriver's first-use cache gate, not per frame; createFont
44
is sub-MB and self-drains because it then blocks in waitForResult. An 8.3 MB full-window background
45
against a documented ~512 MB client heap is about 1.6%, and 128 short-lived 64 KiB arrays are
46
cheaper for the collector than the single humongous 8.3 MB array the old non-streaming path used.
47
So this is a regression against the method's stated design intent rather than a scaling problem -
48
which is why it is MINOR and why the cheapest correct fix is preferable to a sophisticated one.
49

    
50
------------------------------------------------------------------------------------------------
51
2. PROPOSED FIX
52
------------------------------------------------------------------------------------------------
53

    
54
2.1 Gate the streaming producer on in-flight pieces, not on the shared byte budget
55
------------------------------------------------------------------------------------
56

    
57
A counting semaphore released as each piece is written bounds the transfer to a handful of live
58
buffers, independently of what the 32 MB budget is doing for ordinary sends. In
59
PushMessagesWorker, alongside the existing capacity machinery:
60

    
61
   +  /** Maximum streamed pieces outstanding at once; bounds a transfer's live buffers. */
62
   +  private static final int MAX_INFLIGHT_CHUNKS = 4;
63
   +
64
   +  /** Permits for streamed pieces; acquired before queueing one, released once it is written. */
65
   +  private final Semaphore chunkPermits = new Semaphore(MAX_INFLIGHT_CHUNKS);
66
   +
67
   +  /**
68
   +   * Queue one piece of a streamed transfer, blocking while {@link #MAX_INFLIGHT_CHUNKS} pieces
69
   +   * are already outstanding.
70
   +   * <p>
71
   +   * Unlike {@link #pushMessage} this bounds the transfer by piece count rather than by the shared
72
   +   * byte budget, so a single large transfer cannot materialize in the queue in its entirety.
73
   +   * <p>
74
   +   * IMPORTANT: call this WITHOUT holding {@code WebClientProtocol.lock}.
75
   +   *
76
   +   * @param    message
77
   +   *           The piece to enqueue.
78
   +   */
79
   +  public void pushChunk(Object message)
80
   +  {
81
   +     try
82
   +     {
83
   +        chunkPermits.acquire();
84
   +     }
85
   +     catch (InterruptedException e)
86
   +     {
87
   +        Thread.currentThread().interrupt();
88
   +        return;
89
   +     }
90
   +
91
   +     pushMessage(message);
92
   +  }
93

    
94
The permit must be released wherever a chunk leaves the queue - both after a successful send and on
95
the failure/teardown paths - or a stalled socket wedges the producer permanently. The cleanest place
96
is beside the existing releaseCapacity(msgSize) call in the send loop, conditional on the message
97
being a chunk. That needs the worker to recognise a chunk; the least invasive way is to wrap the
98
piece in a tiny marker type rather than inspecting msg[0]:
99

    
100
   +  /** A streamed transfer piece; carries a permit that is released once the piece is written. */
101
   +  static final class Chunk
102
   +  {
103
   +     final ByteBuffer buffer;
104
   +
105
   +     Chunk(ByteBuffer buffer) { this.buffer = buffer; }
106
   +  }
107

    
108
with sizeOf() and the send path unwrapping it, and releaseCapacity's neighbourhood doing
109
chunkPermits.release() for a Chunk. stopPushing() must drain and release outstanding permits too.
110

    
111
Then in WebClientProtocol.queueChunk, swap the call:
112

    
113
   -        pushWorker.pushMessage(ByteBuffer.wrap(msg, 0, CHUNK_HEADER_SIZE + payloadLen));
114
   +        pushWorker.pushChunk(ByteBuffer.wrap(msg, 0, CHUNK_HEADER_SIZE + payloadLen));
115

    
116
and note that the acquire must happen outside synchronized (lock) - move it to sit with the existing
117
awaitSendCapacity() call at the top of queueChunk rather than inside the block, for the same
118
deadlock reason that comment already gives.
119

    
120
At MAX_INFLIGHT_CHUNKS = 4 the peak is ~256 KiB of live buffers instead of the whole payload, and
121
the heartbeat still interleaves freely because pushMessageFirst neither acquires a permit nor waits
122
behind one.
123

    
124
2.2 Cheaper alternative if 2.1 is judged too much machinery
125
-------------------------------------------------------------
126

    
127
Give the streamed path its own, much lower byte threshold rather than a permit count:
128

    
129
   in queueChunk, replace awaitSendCapacity() with a variant that parks while queuedBytes exceeds,
130
   say, 1 MB rather than the 32 MB high-water mark.
131

    
132
This needs no marker type and no permit accounting - just a second threshold parameter on
133
awaitCapacity - but it is approximate, because queuedBytes is shared with ordinary sends, so a busy
134
draw batch can make the streamed producer park early. That is harmless (it only throttles a
135
background transfer) and this option is a legitimate 80% fix at 20% of the complexity.
136

    
137
Recommendation: take 2.2 unless a measurement shows the transfer footprint actually matters, and
138
record 2.1 as the design if it ever does. The finding is a design-intent regression, not a
139
production symptom, and 2.1 adds a marker type and permit-lifecycle obligations to a class that is
140
already the most concurrency-sensitive in this area.
141

    
142
2.3 Correct the stale javadoc uncovered alongside this
143
--------------------------------------------------------
144

    
145
GuiWebSocket.defineImageStreamed's javadoc parenthetical still says "the send blocks until the whole
146
message is on the wire". The *requirement* it justifies still holds - sendBinaryMessageStreamed
147
drains the InputStream on the calling thread, which is what keeps the FWD context available for the
148
underlying chunk pulls - but the stated reason has not been true since the rewrite. Reword to say
149
the body is consumed on the calling thread, without claiming the send is synchronous.
150

    
151
------------------------------------------------------------------------------------------------
152
3. RELATIONSHIP TO OTHER PROPOSALS
153
------------------------------------------------------------------------------------------------
154

    
155
   proposal-issue-critical-functional-1   changes queueChunk's signature and adds a generation
156
                                          check.  If both are taken, apply that one first; the
157
                                          permit acquire belongs next to awaitSendCapacity(), before
158
                                          the generation check, and an abandoned transfer must
159
                                          release any permit it holds.
160

    
161
Note the interaction explicitly: with 2.1, a transfer abandoned by the generation check returns from
162
queueChunk having acquired a permit it never queues. That permit must be released on that path or
163
repeated reconnects will exhaust the semaphore and hang the next transfer.
164

    
165
------------------------------------------------------------------------------------------------
166
4. RISK AND VERIFICATION
167
------------------------------------------------------------------------------------------------
168

    
169
Risk: moderate for 2.1 - a permit leak on any path that removes a chunk from the queue without
170
releasing (teardown, discardQueued from the critical proposal, an exception in the send loop) turns
171
into a hung producer, which is worse than the footprint it fixes. Low for 2.2, which cannot deadlock
172
because it reuses the existing timed-wait capacity mechanism.
173

    
174
Verification
175
   1. Baseline the peak: attach a heap sampler (or log queuedBytes at each queueChunk) while pushing
176
      a maximized-window background image. Expected pre-fix: queuedBytes tracks the full payload.
177
      Post-fix (2.1): it plateaus at ~4 pieces; (2.2): at ~1 MB.
178
   2. Confirm the transfer still completes and renders correctly, and time it - the fix must not
179
      measurably slow the transfer, since the consumer was never the bottleneck's cause.
180
   3. Confirm the heartbeat still goes out during a large transfer (server log shows pings at the
181
      configured cadence throughout), i.e. the permit gate did not starve pushMessageFirst.
182
   4. For 2.1 specifically: kill the link mid-transfer, let the session tear down and reconnect, and
183
      repeat five times. Confirm the next transfer still starts - this is the permit-leak test and
184
      is the one that matters.
185
   5. Confirm custom font loading (the other sendBinaryMessageStreamed caller) is unaffected.