Project

General

Profile

proposal-issue-major-performance-1.txt

Sergey Ivanovskiy, 08/12/2026 02:56 AM

Download (12.3 KB)

 
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.