Project

General

Profile

proposal-issue-minor-functional-4.txt

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

Download (9.67 KB)

 
1
================================================================================================
2
PROPOSAL - [MINOR] functional - WebClientProtocol.startKeepAlivePing
3
The keep-alive timer reads pushWorker without holding lock, and the field is not volatile
4
================================================================================================
5

    
6
REVIEW FINDING (abridged)
7
   "the keep-alive TimerTask reads the pushWorker field from the websocket-keep-alive thread without
8
   holding lock, breaking the discipline every other access site observes - startPushWorker/
9
   stopPushWorker are both documented 'must only be called by a caller that owns the instance lock',
10
   and sendTextMessage/sendBinaryMessage read it under lock. The field is not volatile, so this is a
11
   genuine data race: during onClose the lock holder writes pushWorker = null two statements before
12
   stopKeepAlivePing(), with a stopPushing() join of up to 5 s just ahead of it, and Timer.cancel() does
13
   not await an in-flight run. No behavioural difference is reachable today [...] so this is the
14
   'documented lock discipline violated, no reachable consequence' case. Making the field volatile - as
15
   lastDataNanos correctly was - keeps the invariant honest."
16

    
17
FILE
18
   src/com/goldencode/p2j/ui/client/driver/web/WebClientProtocol.java
19

    
20
------------------------------------------------------------------------------------------------
21
1. ROOT CAUSE
22
------------------------------------------------------------------------------------------------
23

    
24
Two facts that only became relevant when r16675 added a second thread to the picture:
25

    
26
   WebClientProtocol.java:329    private PushMessagesWorker pushWorker;      // plain field
27
   WebClientProtocol.java:1694   PushMessagesWorker worker = pushWorker;     // read on the timer thread
28

    
29
Every other access site holds the instance lock: sendTextMessage (:815-818) and sendBinaryMessage
30
(:832-835) read it inside synchronized (lock), and startPushWorker/stopPushWorker (:1801, :1815) carry
31
the javadoc "must only be called by a caller that owns the instance lock". The keep-alive tick is now
32
the sole exception.
33

    
34
The write/read pair is genuinely concurrent. In onClose (:757-759) the lock holder executes
35

    
36
   stopPushWorker();     // -> stopPushing() joins up to 5 s, then pushWorker = null
37
   stopWebWorker();
38
   stopKeepAlivePing();  // -> Timer.cancel(), which does NOT await an in-flight run
39

    
40
so a tick can be executing throughout, and Timer.cancel() does not wait for it. Without volatile there
41
is no happens-before edge, so the timer thread may observe a stale non-null reference to a worker whose
42
thread has already exited - or, in principle, an out-of-thin-air-free but arbitrarily stale value.
43

    
44
Nothing reachable misbehaves today, and the finding says so: the timer thread is safely published by the
45
Thread.start() inside new Timer(...) which follows startPushWorker in the same synchronized block;
46
PushMessagesWorker.messages is final; a reconnect reuses the same worker instance; and every stale-read
47
outcome on the teardown path is a no-op on a doomed tick. This is a latent-correctness fix, not a bug
48
fix - but it is the kind that stops being latent the moment someone adds a second field to the tick.
49

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

    
54
2.1 Make the field volatile and say why
55
----------------------------------------
56

    
57
WebClientProtocol.java:328-329:
58

    
59
   -  /** Dedicated worker to push the messages to the web-socket. */
60
   -  private PushMessagesWorker pushWorker;
61
   +  /**
62
   +   * Dedicated worker to push the messages to the web-socket.
63
   +   * <p>
64
   +   * Written only under the instance lock (see {@link #startPushWorker} and
65
   +   * {@link #stopPushWorker}). Volatile because the keep-alive timer thread reads it outside that
66
   +   * lock: {@code Timer.cancel()} does not await an in-flight run, so a tick can still be executing
67
   +   * while {@link #onClose} nulls this field.
68
   +   */
69
   +  private volatile PushMessagesWorker pushWorker;
70

    
71
(The exact existing comment text at :328 should be preserved as the first line; adjust if it differs.)
72

    
73
This is the review's own recommendation and matches the precedent set by lastDataNanos in the same
74
revision.
75

    
76
2.2 Put the send back under the lock
77
-------------------------------------
78

    
79
The tick has two interactions with the worker, and they want different treatment:
80

    
81
   (a) The *send*. Today it goes through sendBinaryMessage(MSG_SERVER_PING), which already synchronizes
82
       on lock, so it is correct as-is. proposal-issue-major-performance-1 replaces it with a
83
       sendKeepAlivePing() helper that also synchronizes on lock - deliberately, so the priority push
84
       does not become a new discipline violation:
