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