Project

General

Profile

proposal-issue-major-functional-1.txt

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

Download (9.95 KB)

 
1
================================================================================================
2
PROPOSAL - [MAJOR] functional - WebClientProtocol.startKeepAlivePing
3
Inbound-only deadline: outbound activity no longer buys any tolerance
4
================================================================================================
5

    
6
REVIEW FINDING (abridged)
7
   "because the echo is emitted from the browser's onmessage handler, the deadline measures the
8
   client's inbound message-processing lag rather than its liveness - a page whose event loop is
9
   blocked [...] is declared 'gone or its page is no longer running' and closed. Outbound activity
10
   no longer buys any tolerance [...] Concrete blocker reachable from converted 4GL today:
11
   HTML-BROWSER:PRINT() [...] blocks the page task queue [...] a dialog left open past deadline +
12
   watchdog (~3.5 min at defaults) makes WatchdogTimer System.exit(-1) the client JVM and lose the
13
   FWD session and its record locks."
14

    
15
FILES
16
   src/com/goldencode/p2j/ui/client/driver/web/WebClientProtocol.java
17
   src/com/goldencode/p2j/ui/client/driver/web/PushMessagesWorker.java
18

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

    
23
Before r16675 a session survived on *either* direction of traffic. Jetty's EndPoint idle timeout is
24
reset by every successful write (SocketChannelEndPoint.flush() -> notIdle()), so a client the server
25
was still pushing output to was never reaped, no matter how long it had been silent.
26

    
27
r16675 replaces that with a strictly inbound deadline: lastDataNanos is stamped only from onMessage
28
(WebClientProtocol.java:463 and :478) and the tick closes the session when
29
(now - lastDataNanos) > dataIdleMs. The only thing that can refresh it is the page's own echo of
30
MSG_SERVER_PING, which is dispatched from the JS onmessage handler - i.e. from the page task queue.
31
Any page whose task queue is blocked therefore looks dead, even though its host, socket and JVM are
32
all healthy and the server's own writes are still completing normally.
33

    
34
The queue-depth grace does not cover it: PushMessagesWorker.sendMessage() polls the message off the
35
deque *before* writing (PushMessagesWorker.java:304), so getQueueSize() reads 0 while the write is
36
in flight, stuckTicks never engages, and teardown happens at the bare deadline.
37

    
38
------------------------------------------------------------------------------------------------
39
2. PROPOSED FIX
40
------------------------------------------------------------------------------------------------
41

    
42
Two changes, neither of which reintroduces the half-open-socket hole the heartbeat exists to close:
43

    
44
   (a) [this proposal] Let *outbound progress* postpone the deadline again, but as bounded evidence
45
       rather than as a full reset. Measure progress as "writes are still completing", which is
46
       exactly the signal Jetty's notIdle() used to give us, and which stops dead on a half-open
47
       socket (the write wedges and never completes).
48

    
49
   (b) [proposal-issue-major-functional-2] Derive the deadline from the configured idle tolerance
50
       instead of from the ping cadence. On the shipped configurations in this workspace that alone
51
       moves the print-dialog deadline from 90 s to 10 min (hotel_gui) or 1 h (counteract).
52

    
53
Both are needed. (b) makes the deadline honest about what the administrator configured; (a) makes a
54
busy-but-live peer survive it the way it did before this revision.
55

    
56
2.1 PushMessagesWorker: expose completed-write progress
57
--------------------------------------------------------
58

    
59
Add a monotonic counter of successfully transmitted messages. It is the outbound counterpart of
60
lastDataNanos and, unlike getQueueSize(), it is not blind to an in-flight write.
61

    
62
   /** Number of messages successfully handed to the socket, used as outbound liveness evidence. */
63
   private volatile long transmitted;
64

    
65
   /**
66
    * The number of messages successfully written to the socket since this worker started.
67
    * <p>
68
    * Used by the keep-alive timer as outbound evidence: a write completes when the frame has been
69
    * flushed to the peer's socket, which is the same signal that used to reset Jetty's idle
70
    * timeout, and it stops advancing on a half-open socket. Unlike {@link #getQueueSize} it is not
71
    * blind to a message which has already been polled off the queue but is still in flight.
72
    *
73
    * @return   The monotonically increasing count of transmitted messages.
74
    */
75
   public long getTransmittedCount()
76
   {
77
      return transmitted;
78
   }
79

    
80
and in sendMessage(Session), immediately after the existing failure check
81
(PushMessagesWorker.java:393-397):
82

    
83
            if (sendMessageException[0] != null)
84
            {
85
               msgSent = false;
86
               LOG.log(Level.WARNING, "Send message failed!", sendMessageException[0]);
87
            }
88
   +        else if (message != null)
89
   +        {
90
   +           // the frame reached the peer's socket; this is the outbound liveness evidence the
91
   +           // keep-alive timer uses, see getTransmittedCount
92
   +           transmitted++;
93
   +        }
