|
1
|
================================================================================================
|
|
2
|
PROPOSAL - [MAJOR] performance - WebClientProtocol.startKeepAlivePing
|
|
3
|
Backlog "draining" heuristic tears down healthy, actively-receiving sessions
|
|
4
|
================================================================================================
|
|
5
|
|
|
6
|
REVIEW FINDING (abridged)
|
|
7
|
"the keep-alive tears down a healthy, actively-receiving session whose outbound backlog does not
|
|
8
|
shrink between two consecutive samples. draining is queued > 0 && queued < lastQueueSize against
|
|
9
|
the single previous sample, so a deque that is flat or growing because production outruns a slow
|
|
10
|
link is never 'draining'; after MAX_BACKLOG_STUCK_TICKS the tick falls through and enqueues
|
|
11
|
MSG_SERVER_PING at the *tail* of that same FIFO [...] so once the backlog delay exceeds the
|
|
12
|
deadline the echo can no longer arrive in time. [...] because sendMessage polls before writing, a
|
|
13
|
single large in-flight message samples as queued == 0, so stuckTicks never engages and teardown
|
|
14
|
comes after only 3 ticks (~90 s)."
|
|
15
|
|
|
16
|
FILES
|
|
17
|
src/com/goldencode/p2j/ui/client/driver/web/WebClientProtocol.java
|
|
18
|
src/com/goldencode/p2j/ui/client/driver/web/PushMessagesWorker.java
|
|
19
|
|
|
20
|
------------------------------------------------------------------------------------------------
|
|
21
|
1. ROOT CAUSE
|
|
22
|
------------------------------------------------------------------------------------------------
|
|
23
|
|
|
24
|
Two independent defects in the same block (WebClientProtocol.java:1694-1720):
|
|
25
|
|
|
26
|
(a) Queue depth is the wrong observable. It is a *level*, not a rate, and the code infers progress
|
|
27
|
from a level difference against a single previous sample. A queue that is flat or growing is
|
|
28
|
indistinguishable from a wedged one, even though a flat queue under continuous production is the
|
|
29
|
normal steady state of a slow link. Worse, the level is blind to the write actually in progress:
|
|
30
|
sendMessage() polls the message off the deque before writing (PushMessagesWorker.java:304), so a
|
|
31
|
single large in-flight message samples as queued == 0, stuckTicks resets, and the peer gets no
|
|
32
|
grace at all.
|
|
33
|
|
|
34
|
(b) The ping shares the FIFO it is supposed to probe. sendBinaryMessage() -> pushMessage() ->
|
|
35
|
offerLast() puts MSG_SERVER_PING behind the entire backlog. Once the queue delay exceeds
|
|
36
|
dataIdleMs the echo *cannot* arrive in time, no matter how healthy the peer is: the probe is
|
|
37
|
self-defeating. On the web CHUI path there is no other inbound traffic to fall back on -
|
|
38
|
ChuiWebSimulator pushes one-way MSG_DRAW per screen flush, and this same revision removed the
|
|
39
|
client's periodic outbound ping (ping() became checkServerSilence(), which sends nothing).
|
|
40
|
|
|
41
|
Together they contradict the method's own documented contract that "comparing against the previous
|
|
42
|
depth is what keeps a genuinely slow but progressing burst alive indefinitely".
|
|
43
|
|
|
44
|
------------------------------------------------------------------------------------------------
|
|
45
|
2. PROPOSED FIX
|
|
46
|
------------------------------------------------------------------------------------------------
|
|
47
|
|
|
48
|
2.1 Measure the rate, not the level
|
|
49
|
------------------------------------
|
|
50
|
|
|
51
|
Delete the lastQueueSize / stuckTicks / draining logic entirely and use the monotonic
|
|
52
|
transmitted-message counter added in proposal-issue-major-functional-1 (PushMessagesWorker.
|
|
53
|
getTransmittedCount()). It is a true progress signal: it advances on every completed write,
|
|
54
|
including the in-flight one that queue depth cannot see, and it freezes on a wedged write.
|
|
55
|
|
|
56
|
getQueueSize() is kept, but demoted to diagnostics only - it appears in the log lines below and
|
|
57
|
nowhere in the decision. (If it ends up unused after this change, remove it and revision entry 009
|
|
58
|
in PushMessagesWorker.java with it, rather than leaving an unused public accessor.)
|
|
59
|
|
|
60
|
2.2 Send the ping out of band
|
|
61
|
------------------------------
|
|
62
|
|
|
63
|
Give the keep-alive a queue-jumping push so a backlog cannot delay the probe. MSG_SERVER_PING is
|
|
64
|
stateless and is answered by an echo the client handler treats independently of every other message
|
|
65
|
(p2j.socket.js case types.MSG_SERVER_PING), so reordering it ahead of drawing traffic is safe.
|
|
66
|
|
|
67
|
PushMessagesWorker:
|
|
68
|
|
|
69
|
+ /**
|
|
70
|
+ * Add a message at the *head* of the FIFO queue and notify the push thread.
|
|
71
|
+ * <p>
|
|
72
|
+ * Reserved for the keep-alive heartbeat. A probe queued behind a large backlog cannot be
|
|
73
|
+ * answered inside its own deadline, which would make the heartbeat report a healthy peer as
|
|
74
|
+ * dead; the heartbeat is stateless and independently handled by the client, so overtaking the
|
|
75
|
+ * pending output is safe. Do not use it for anything order-sensitive.
|
|
76
|
+ *
|
|
77
|
+ * @param message
|
|
78
|
+ * A binary or a text message.
|
|
79
|
+ */
|
|
80
|
+ public void pushPriorityMessage(Object message)
|
|
81
|
+ {
|
|
82
|
+ if (running)
|
|
83
|
+ {
|
|
84
|
+ messages.offerFirst(message);
|
|
85
|
+
|
|
86
|
+ // notify
|
|
87
|
+ synchronized (messages)
|
|
88
|
+ {
|
|
89
|
+ messages.notify();
|
|
90
|
+ }
|
|
91
|
+ }
|
|
92
|
+ }
|
|
93
|
|
|
94
|
WebClientProtocol, next to the other send helpers:
|
|
95
|
|
|
96
|
+ /**
|
|
97
|
+ * Send the keep-alive heartbeat, ahead of any pending output.
|
|
98
|
+ * <p>
|
|
99
|
+ * Called from the keep-alive timer thread. The instance lock is taken here so that the
|
|
100
|
+ * {@code pushWorker} read observes the same discipline as every other access site.
|
|
101
|
+ */
|
|
102
|
+ private void sendKeepAlivePing()
|
|
103
|
+ {
|
|
104
|
+ synchronized (lock)
|
|
105
|
+ {
|
|
106
|
+ if (pushWorker != null)
|
|
107
|
+ {
|
|
108
|
+ pushWorker.pushPriorityMessage(ByteBuffer.wrap(new byte[] { MSG_SERVER_PING }));
|
|
109
|
+ }
|
|
110
|
+ }
|
|
111
|
+ }
|
|
112
|
|
|
113
|
2.3 Composed tick body
|
|
114
|
-----------------------
|
|
115
|
|
|
116
|
This is the final form of the TimerTask after this proposal, proposal-issue-major-functional-1
|
|
117
|
(outbound grace) and proposal-issue-minor-security-1 (absolute cap) are applied together:
|
|
118
|
|
|
119
|
pingTimer = new Timer("websocket-keep-alive", true);
|
|
120
|
pingTimer.schedule(new TimerTask()
|
|
121
|
{
|
|
122
|
/**
|
|
123
|
* The transmitted-message count seen at the previous tick. Owned by this task instance
|
|
124
|
* rather than the enclosing protocol, so that a stale tick of a cancelled timer cannot
|
|
125
|
* perturb the counters of the timer that replaced it.
|
|
126
|
*/
|
|
127
|
private long lastTransmitted;
|
|
128
|
|
|
129
|
/**
|
|
130
|
* Close the session if the page has stopped echoing the heartbeat, otherwise send one.
|
|
131
|
*/
|
|
132
|
@Override
|
|
133
|
public void run()
|
|
134
|
{
|
|
135
|
try
|
|
136
|
{
|
|
137
|
if (!session.isOpen())
|
|
138
|
{
|
|
139
|
return;
|
|
140
|
}
|
|
141
|
|
|
142
|
PushMessagesWorker worker = pushWorker;
|
|
143
|
long transmitted = (worker != null) ? worker.getTransmittedCount() : 0L;
|
|
144
|
int queued = (worker != null) ? worker.getQueueSize() : 0;
|
|
145
|
|
|
146
|
// progress is measured as completed writes, not as queue depth: a depth is a level,
|
|
147
|
// not a rate, and it is blind to the message already polled off the queue and being
|
|
148
|
// written. A completed write is the same evidence Jetty's notIdle() used to act on,
|
|
149
|
// and it stops dead on a half-open socket.
|
|
150
|
boolean progressing = transmitted != lastTransmitted;
|
|
151
|
lastTransmitted = transmitted;
|
|
152
|
|
|
153
|
long quietMs = (System.nanoTime() - lastDataNanos) / 1_000_000L;
|
|
154
|
|
|
155
|
if (quietMs > dataIdleMs)
|
|
156
|
{
|
|
157
|
// outbound progress postpones the deadline, but never removes it: the peer's
|
|
158
|
// kernel accepting our frames does not prove its page still runs code
|
|
159
|
if (progressing && quietMs <= graceLimitMs)
|
|
160
|
{
|
|
161
|
if (LOG.isLoggable(Level.FINE))
|
|
162
|
{
|
|
163
|
LOG.fine(String.format(
|
|
164
|
"Heartbeat quiet for %d ms (deadline=%d ms) but output is still being " +
|
|
165
|
"delivered (queued=%d); waiting up to %d ms.",
|
|
166
|
quietMs,
|
|
167
|
dataIdleMs,
|
|
168
|
queued,
|
|
169
|
graceLimitMs));
|
|
170
|
}
|
|
171
|
|
|
172
|
sendKeepAlivePing();
|
|
173
|
return;
|
|
174
|
}
|
|
175
|
|
|
176
|
// either the peer is unreachable or its page no longer runs any code; in both
|
|
177
|
// cases our own pings have kept Jetty's idle timeout from ever reclaiming this
|
|
178
|
LOG.severe(String.format(
|
|
179
|
"No websocket message received for %d ms (deadline=%d ms, grace limit=%d ms, " +
|
|
180
|
"queued=%d, delivering=%b), the peer is gone or its page is no longer " +
|
|
181
|
"running; closing the session!",
|
|
182
|
quietMs,
|
|
183
|
dataIdleMs,
|
|
184
|
graceLimitMs,
|
|
185
|
queued,
|
|
186
|
progressing));
|
|
187
|
session.close(SESSION_HEARTBEAT_LOST, "client heartbeat stopped", Callback.NOOP);
|
|
188
|
return;
|
|
189
|
}
|
|
190
|
|
|
191
|
// queue-jumping, so a backlog cannot delay the probe past its own deadline
|
|
192
|
sendKeepAlivePing();
|
|
193
|
}
|
|
194
|
catch (Throwable t)
|
|
195
|
{
|
|
196
|
// must never propagate: an uncaught exception silently kills the timer thread
|
|
197
|
LOG.warning("Keep-alive ping could not be sent!", t);
|
|
198
|
}
|
|
199
|
}
|
|
200
|
}, interval, interval);
|
|
201
|
|
|
202
|
MAX_BACKLOG_STUCK_TICKS becomes unused and must be removed; its javadoc is superseded by
|
|
203
|
MAX_BACKLOG_GRACE_MULTIPLIER (proposal-issue-minor-security-1). The method javadoc paragraph
|
|
204
|
claiming "comparing against the previous depth is what keeps a genuinely slow but progressing burst
|
|
205
|
alive indefinitely" must be rewritten - the new mechanism keeps such a burst alive for graceLimitMs,
|
|
206
|
not indefinitely, and that is the honest statement.
|
|
207
|
|
|
208
|
------------------------------------------------------------------------------------------------
|
|
209
|
3. PERFORMANCE NOTES
|
|
210
|
------------------------------------------------------------------------------------------------
|
|
211
|
|
|
212
|
- No new per-message cost: one volatile long increment per successful send, on the pushworker
|
|
213
|
thread only.
|
|
214
|
- No new allocation on the tick path beyond the existing 1-byte ping buffer.
|
|
215
|
- offerFirst() on ConcurrentLinkedDeque is the same cost as offerLast().
|
|
216
|
- The scrolling-report / VT100-stream case in the finding no longer closes the session at all:
|
|
217
|
the ping overtakes the backlog, the echo arrives within one RTT, and lastDataNanos is refreshed
|
|
218
|
normally. The entire undelivered backlog is therefore no longer discarded by stopPushWorker().
|
|
219
|
|
|
220
|
------------------------------------------------------------------------------------------------
|
|
221
|
4. RELATIONSHIP TO OTHER PROPOSALS
|
|
222
|
------------------------------------------------------------------------------------------------
|
|
223
|
|
|
224
|
proposal-issue-major-functional-1 adds getTransmittedCount() consumed here
|
|
225
|
proposal-issue-minor-security-1 adds graceLimitMs / MAX_BACKLOG_GRACE_MULTIPLIER used here
|
|
226
|
proposal-issue-major-functional-2 supplies dataIdleMs derived from the idle tolerance
|
|
227
|
proposal-issue-minor-functional-3 supplies SESSION_HEARTBEAT_LOST
|
|
228
|
proposal-issue-minor-functional-4 the sendKeepAlivePing() helper also removes the unsynchronized
|
|
229
|
pushWorker read on the send path; the remaining read in the
|
|
230
|
tick still wants the field made volatile
|
|
231
|
|
|
232
|
------------------------------------------------------------------------------------------------
|
|
233
|
5. VERIFICATION
|
|
234
|
------------------------------------------------------------------------------------------------
|
|
235
|
|
|
236
|
1. Web CHUI, scrolling report over a throttled link (tc/netem, e.g. 256 kbit + 300 ms), mouse and
|
|
237
|
keyboard idle for >6 ticks. Expected before: session.close(SHUTDOWN) at ~180 s. Expected
|
|
238
|
after: session survives, MSG_SERVER_PING echoes arrive between MSG_DRAW frames.
|
|
239
|
2. Web GUI graphics-heavy repaint producing a single multi-megabyte in-flight message across
|
|
240
|
>3 ticks. Expected before: teardown at ~90 s (queued samples as 0). After: no teardown.
|
|
241
|
3. Flat-but-nonempty queue (steady production at exactly link capacity) for 10 ticks: no
|
|
242
|
teardown, no FINE grace logging (the echo keeps arriving).
|
|
243
|
4. Confirm ordering is unaffected: capture the frames and check MSG_SERVER_PING interleaving does
|
|
244
|
not break MSG_DRAW / draw-hash sequences.
|