Fossil SCM

Rework the chat poll fallback considerably. The notification widgets now interact with other messages better and don't mis-fire when the long-poll connection times out.

stephan 2025-04-10 06:01 chat-backoff-timer2
Commit 9516f7cd1580b1f9f628763344edc14447a2dcb34de7ce18e9c5780763cb4e0e
--- src/fossil.fetch.js
+++ src/fossil.fetch.js
@@ -34,11 +34,17 @@
3434
handler throws an exception. In the context of the callback, the
3535
options object is "this". Note that this function is intended to be
3636
used solely for error reporting, not error recovery. Because
3737
onerror() may be called if onload() throws, it is up to the caller
3838
to ensure that their onerror() callback references only state which
39
- is valid in such a case.
39
+ is valid in such a case. Special cases for the Error object: (1) If
40
+ the connection times out, the error object will have its
41
+ (.name='timeout') and its (.status=XHR.status) set. (2) If it gets
42
+ a non 2xx HTTP code then it will have
43
+ (.name='http',.status=XHR.status). (3) If it was proxied through a
44
+ JSON-format exception on the server, it will have
45
+ (.name='json',status=XHR.status).
4046
4147
- method: 'POST' | 'GET' (default = 'GET'). CASE SENSITIVE!
4248
4349
- payload: anything acceptable by XHR2.send(ARG) (DOMString,
4450
Document, FormData, Blob, File, ArrayBuffer), or a plain object or
@@ -166,11 +172,14 @@
166172
}else{
167173
x.responseType = opt.responseType||'text';
168174
}
169175
x.ontimeout = function(){
170176
try{opt.aftersend()}catch(e){/*ignore*/}
171
- opt.onerror(new Error("XHR timeout of "+x.timeout+"ms expired."));
177
+ const err = new Error("XHR timeout of "+x.timeout+"ms expired.");
178
+ err.status = x.status;
179
+ err.name = 'timeout';
180
+ opt.onerror(err);
172181
};
173182
x.onreadystatechange = function(){
174183
if(XMLHttpRequest.DONE !== x.readyState) return;
175184
try{opt.aftersend()}catch(e){/*ignore*/}
176185
if(false && 0===x.status){
@@ -180,20 +189,32 @@
180189
request is actually sent and it appears to have no
181190
side-effects on the app other than to generate an error
182191
(i.e. no requests/responses are missing). This is a silly
183192
workaround which may or may not bite us later. If so, it can
184193
be removed at the cost of an unsightly console error message
185
- in FF. */
194
+ in FF.
195
+
196
+ 2025-04-10: that behavior is now also in Chrome and enabling
197
+ this workaround causes our timeout errors to never arrive.
198
+ */
186199
return;
187200
}
188201
if(200!==x.status){
189202
let err;
190203
try{
191204
const j = JSON.parse(x.response);
192
- if(j.error) err = new Error(j.error);
205
+ if(j.error){
206
+ err = new Error(j.error);
207
+ err.name = 'json.error';
208
+ }
193209
}catch(ex){/*ignore*/}
194
- opt.onerror(err || new Error("HTTP response status "+x.status+"."));
210
+ if( !err ){
211
+ err = new Error("HTTP response status "+x.status+".")
212
+ err.name = 'http';
213
+ }
214
+ err.status = x.status;
215
+ opt.onerror(err);
195216
return;
196217
}
197218
const orh = opt.responseHeaders;
198219
let head;
199220
if(true===orh){
200221
--- src/fossil.fetch.js
+++ src/fossil.fetch.js
@@ -34,11 +34,17 @@
34 handler throws an exception. In the context of the callback, the
35 options object is "this". Note that this function is intended to be
36 used solely for error reporting, not error recovery. Because
37 onerror() may be called if onload() throws, it is up to the caller
38 to ensure that their onerror() callback references only state which
39 is valid in such a case.
 
 
 
 
 
 
40
41 - method: 'POST' | 'GET' (default = 'GET'). CASE SENSITIVE!
42
43 - payload: anything acceptable by XHR2.send(ARG) (DOMString,
44 Document, FormData, Blob, File, ArrayBuffer), or a plain object or
@@ -166,11 +172,14 @@
166 }else{
167 x.responseType = opt.responseType||'text';
168 }
169 x.ontimeout = function(){
170 try{opt.aftersend()}catch(e){/*ignore*/}
171 opt.onerror(new Error("XHR timeout of "+x.timeout+"ms expired."));
 
 
 
172 };
173 x.onreadystatechange = function(){
174 if(XMLHttpRequest.DONE !== x.readyState) return;
175 try{opt.aftersend()}catch(e){/*ignore*/}
176 if(false && 0===x.status){
@@ -180,20 +189,32 @@
180 request is actually sent and it appears to have no
181 side-effects on the app other than to generate an error
182 (i.e. no requests/responses are missing). This is a silly
183 workaround which may or may not bite us later. If so, it can
184 be removed at the cost of an unsightly console error message
185 in FF. */
 
 
 
 
186 return;
187 }
188 if(200!==x.status){
189 let err;
190 try{
191 const j = JSON.parse(x.response);
192 if(j.error) err = new Error(j.error);
 
 
 
193 }catch(ex){/*ignore*/}
194 opt.onerror(err || new Error("HTTP response status "+x.status+"."));
 
 
 
 
 
195 return;
196 }
197 const orh = opt.responseHeaders;
198 let head;
199 if(true===orh){
200
--- src/fossil.fetch.js
+++ src/fossil.fetch.js
@@ -34,11 +34,17 @@
34 handler throws an exception. In the context of the callback, the
35 options object is "this". Note that this function is intended to be
36 used solely for error reporting, not error recovery. Because
37 onerror() may be called if onload() throws, it is up to the caller
38 to ensure that their onerror() callback references only state which
39 is valid in such a case. Special cases for the Error object: (1) If
40 the connection times out, the error object will have its
41 (.name='timeout') and its (.status=XHR.status) set. (2) If it gets
42 a non 2xx HTTP code then it will have
43 (.name='http',.status=XHR.status). (3) If it was proxied through a
44 JSON-format exception on the server, it will have
45 (.name='json',status=XHR.status).
46
47 - method: 'POST' | 'GET' (default = 'GET'). CASE SENSITIVE!
48
49 - payload: anything acceptable by XHR2.send(ARG) (DOMString,
50 Document, FormData, Blob, File, ArrayBuffer), or a plain object or
@@ -166,11 +172,14 @@
172 }else{
173 x.responseType = opt.responseType||'text';
174 }
175 x.ontimeout = function(){
176 try{opt.aftersend()}catch(e){/*ignore*/}
177 const err = new Error("XHR timeout of "+x.timeout+"ms expired.");
178 err.status = x.status;
179 err.name = 'timeout';
180 opt.onerror(err);
181 };
182 x.onreadystatechange = function(){
183 if(XMLHttpRequest.DONE !== x.readyState) return;
184 try{opt.aftersend()}catch(e){/*ignore*/}
185 if(false && 0===x.status){
@@ -180,20 +189,32 @@
189 request is actually sent and it appears to have no
190 side-effects on the app other than to generate an error
191 (i.e. no requests/responses are missing). This is a silly
192 workaround which may or may not bite us later. If so, it can
193 be removed at the cost of an unsightly console error message
194 in FF.
195
196 2025-04-10: that behavior is now also in Chrome and enabling
197 this workaround causes our timeout errors to never arrive.
198 */
199 return;
200 }
201 if(200!==x.status){
202 let err;
203 try{
204 const j = JSON.parse(x.response);
205 if(j.error){
206 err = new Error(j.error);
207 err.name = 'json.error';
208 }
209 }catch(ex){/*ignore*/}
210 if( !err ){
211 err = new Error("HTTP response status "+x.status+".")
212 err.name = 'http';
213 }
214 err.status = x.status;
215 opt.onerror(err);
216 return;
217 }
218 const orh = opt.responseHeaders;
219 let head;
220 if(true===orh){
221
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -129,12 +129,13 @@
129129
return resized;
130130
})();
131131
fossil.FRK = ForceResizeKludge/*for debugging*/;
132132
const Chat = ForceResizeKludge.chat = (function(){
133133
const cs = { // the "Chat" object (result of this function)
134
- verboseErrors: false /* if true then certain, mostly extraneous,
135
- error messages may be sent to the console. */,
134
+ beVerbose: false /* if true then certain, mostly extraneous,
135
+ error messages and log messages may be sent
136
+ to the console. */,
136137
playedBeep: false /* used for the beep-once setting */,
137138
e:{/*map of certain DOM elements.*/
138139
messageInjectPoint: E1('#message-inject-point'),
139140
pageTitle: E1('head title'),
140141
loadOlderToolbar: undefined /* the load-posts toolbar (dynamically created) */,
@@ -155,11 +156,12 @@
155156
viewSearch: E1('#chat-search'),
156157
searchContent: E1('#chat-search-content'),
157158
btnPreview: E1('#chat-button-preview'),
158159
views: document.querySelectorAll('.chat-view'),
159160
activeUserListWrapper: E1('#chat-user-list-wrapper'),
160
- activeUserList: E1('#chat-user-list')
161
+ activeUserList: E1('#chat-user-list'),
162
+ eMsgPollError: undefined /* current connection error MessageMidget */
161163
},
162164
me: F.user.name,
163165
mxMsg: F.config.chat.initSize ? -F.config.chat.initSize : -50,
164166
mnMsg: undefined/*lowest message ID we've seen so far (for history loading)*/,
165167
pageIsActive: 'visible'===document.visibilityState,
@@ -195,32 +197,32 @@
195197
*/
196198
timer:{
197199
tidPoller: undefined /* poller timer */,
198200
$initialDelay: 1000 /* initial polling interval (ms) */,
199201
currentDelay: 1000 /* current polling interval */,
200
- maxDelay: 60000 /* max interval when backing off for
202
+ maxDelay: 60000 * 5 /* max interval when backing off for
201203
connection errors */,
202204
minDelay: 5000 /* minimum delay time */,
203205
tidReconnect: undefined /*timer id for reconnection determination*/,
204206
randomInterval: function(factor){
205207
return Math.floor(Math.random() * factor);
206208
},
207209
incrDelay: function(){
208210
if( this.maxDelay > this.currentDelay ){
209211
if(this.currentDelay < this.minDelay){
210
- this.currentDelay = this.minDelay + this.randomInterval(this.minDelay/2);
212
+ this.currentDelay = this.minDelay + this.randomInterval(this.minDelay);
211213
}else{
212
- this.currentDelay = this.currentDelay*2 + this.randomInterval(this.currentDelay/2);
214
+ this.currentDelay = this.currentDelay*2 + this.randomInterval(this.currentDelay);
213215
}
214216
}
215217
return this.currentDelay;
216218
},
217219
resetDelay: function(){
218220
return this.currentDelay = this.$initialDelay;
219221
},
220222
isDelayed: function(){
221
- return this.currentDelay > this.$initialDelay;
223
+ return (this.currentDelay > this.$initialDelay) ? this.currentDelay : 0;
222224
}
223225
},
224226
/**
225227
Gets (no args) or sets (1 arg) the current input text field
226228
value, taking into account single- vs multi-line input. The
@@ -272,11 +274,10 @@
272274
*/
273275
ajaxStart: function(){
274276
if(1===++this.ajaxInflight){
275277
this.enableAjaxComponents(false);
276278
}
277
- setupConnectionReestablished();
278279
},
279280
/* Must be called after any ajax-related call for which
280281
ajaxStart() was called, regardless of success or failure. If
281282
it was the last such call (as measured by calls to
282283
ajaxStart() and ajaxEnd()), elements disabled by a prior call
@@ -697,11 +698,11 @@
697698
*/
698699
cs.reportErrorAsMessage = function f(/*msg args*/){
699700
const args = argsToArray(arguments).map(function(v){
700701
return (v instanceof Error) ? v.message : v;
701702
});
702
- if(Chat.verboseErrors){
703
+ if(Chat.beVerbose){
703704
console.error("chat error:",args);
704705
}
705706
const d = new Date().toISOString(),
706707
mw = new this.MessageWidget({
707708
isError: true,
@@ -733,10 +734,11 @@
733734
lmtime: d,
734735
xmsg: args
735736
});
736737
this.injectMessageElem(mw.e.body);
737738
mw.scrollIntoView();
739
+ //Chat.playNewMessageSound();// browser complains b/c this wasn't via human interaction
738740
return mw;
739741
};
740742
741743
cs.getMessageElemById = function(id){
742744
return qs('[data-msgid="'+id+'"]');
@@ -842,11 +844,11 @@
842844
const self = this;
843845
F.fetch('chat-fetch-one',{
844846
urlParams:{ name: id, raw: true},
845847
responseType: 'json',
846848
onload: function(msg){
847
- reportConnectionReestablished();
849
+ reportConnectionReestablished('chat-fetch-one');
848850
content.$elems[1] = D.append(D.pre(),msg.xmsg);
849851
content.$elems[1]._xmsgRaw = msg.xmsg/*used for copy-to-clipboard feature*/;
850852
self.toggleTextMode(e);
851853
},
852854
aftersend:function(){
@@ -905,11 +907,11 @@
905907
if(this.userMayDelete(e)){
906908
this.ajaxStart();
907909
F.fetch("chat-delete/" + id, {
908910
responseType: 'json',
909911
onload:(r)=>{
910
- reportConnectionReestablished();
912
+ reportConnectionReestablished('chat-delete');
911913
this.deleteMessageElem(r);
912914
},
913915
onerror:(err)=>this.reportErrorAsMessage(err)
914916
});
915917
}else{
@@ -1527,11 +1529,11 @@
15271529
n: nFetch,
15281530
i: iFirst
15291531
},
15301532
responseType: "json",
15311533
onload:function(jx){
1532
- reportConnectionReestablished();
1534
+ reportConnectionReestablished('chat-query.onload');
15331535
if( bDown ) jx.msgs.reverse();
15341536
jx.msgs.forEach((m) => {
15351537
m.isSearchResult = true;
15361538
var mw = new Chat.MessageWidget(m);
15371539
if( bDown ){
@@ -1696,48 +1698,52 @@
16961698
if(theMsg) Chat.deleteMessageElem(theMsg);
16971699
}));
16981700
Chat.reportErrorAsMessage(w);
16991701
};
17001702
1701
- const removeConnectionErrors = function() {
1702
- D.remove(Chat.e.viewMessages.querySelectorAll(
1703
- '.message-widget.error-connection'));
1704
- };
1705
-
1706
- /* Assume the connection has been established, reset
1707
- the Chat.timer.tidReconnect, and alert the user
1708
- that the outage appears to be over. */
1709
- const reportConnectionReestablished = function(){
1703
+ /* Assume the connection has been established, reset the
1704
+ Chat.timer.tidReconnect, and (if showMsg and
1705
+ !!Chat.e.eMsgPollError) alert the user that the outage appears to
1706
+ be over. */
1707
+ const reportConnectionReestablished = function(dbgContext, showMsg = true){
1708
+ if(Chat.beVerbose){
1709
+ console.warn("reportConnectionReestablished()",
1710
+ dbgContext, showMsg, Chat.timer.tidReconnect, Chat.e.eMsgPollError);
1711
+ }
17101712
if( Chat.timer.tidReconnect ){
17111713
clearTimeout(Chat.timer.tidReconnect);
17121714
Chat.timer.tidReconnect = 0;
17131715
}
1714
- if( Chat.timer.isDelayed() ){
1715
- removeConnectionErrors();
1716
- Chat.timer.resetDelay();
1717
- Chat.reportReconnection(
1718
- "Connection restored after outage."
1719
- );
1720
- setTimeout( Chat.poll, 0 );
1721
- }
1716
+ Chat.timer.resetDelay();
1717
+ if( Chat.e.eMsgPollError ) {
1718
+ Chat.e.eMsgPollError = undefined;
1719
+ if( showMsg ){
1720
+ Chat.reportReconnection(
1721
+ "Poller connection restored."
1722
+ );
1723
+ }
1724
+ }
1725
+ setTimeout( Chat.poll, 0 );
17221726
};
17231727
1724
- /* To be called from F.fetch() beforesend() handlers. If we're
1725
- currently in delayed-retry mode and a connection is start, try to
1726
- reset the delay after N time waiting on that connection. The fact
1727
- that the connection is waiting to respond, rather than outright
1728
- failing, is a good hint that the outage is over and we can reset
1729
- the back-off timer. */
1730
- const setupConnectionReestablished = function(){
1728
+ /* To be called from F.fetch('chat-poll') beforesend() handlers. If we're
1729
+ currently in delayed-retry mode and a connection is started, try
1730
+ to reset the delay after N time waiting on that connection. The
1731
+ fact that the connection is waiting to respond, rather than
1732
+ outright failing, is a good hint that the outage is over and we
1733
+ can reset the back-off timer. */
1734
+ const clearPollErrOnWait = function(){
17311735
if( !Chat.timer.tidReconnect && Chat.timer.isDelayed() ){
17321736
Chat.timer.tidReconnect = setTimeout(()=>{
17331737
Chat.timer.tidReconnect = 0;
17341738
if( poll.running ){
1735
- reportConnectionReestablished();
1739
+ /* This chat-poll F.fetch() is still underway, so let's
1740
+ assume the connection is back up until/unless it times
1741
+ out or breaks again. */
1742
+ reportConnectionReestablished('clearPollErrOnWait');
17361743
}
1737
- }, Chat.timer.$initialDelay * 5 );
1738
- removeConnectionErrors();
1744
+ }, Chat.timer.$initialDelay * 3 );
17391745
}
17401746
};
17411747
17421748
/**
17431749
Submits the contents of the message input field (if not empty)
@@ -2582,32 +2588,34 @@
25822588
Chat.reportErrorAsMessage(
25832589
"Shutting down chat poller due to server-side error. ",
25842590
"Reload this page to reactivate it."
25852591
);
25862592
Chat.timer.tidPoller = undefined;
2587
- poll.running = false;
25882593
} else {
2589
- poll.running = false;
2590
- if( !err ){
2591
- /* Restart the poller. */
2592
- Chat.timer.tidPoller = setTimeout(
2593
- poll, Chat.timer.resetDelay()
2594
- );
2594
+ if( err && Chat.beVerbose ){
2595
+ console.error("afterPollFetch:",err.name,err.status,err.message);
2596
+ }
2597
+ if( !err || 'timeout'===err.name/*(probably) long-poll expired*/ ){
2598
+ /* Restart the poller immediately. */
2599
+ reportConnectionReestablished('afterPollFetch '+err, false);
25952600
}else{
25962601
/* Delay a while before trying again, noting that other Chat
25972602
APIs may try and succeed at connections before this timer
2598
- resolves. */
2603
+ resolves, in which case they'll clear this timeout and the
2604
+ UI message about the outage. */
25992605
const delay = Chat.timer.incrDelay();
2600
- const msg = D.addClass(
2601
- Chat.reportErrorAsMessage(
2602
- "Connection error. Retrying in ",
2603
- delay, " ms."
2604
- ).e.body,
2605
- 'error-connection'
2606
- );
2606
+ //console.warn("afterPollFetch Chat.e.eMsgPollError",Chat.e.eMsgPollError);
2607
+ const msg = "Connection error. Retrying in "+delay+ " ms.";
2608
+ if( Chat.e.eMsgPollError ){
2609
+ /* Update the error message on the current error MessageWidget */
2610
+ Chat.e.eMsgPollError.e.content.innerText = msg;
2611
+ }else {
2612
+ /* Set current (new) error MessageWidget */
2613
+ Chat.e.eMsgPollError = Chat.reportErrorAsMessage(msg);
2614
+ //Chat.playNewMessageSound();// browser complains b/c this wasn't via human interaction
2615
+ }
26072616
Chat.timer.tidPoller = setTimeout(()=>{
2608
- D.remove(msg);
26092617
poll();
26102618
}, delay);
26112619
}
26122620
//console.log("isOkay =",isOkay,"currentDelay =",Chat.timer.currentDelay);
26132621
}
@@ -2627,42 +2635,78 @@
26272635
xhrRequest.status value to do so, with status==0 being a
26282636
connection error. We do not currently have a clean way of passing
26292637
that info back to the fossil.fetch() client, so we'll need to
26302638
hammer on that API a bit to get this working.
26312639
*/
2632
- const poll = async function f(){
2640
+ const poll = Chat.poll = async function f(){
26332641
if(f.running) return;
26342642
f.running = true;
26352643
Chat._isBatchLoading = f.isFirstCall;
26362644
if(true===f.isFirstCall){
26372645
f.isFirstCall = false;
2646
+ Chat.aPollErr = [];
26382647
Chat.ajaxStart();
26392648
Chat.e.viewMessages.classList.add('loading');
2649
+ setInterval(
2650
+ /*
2651
+ We manager onerror() results in poll() using a
2652
+ stack of error objects and we delay their handling by
2653
+ a small amount, rather than immediately when the
2654
+ exception arrives.
2655
+
2656
+ This level of indirection is to work around an
2657
+ inexplicable behavior from the F.fetch() connections:
2658
+ timeouts are always announced in pairs of an HTTP 0 and
2659
+ something we can unambiguously identify as a timeout. When
2660
+ that happens, we ignore the HTTP 0. If, however, an HTTP 0
2661
+ is seen here without an immediately-following timeout, we
2662
+ process it.
2663
+
2664
+ It's kinda like in the curses C API, where you to match
2665
+ ALT-X by first getting an ALT event, then a separate X
2666
+ event, but a lot less explicable.
2667
+ */
2668
+ ()=>{
2669
+ if( Chat.aPollErr.length ){
2670
+ if(Chat.aPollErr.length>1){
2671
+ console.warn('aPollErr',Chat.aPollErr);
2672
+ if(Chat.aPollErr[1].name='timeout'){
2673
+ /* mysterious pairs of HTTP 0 followed immediately
2674
+ by timeout response; ignore the former in that case. */
2675
+ Chat.aPollErr.shift();
2676
+ }
2677
+ }
2678
+ afterPollFetch(Chat.aPollErr.shift());
2679
+ }
2680
+ },
2681
+ 1000
2682
+ );
26402683
}
2684
+ let nErr = 0;
26412685
F.fetch("chat-poll",{
2642
- timeout: 420 * 1000/*FIXME: get the value from the server*/,
2643
- //timeout: 8000,
2686
+ timeout: //420 * 1000/*FIXME: get the value from the server*/,
2687
+ 15000,
26442688
urlParams:{
26452689
name: Chat.mxMsg
26462690
},
26472691
responseType: "json",
26482692
// Disable the ajax start/end handling for this long-polling op:
26492693
beforesend: function(){
2650
- setupConnectionReestablished();
2694
+ clearPollErrOnWait();
26512695
},
26522696
aftersend: function(){
2697
+ poll.running = false;
26532698
},
26542699
onerror:function(err){
26552700
Chat._isBatchLoading = false;
2656
- if(Chat.verboseErrors) console.error("poll onerror:",err);
2657
- /* ^^^ we don't use Chat.reportError() here b/c the polling
2658
- fails exepectedly when it times out, but is then immediately
2659
- resumed, and reportError() produces a loud error message. */
2660
- afterPollFetch(err);
2701
+ if(Chat.beVerbose){
2702
+ console.error("poll.onerror:",err.name,err.status,JSON.stringify(err));
2703
+ }
2704
+ Chat.aPollErr.push(err);
26612705
},
26622706
onload:function(y){
2663
- reportConnectionReestablished();
2707
+ reportConnectionReestablished('poll.onload', true);
26642708
newcontent(y);
26652709
if(Chat._isBatchLoading){
26662710
Chat._isBatchLoading = false;
26672711
Chat.updateActiveUserList();
26682712
}
@@ -2669,11 +2713,10 @@
26692713
afterPollFetch();
26702714
}
26712715
});
26722716
};
26732717
poll.isFirstCall = true;
2674
- Chat.poll = poll;
26752718
Chat._gotServerError = poll.running = false;
26762719
if( window.fossil.config.chat.fromcli ){
26772720
Chat.chatOnlyMode(true);
26782721
}
26792722
Chat.timer.tidPoller = setTimeout(poll, Chat.timer.resetDelay());
26802723
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -129,12 +129,13 @@
129 return resized;
130 })();
131 fossil.FRK = ForceResizeKludge/*for debugging*/;
132 const Chat = ForceResizeKludge.chat = (function(){
133 const cs = { // the "Chat" object (result of this function)
134 verboseErrors: false /* if true then certain, mostly extraneous,
135 error messages may be sent to the console. */,
 
136 playedBeep: false /* used for the beep-once setting */,
137 e:{/*map of certain DOM elements.*/
138 messageInjectPoint: E1('#message-inject-point'),
139 pageTitle: E1('head title'),
140 loadOlderToolbar: undefined /* the load-posts toolbar (dynamically created) */,
@@ -155,11 +156,12 @@
155 viewSearch: E1('#chat-search'),
156 searchContent: E1('#chat-search-content'),
157 btnPreview: E1('#chat-button-preview'),
158 views: document.querySelectorAll('.chat-view'),
159 activeUserListWrapper: E1('#chat-user-list-wrapper'),
160 activeUserList: E1('#chat-user-list')
 
161 },
162 me: F.user.name,
163 mxMsg: F.config.chat.initSize ? -F.config.chat.initSize : -50,
164 mnMsg: undefined/*lowest message ID we've seen so far (for history loading)*/,
165 pageIsActive: 'visible'===document.visibilityState,
@@ -195,32 +197,32 @@
195 */
196 timer:{
197 tidPoller: undefined /* poller timer */,
198 $initialDelay: 1000 /* initial polling interval (ms) */,
199 currentDelay: 1000 /* current polling interval */,
200 maxDelay: 60000 /* max interval when backing off for
201 connection errors */,
202 minDelay: 5000 /* minimum delay time */,
203 tidReconnect: undefined /*timer id for reconnection determination*/,
204 randomInterval: function(factor){
205 return Math.floor(Math.random() * factor);
206 },
207 incrDelay: function(){
208 if( this.maxDelay > this.currentDelay ){
209 if(this.currentDelay < this.minDelay){
210 this.currentDelay = this.minDelay + this.randomInterval(this.minDelay/2);
211 }else{
212 this.currentDelay = this.currentDelay*2 + this.randomInterval(this.currentDelay/2);
213 }
214 }
215 return this.currentDelay;
216 },
217 resetDelay: function(){
218 return this.currentDelay = this.$initialDelay;
219 },
220 isDelayed: function(){
221 return this.currentDelay > this.$initialDelay;
222 }
223 },
224 /**
225 Gets (no args) or sets (1 arg) the current input text field
226 value, taking into account single- vs multi-line input. The
@@ -272,11 +274,10 @@
272 */
273 ajaxStart: function(){
274 if(1===++this.ajaxInflight){
275 this.enableAjaxComponents(false);
276 }
277 setupConnectionReestablished();
278 },
279 /* Must be called after any ajax-related call for which
280 ajaxStart() was called, regardless of success or failure. If
281 it was the last such call (as measured by calls to
282 ajaxStart() and ajaxEnd()), elements disabled by a prior call
@@ -697,11 +698,11 @@
697 */
698 cs.reportErrorAsMessage = function f(/*msg args*/){
699 const args = argsToArray(arguments).map(function(v){
700 return (v instanceof Error) ? v.message : v;
701 });
702 if(Chat.verboseErrors){
703 console.error("chat error:",args);
704 }
705 const d = new Date().toISOString(),
706 mw = new this.MessageWidget({
707 isError: true,
@@ -733,10 +734,11 @@
733 lmtime: d,
734 xmsg: args
735 });
736 this.injectMessageElem(mw.e.body);
737 mw.scrollIntoView();
 
738 return mw;
739 };
740
741 cs.getMessageElemById = function(id){
742 return qs('[data-msgid="'+id+'"]');
@@ -842,11 +844,11 @@
842 const self = this;
843 F.fetch('chat-fetch-one',{
844 urlParams:{ name: id, raw: true},
845 responseType: 'json',
846 onload: function(msg){
847 reportConnectionReestablished();
848 content.$elems[1] = D.append(D.pre(),msg.xmsg);
849 content.$elems[1]._xmsgRaw = msg.xmsg/*used for copy-to-clipboard feature*/;
850 self.toggleTextMode(e);
851 },
852 aftersend:function(){
@@ -905,11 +907,11 @@
905 if(this.userMayDelete(e)){
906 this.ajaxStart();
907 F.fetch("chat-delete/" + id, {
908 responseType: 'json',
909 onload:(r)=>{
910 reportConnectionReestablished();
911 this.deleteMessageElem(r);
912 },
913 onerror:(err)=>this.reportErrorAsMessage(err)
914 });
915 }else{
@@ -1527,11 +1529,11 @@
1527 n: nFetch,
1528 i: iFirst
1529 },
1530 responseType: "json",
1531 onload:function(jx){
1532 reportConnectionReestablished();
1533 if( bDown ) jx.msgs.reverse();
1534 jx.msgs.forEach((m) => {
1535 m.isSearchResult = true;
1536 var mw = new Chat.MessageWidget(m);
1537 if( bDown ){
@@ -1696,48 +1698,52 @@
1696 if(theMsg) Chat.deleteMessageElem(theMsg);
1697 }));
1698 Chat.reportErrorAsMessage(w);
1699 };
1700
1701 const removeConnectionErrors = function() {
1702 D.remove(Chat.e.viewMessages.querySelectorAll(
1703 '.message-widget.error-connection'));
1704 };
1705
1706 /* Assume the connection has been established, reset
1707 the Chat.timer.tidReconnect, and alert the user
1708 that the outage appears to be over. */
1709 const reportConnectionReestablished = function(){
1710 if( Chat.timer.tidReconnect ){
1711 clearTimeout(Chat.timer.tidReconnect);
1712 Chat.timer.tidReconnect = 0;
1713 }
1714 if( Chat.timer.isDelayed() ){
1715 removeConnectionErrors();
1716 Chat.timer.resetDelay();
1717 Chat.reportReconnection(
1718 "Connection restored after outage."
1719 );
1720 setTimeout( Chat.poll, 0 );
1721 }
 
 
1722 };
1723
1724 /* To be called from F.fetch() beforesend() handlers. If we're
1725 currently in delayed-retry mode and a connection is start, try to
1726 reset the delay after N time waiting on that connection. The fact
1727 that the connection is waiting to respond, rather than outright
1728 failing, is a good hint that the outage is over and we can reset
1729 the back-off timer. */
1730 const setupConnectionReestablished = function(){
1731 if( !Chat.timer.tidReconnect && Chat.timer.isDelayed() ){
1732 Chat.timer.tidReconnect = setTimeout(()=>{
1733 Chat.timer.tidReconnect = 0;
1734 if( poll.running ){
1735 reportConnectionReestablished();
 
 
 
1736 }
1737 }, Chat.timer.$initialDelay * 5 );
1738 removeConnectionErrors();
1739 }
1740 };
1741
1742 /**
1743 Submits the contents of the message input field (if not empty)
@@ -2582,32 +2588,34 @@
2582 Chat.reportErrorAsMessage(
2583 "Shutting down chat poller due to server-side error. ",
2584 "Reload this page to reactivate it."
2585 );
2586 Chat.timer.tidPoller = undefined;
2587 poll.running = false;
2588 } else {
2589 poll.running = false;
2590 if( !err ){
2591 /* Restart the poller. */
2592 Chat.timer.tidPoller = setTimeout(
2593 poll, Chat.timer.resetDelay()
2594 );
2595 }else{
2596 /* Delay a while before trying again, noting that other Chat
2597 APIs may try and succeed at connections before this timer
2598 resolves. */
 
2599 const delay = Chat.timer.incrDelay();
2600 const msg = D.addClass(
2601 Chat.reportErrorAsMessage(
2602 "Connection error. Retrying in ",
2603 delay, " ms."
2604 ).e.body,
2605 'error-connection'
2606 );
 
 
 
2607 Chat.timer.tidPoller = setTimeout(()=>{
2608 D.remove(msg);
2609 poll();
2610 }, delay);
2611 }
2612 //console.log("isOkay =",isOkay,"currentDelay =",Chat.timer.currentDelay);
2613 }
@@ -2627,42 +2635,78 @@
2627 xhrRequest.status value to do so, with status==0 being a
2628 connection error. We do not currently have a clean way of passing
2629 that info back to the fossil.fetch() client, so we'll need to
2630 hammer on that API a bit to get this working.
2631 */
2632 const poll = async function f(){
2633 if(f.running) return;
2634 f.running = true;
2635 Chat._isBatchLoading = f.isFirstCall;
2636 if(true===f.isFirstCall){
2637 f.isFirstCall = false;
 
2638 Chat.ajaxStart();
2639 Chat.e.viewMessages.classList.add('loading');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2640 }
 
2641 F.fetch("chat-poll",{
2642 timeout: 420 * 1000/*FIXME: get the value from the server*/,
2643 //timeout: 8000,
2644 urlParams:{
2645 name: Chat.mxMsg
2646 },
2647 responseType: "json",
2648 // Disable the ajax start/end handling for this long-polling op:
2649 beforesend: function(){
2650 setupConnectionReestablished();
2651 },
2652 aftersend: function(){
 
2653 },
2654 onerror:function(err){
2655 Chat._isBatchLoading = false;
2656 if(Chat.verboseErrors) console.error("poll onerror:",err);
2657 /* ^^^ we don't use Chat.reportError() here b/c the polling
2658 fails exepectedly when it times out, but is then immediately
2659 resumed, and reportError() produces a loud error message. */
2660 afterPollFetch(err);
2661 },
2662 onload:function(y){
2663 reportConnectionReestablished();
2664 newcontent(y);
2665 if(Chat._isBatchLoading){
2666 Chat._isBatchLoading = false;
2667 Chat.updateActiveUserList();
2668 }
@@ -2669,11 +2713,10 @@
2669 afterPollFetch();
2670 }
2671 });
2672 };
2673 poll.isFirstCall = true;
2674 Chat.poll = poll;
2675 Chat._gotServerError = poll.running = false;
2676 if( window.fossil.config.chat.fromcli ){
2677 Chat.chatOnlyMode(true);
2678 }
2679 Chat.timer.tidPoller = setTimeout(poll, Chat.timer.resetDelay());
2680
--- src/fossil.page.chat.js
+++ src/fossil.page.chat.js
@@ -129,12 +129,13 @@
129 return resized;
130 })();
131 fossil.FRK = ForceResizeKludge/*for debugging*/;
132 const Chat = ForceResizeKludge.chat = (function(){
133 const cs = { // the "Chat" object (result of this function)
134 beVerbose: false /* if true then certain, mostly extraneous,
135 error messages and log messages may be sent
136 to the console. */,
137 playedBeep: false /* used for the beep-once setting */,
138 e:{/*map of certain DOM elements.*/
139 messageInjectPoint: E1('#message-inject-point'),
140 pageTitle: E1('head title'),
141 loadOlderToolbar: undefined /* the load-posts toolbar (dynamically created) */,
@@ -155,11 +156,12 @@
156 viewSearch: E1('#chat-search'),
157 searchContent: E1('#chat-search-content'),
158 btnPreview: E1('#chat-button-preview'),
159 views: document.querySelectorAll('.chat-view'),
160 activeUserListWrapper: E1('#chat-user-list-wrapper'),
161 activeUserList: E1('#chat-user-list'),
162 eMsgPollError: undefined /* current connection error MessageMidget */
163 },
164 me: F.user.name,
165 mxMsg: F.config.chat.initSize ? -F.config.chat.initSize : -50,
166 mnMsg: undefined/*lowest message ID we've seen so far (for history loading)*/,
167 pageIsActive: 'visible'===document.visibilityState,
@@ -195,32 +197,32 @@
197 */
198 timer:{
199 tidPoller: undefined /* poller timer */,
200 $initialDelay: 1000 /* initial polling interval (ms) */,
201 currentDelay: 1000 /* current polling interval */,
202 maxDelay: 60000 * 5 /* max interval when backing off for
203 connection errors */,
204 minDelay: 5000 /* minimum delay time */,
205 tidReconnect: undefined /*timer id for reconnection determination*/,
206 randomInterval: function(factor){
207 return Math.floor(Math.random() * factor);
208 },
209 incrDelay: function(){
210 if( this.maxDelay > this.currentDelay ){
211 if(this.currentDelay < this.minDelay){
212 this.currentDelay = this.minDelay + this.randomInterval(this.minDelay);
213 }else{
214 this.currentDelay = this.currentDelay*2 + this.randomInterval(this.currentDelay);
215 }
216 }
217 return this.currentDelay;
218 },
219 resetDelay: function(){
220 return this.currentDelay = this.$initialDelay;
221 },
222 isDelayed: function(){
223 return (this.currentDelay > this.$initialDelay) ? this.currentDelay : 0;
224 }
225 },
226 /**
227 Gets (no args) or sets (1 arg) the current input text field
228 value, taking into account single- vs multi-line input. The
@@ -272,11 +274,10 @@
274 */
275 ajaxStart: function(){
276 if(1===++this.ajaxInflight){
277 this.enableAjaxComponents(false);
278 }
 
279 },
280 /* Must be called after any ajax-related call for which
281 ajaxStart() was called, regardless of success or failure. If
282 it was the last such call (as measured by calls to
283 ajaxStart() and ajaxEnd()), elements disabled by a prior call
@@ -697,11 +698,11 @@
698 */
699 cs.reportErrorAsMessage = function f(/*msg args*/){
700 const args = argsToArray(arguments).map(function(v){
701 return (v instanceof Error) ? v.message : v;
702 });
703 if(Chat.beVerbose){
704 console.error("chat error:",args);
705 }
706 const d = new Date().toISOString(),
707 mw = new this.MessageWidget({
708 isError: true,
@@ -733,10 +734,11 @@
734 lmtime: d,
735 xmsg: args
736 });
737 this.injectMessageElem(mw.e.body);
738 mw.scrollIntoView();
739 //Chat.playNewMessageSound();// browser complains b/c this wasn't via human interaction
740 return mw;
741 };
742
743 cs.getMessageElemById = function(id){
744 return qs('[data-msgid="'+id+'"]');
@@ -842,11 +844,11 @@
844 const self = this;
845 F.fetch('chat-fetch-one',{
846 urlParams:{ name: id, raw: true},
847 responseType: 'json',
848 onload: function(msg){
849 reportConnectionReestablished('chat-fetch-one');
850 content.$elems[1] = D.append(D.pre(),msg.xmsg);
851 content.$elems[1]._xmsgRaw = msg.xmsg/*used for copy-to-clipboard feature*/;
852 self.toggleTextMode(e);
853 },
854 aftersend:function(){
@@ -905,11 +907,11 @@
907 if(this.userMayDelete(e)){
908 this.ajaxStart();
909 F.fetch("chat-delete/" + id, {
910 responseType: 'json',
911 onload:(r)=>{
912 reportConnectionReestablished('chat-delete');
913 this.deleteMessageElem(r);
914 },
915 onerror:(err)=>this.reportErrorAsMessage(err)
916 });
917 }else{
@@ -1527,11 +1529,11 @@
1529 n: nFetch,
1530 i: iFirst
1531 },
1532 responseType: "json",
1533 onload:function(jx){
1534 reportConnectionReestablished('chat-query.onload');
1535 if( bDown ) jx.msgs.reverse();
1536 jx.msgs.forEach((m) => {
1537 m.isSearchResult = true;
1538 var mw = new Chat.MessageWidget(m);
1539 if( bDown ){
@@ -1696,48 +1698,52 @@
1698 if(theMsg) Chat.deleteMessageElem(theMsg);
1699 }));
1700 Chat.reportErrorAsMessage(w);
1701 };
1702
1703 /* Assume the connection has been established, reset the
1704 Chat.timer.tidReconnect, and (if showMsg and
1705 !!Chat.e.eMsgPollError) alert the user that the outage appears to
1706 be over. */
1707 const reportConnectionReestablished = function(dbgContext, showMsg = true){
1708 if(Chat.beVerbose){
1709 console.warn("reportConnectionReestablished()",
1710 dbgContext, showMsg, Chat.timer.tidReconnect, Chat.e.eMsgPollError);
1711 }
1712 if( Chat.timer.tidReconnect ){
1713 clearTimeout(Chat.timer.tidReconnect);
1714 Chat.timer.tidReconnect = 0;
1715 }
1716 Chat.timer.resetDelay();
1717 if( Chat.e.eMsgPollError ) {
1718 Chat.e.eMsgPollError = undefined;
1719 if( showMsg ){
1720 Chat.reportReconnection(
1721 "Poller connection restored."
1722 );
1723 }
1724 }
1725 setTimeout( Chat.poll, 0 );
1726 };
1727
1728 /* To be called from F.fetch('chat-poll') beforesend() handlers. If we're
1729 currently in delayed-retry mode and a connection is started, try
1730 to reset the delay after N time waiting on that connection. The
1731 fact that the connection is waiting to respond, rather than
1732 outright failing, is a good hint that the outage is over and we
1733 can reset the back-off timer. */
1734 const clearPollErrOnWait = function(){
1735 if( !Chat.timer.tidReconnect && Chat.timer.isDelayed() ){
1736 Chat.timer.tidReconnect = setTimeout(()=>{
1737 Chat.timer.tidReconnect = 0;
1738 if( poll.running ){
1739 /* This chat-poll F.fetch() is still underway, so let's
1740 assume the connection is back up until/unless it times
1741 out or breaks again. */
1742 reportConnectionReestablished('clearPollErrOnWait');
1743 }
1744 }, Chat.timer.$initialDelay * 3 );
 
1745 }
1746 };
1747
1748 /**
1749 Submits the contents of the message input field (if not empty)
@@ -2582,32 +2588,34 @@
2588 Chat.reportErrorAsMessage(
2589 "Shutting down chat poller due to server-side error. ",
2590 "Reload this page to reactivate it."
2591 );
2592 Chat.timer.tidPoller = undefined;
 
2593 } else {
2594 if( err && Chat.beVerbose ){
2595 console.error("afterPollFetch:",err.name,err.status,err.message);
2596 }
2597 if( !err || 'timeout'===err.name/*(probably) long-poll expired*/ ){
2598 /* Restart the poller immediately. */
2599 reportConnectionReestablished('afterPollFetch '+err, false);
2600 }else{
2601 /* Delay a while before trying again, noting that other Chat
2602 APIs may try and succeed at connections before this timer
2603 resolves, in which case they'll clear this timeout and the
2604 UI message about the outage. */
2605 const delay = Chat.timer.incrDelay();
2606 //console.warn("afterPollFetch Chat.e.eMsgPollError",Chat.e.eMsgPollError);
2607 const msg = "Connection error. Retrying in "+delay+ " ms.";
2608 if( Chat.e.eMsgPollError ){
2609 /* Update the error message on the current error MessageWidget */
2610 Chat.e.eMsgPollError.e.content.innerText = msg;
2611 }else {
2612 /* Set current (new) error MessageWidget */
2613 Chat.e.eMsgPollError = Chat.reportErrorAsMessage(msg);
2614 //Chat.playNewMessageSound();// browser complains b/c this wasn't via human interaction
2615 }
2616 Chat.timer.tidPoller = setTimeout(()=>{
 
2617 poll();
2618 }, delay);
2619 }
2620 //console.log("isOkay =",isOkay,"currentDelay =",Chat.timer.currentDelay);
2621 }
@@ -2627,42 +2635,78 @@
2635 xhrRequest.status value to do so, with status==0 being a
2636 connection error. We do not currently have a clean way of passing
2637 that info back to the fossil.fetch() client, so we'll need to
2638 hammer on that API a bit to get this working.
2639 */
2640 const poll = Chat.poll = async function f(){
2641 if(f.running) return;
2642 f.running = true;
2643 Chat._isBatchLoading = f.isFirstCall;
2644 if(true===f.isFirstCall){
2645 f.isFirstCall = false;
2646 Chat.aPollErr = [];
2647 Chat.ajaxStart();
2648 Chat.e.viewMessages.classList.add('loading');
2649 setInterval(
2650 /*
2651 We manager onerror() results in poll() using a
2652 stack of error objects and we delay their handling by
2653 a small amount, rather than immediately when the
2654 exception arrives.
2655
2656 This level of indirection is to work around an
2657 inexplicable behavior from the F.fetch() connections:
2658 timeouts are always announced in pairs of an HTTP 0 and
2659 something we can unambiguously identify as a timeout. When
2660 that happens, we ignore the HTTP 0. If, however, an HTTP 0
2661 is seen here without an immediately-following timeout, we
2662 process it.
2663
2664 It's kinda like in the curses C API, where you to match
2665 ALT-X by first getting an ALT event, then a separate X
2666 event, but a lot less explicable.
2667 */
2668 ()=>{
2669 if( Chat.aPollErr.length ){
2670 if(Chat.aPollErr.length>1){
2671 console.warn('aPollErr',Chat.aPollErr);
2672 if(Chat.aPollErr[1].name='timeout'){
2673 /* mysterious pairs of HTTP 0 followed immediately
2674 by timeout response; ignore the former in that case. */
2675 Chat.aPollErr.shift();
2676 }
2677 }
2678 afterPollFetch(Chat.aPollErr.shift());
2679 }
2680 },
2681 1000
2682 );
2683 }
2684 let nErr = 0;
2685 F.fetch("chat-poll",{
2686 timeout: //420 * 1000/*FIXME: get the value from the server*/,
2687 15000,
2688 urlParams:{
2689 name: Chat.mxMsg
2690 },
2691 responseType: "json",
2692 // Disable the ajax start/end handling for this long-polling op:
2693 beforesend: function(){
2694 clearPollErrOnWait();
2695 },
2696 aftersend: function(){
2697 poll.running = false;
2698 },
2699 onerror:function(err){
2700 Chat._isBatchLoading = false;
2701 if(Chat.beVerbose){
2702 console.error("poll.onerror:",err.name,err.status,JSON.stringify(err));
2703 }
2704 Chat.aPollErr.push(err);
 
2705 },
2706 onload:function(y){
2707 reportConnectionReestablished('poll.onload', true);
2708 newcontent(y);
2709 if(Chat._isBatchLoading){
2710 Chat._isBatchLoading = false;
2711 Chat.updateActiveUserList();
2712 }
@@ -2669,11 +2713,10 @@
2713 afterPollFetch();
2714 }
2715 });
2716 };
2717 poll.isFirstCall = true;
 
2718 Chat._gotServerError = poll.running = false;
2719 if( window.fossil.config.chat.fromcli ){
2720 Chat.chatOnlyMode(true);
2721 }
2722 Chat.timer.tidPoller = setTimeout(poll, Chat.timer.resetDelay());
2723
--- src/fossil.popupwidget.js
+++ src/fossil.popupwidget.js
@@ -286,11 +286,11 @@
286286
};
287287
288288
F.toast = {
289289
config: {
290290
position: { x: 5, y: 5 /*viewport-relative, pixels*/ },
291
- displayTimeMs: 3000
291
+ displayTimeMs: 5000
292292
},
293293
/**
294294
Convenience wrapper around a PopupWidget which pops up a shared
295295
PopupWidget instance to show toast-style messages (commonly
296296
seen on Android). Its arguments may be anything suitable for
297297
--- src/fossil.popupwidget.js
+++ src/fossil.popupwidget.js
@@ -286,11 +286,11 @@
286 };
287
288 F.toast = {
289 config: {
290 position: { x: 5, y: 5 /*viewport-relative, pixels*/ },
291 displayTimeMs: 3000
292 },
293 /**
294 Convenience wrapper around a PopupWidget which pops up a shared
295 PopupWidget instance to show toast-style messages (commonly
296 seen on Android). Its arguments may be anything suitable for
297
--- src/fossil.popupwidget.js
+++ src/fossil.popupwidget.js
@@ -286,11 +286,11 @@
286 };
287
288 F.toast = {
289 config: {
290 position: { x: 5, y: 5 /*viewport-relative, pixels*/ },
291 displayTimeMs: 5000
292 },
293 /**
294 Convenience wrapper around a PopupWidget which pops up a shared
295 PopupWidget instance to show toast-style messages (commonly
296 seen on Android). Its arguments may be anything suitable for
297

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button