94

    
95
The increment is single-writer (only the pushworker thread executes sendMessage), so a plain
96
volatile long is sufficient - no atomic needed.
97

    
98
2.2 WebClientProtocol: grant a grace while writes keep completing
99
------------------------------------------------------------------
100

    
101
Replace the queue-depth heuristic in the timer task with a test of that counter. The composed final
102
form of the tick is listed in proposal-issue-major-performance-1 (which owns the removal of the
103
queue-depth logic); the contribution of *this* proposal is the "progressing" branch:
104

    
105
   +  /** Transmitted count seen at the previous tick, used to detect completed outbound writes. */
106
   +  private long lastTransmitted;
107
      ...
108
   +           PushMessagesWorker worker = pushWorker;
109
   +           long transmitted = (worker != null) ? worker.getTransmittedCount() : 0L;
110
   +           boolean progressing = transmitted != lastTransmitted;
111
   +           lastTransmitted = transmitted;
112
   +
113
   +           long quietMs = (System.nanoTime() - lastDataNanos) / 1_000_000L;
114
   +
115
   +           // A peer that is still taking delivery is alive at the socket level even if its page
116
   +           // is not answering: its event loop may be blocked (a native print dialog from
117
   +           // HTML-BROWSER:PRINT, a modal file chooser) or it may simply be behind on a burst.
118
   +           // That is exactly the tolerance Jetty's idle timeout used to grant through notIdle(),
119
   +           // so it is granted here too - but bounded, because "the socket accepts bytes" is not
120
   +           // "the page runs code". See MAX_BACKLOG_GRACE_MULTIPLIER.
121
   +           if (quietMs > dataIdleMs && (!progressing || quietMs > graceLimitMs))
122
   +           {
123
   +              LOG.severe(...);
124
   +              session.close(SESSION_HEARTBEAT_LOST, "client heartbeat stopped", Callback.NOOP);
125
   +              return;
126
   +           }
127

    
128
Note the counter is sampled on *every* tick, not only when the deadline is exceeded; sampling only
129
inside the deadline branch would compare against a stale value from an arbitrarily long time ago and
130
make "progressing" trivially true.
131

    
132
2.3 Why the half-open case is still detected
133
---------------------------------------------
134

    
135
On the VPN-drop / laptop-suspend case the javadoc names, the socket send buffer stops draining and
136
the Jetty write callback never completes, so getTransmittedCount() freezes. The very first tick past
137
dataIdleMs then sees progressing == false and closes the session - the same behaviour as the code
138
being reviewed. The grace only ever applies while the kernel is still acknowledging our frames, and
139
even then it is capped (proposal-issue-minor-security-1).
140

    
141
------------------------------------------------------------------------------------------------
142
3. RELATIONSHIP TO OTHER PROPOSALS
143
------------------------------------------------------------------------------------------------
144

    
145
   proposal-issue-major-functional-2   deadline from the idle tolerance, not the ping cadence
146
                                       (prerequisite for a usable deadline on shipped configs)
147
   proposal-issue-major-performance-1  removes the queue-depth heuristic this replaces; owns the
148
                                       composed tick body and the priority push of the ping
149
   proposal-issue-minor-security-1     supplies graceLimitMs, the absolute cap on the grace granted
150
                                       here, and the worst-case-teardown javadoc
151
   proposal-issue-minor-functional-3   supplies SESSION_HEARTBEAT_LOST used in the close above
152

    
153
Applying 2.1 + 2.2 without proposal-issue-minor-security-1 would leave the grace unbounded; do not
154
land them separately.
155

    
156
------------------------------------------------------------------------------------------------
157
4. RISK AND VERIFICATION
158
------------------------------------------------------------------------------------------------
159

    
160
Risk: low. getTransmittedCount() is additive; the tick only ever gains a reason *not* to close.
161
Worst case a dead peer whose kernel still accepts small frames is reaped at graceLimitMs instead of
162
dataIdleMs - bounded, logged, and still far short of the TCP retransmission window this timer was
163
introduced to avoid.
164

    
165
Verification
166
   1. HTML-BROWSER:PRINT() from a converted procedure in the Web GUI; leave the native print dialog
167
      open for >3x pingPongInterval. Expected: session stays open, the keep-alive logs the grace at
168
      FINE, no WatchdogTimer System.exit(-1). Reproduces the reported blocker directly.
169
   2. Same test with the browser process SIGSTOPped instead (page blocked *and* socket unread):
170
      writes wedge, teardown occurs at dataIdleMs, i.e. dead-peer detection is unchanged.
171
   3. Drop the network at the OS level (VPN down / iptables DROP): teardown at dataIdleMs.
172
   4. Confirm the FINE grace log and the SEVERE teardown log both report quietMs, dataIdleMs and
173
      graceLimitMs, so a support engineer can tell the two apart.