85

    
86
          private void sendKeepAlivePing()
87
          {
88
             synchronized (lock)
89
             {
90
                if (pushWorker != null)
91
                {
92
                   pushWorker.pushPriorityMessage(ByteBuffer.wrap(new byte[] { MSG_SERVER_PING }));
93
                }
94
             }
95
          }
96

    
97
   (b) The *observation* (getTransmittedCount / getQueueSize). This is a snapshot read for a heuristic;
98
       taking the instance lock for it on every tick is undesirable. onClose holds the lock across a
99
       stopPushing() join of up to 5 s, so a lock-taking observation would block the timer thread for
100
       that whole window - and the timer thread is the one thing that must never wedge. Leave it lock-
101
       free, which volatile now makes well-defined, and document the exception at the read site:
102

    
103
          // read without the instance lock on purpose: onClose holds it across a stopPushing() join of
104
          // up to WAIT_TO_DIE, and the keep-alive tick must never block on that. The field is volatile,
105
          // and a stale-but-published worker only ever costs this tick a heuristic, never correctness.
106
          PushMessagesWorker worker = pushWorker;
107

    
108
2.3 Also close the stale-tick window properly
109
----------------------------------------------
110

    
111
Optional, and worth doing while in the area. Because Timer.cancel() does not await an in-flight run, a
112
tick that has already passed the session.isOpen() check can still call session.close() *after* onClose
113
has finished - on the session it was created for, which is harmless, but the same is not obviously true
114
once the tick does more work. Two cheap belts:
115

    
116
   (a) The tick already captures `session` as its own final parameter and checks isOpen(), which is the
117
       right pattern; keep it and do not replace it with a read of this.session.
118

    
119
   (b) Give the task a `cancelled` flag set by stopKeepAlivePing() before cancel(), and check it first
120
       in run(). This makes "a cancelled timer performs no observable action" true by construction
121
       rather than by case analysis:
122

    
123
          private void stopKeepAlivePing()
124
          {
125
             if (pingTimer != null)
126
             {
127
                pingTimer.cancel();
128
                pingTimer = null;
129
                LOG.info("Keep-alive ping stopped!");
130
             }
131
          }
132

    
133
       becomes, with the task held in a field alongside the timer, `keepAliveTask.cancel()` (TimerTask.
134
       cancel() also does not await, so the flag is still what does the work) plus an early
135
       `if (isCancelled()) return;` in run(). Judgement call: this is defensive rather than fixing
136
       anything observable, and it adds a field. Recommend 2.1 + 2.2 unconditionally, and 2.3(b) only if
137
       the tick grows further.
138

    
139
------------------------------------------------------------------------------------------------
140
3. WHY NOT SYNCHRONIZE THE WHOLE TICK
141
------------------------------------------------------------------------------------------------
142

    
143
Wrapping run() in synchronized (lock) would restore the documented discipline uniformly, but it makes
144
the keep-alive thread contend with - and, during teardown, block for up to
145
PushMessagesWorker.WAIT_TO_DIE (5 s) behind - the lock held across stopPushing(). The heartbeat is the
146
last line of defence for reclaiming a wedged session; it must not be blockable by the very teardown path
147
it may need to trigger. Volatile plus a lock-scoped send is the right balance, and documenting the one
148
deliberate exception is what keeps the invariant honest.
149

    
150
------------------------------------------------------------------------------------------------
151
4. RELATIONSHIP TO OTHER PROPOSALS
152
------------------------------------------------------------------------------------------------
153

    
154
   proposal-issue-major-performance-1  introduces sendKeepAlivePing(), the lock-scoped send in 2.2(a)
155
   proposal-issue-major-functional-1   adds the getTransmittedCount() read covered by 2.2(b)
156

    
157
------------------------------------------------------------------------------------------------
158
5. RISK AND VERIFICATION
159
------------------------------------------------------------------------------------------------
160

    
161
Risk: none functionally. Volatile on a reference read once per tick and a handful of times per message
162
send is not measurable; pushMessage/pushPriorityMessage already take a monitor on `messages`.
163

    
164
Verification
165
   1. Compile and run the existing Web GUI/CHUI smoke path; there is no unit test around this class.
166
   2. Repeated connect/disconnect/reconnect cycling (30x) with the keep-alive interval forced to 1000 ms
167
      (client/web/pingPongInterval=1000) to maximise the overlap between a tick and onClose. Expect no
168
      NPE, no "Keep-alive ping could not be sent!" warnings, and a clean "Keep-alive ping stopped!" per
169
      close.
170
   3. Kill the browser mid-burst so stopPushWorker's join actually takes time, and confirm the timer
171
      thread is not blocked (thread dump during the window, or timestamps on the tick log lines).