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