Fossil SCM
If /chat's poller cannot connect to the server, apply a back-off timer so that it does not keep hammering the remote every single second. It attempts to inform the user about outages and when reconnection has succeeded, but it's difficult to test the timing of the the UI elements thoroughly with a single pair of hands, so this is being checked in for dogfooding.
Commit
2debc54e67ec71fa66f0a6b025713e97734cbfd5c65eed23dd77d54af1ef2a33
Parent
3af3ffdf443e77e…
2 files changed
+1
-1
+151
-17
+1
-1
| --- src/fossil.dom.js | ||
| +++ src/fossil.dom.js | ||
| @@ -21,11 +21,11 @@ | ||
| 21 | 21 | remove: function(e){ |
| 22 | 22 | if(e.forEach){ |
| 23 | 23 | e.forEach( |
| 24 | 24 | (x)=>x.parentNode.removeChild(x) |
| 25 | 25 | ); |
| 26 | - }else{ | |
| 26 | + }else if(e.parentNode){ | |
| 27 | 27 | e.parentNode.removeChild(e); |
| 28 | 28 | } |
| 29 | 29 | return e; |
| 30 | 30 | }, |
| 31 | 31 | /** |
| 32 | 32 |
| --- src/fossil.dom.js | |
| +++ src/fossil.dom.js | |
| @@ -21,11 +21,11 @@ | |
| 21 | remove: function(e){ |
| 22 | if(e.forEach){ |
| 23 | e.forEach( |
| 24 | (x)=>x.parentNode.removeChild(x) |
| 25 | ); |
| 26 | }else{ |
| 27 | e.parentNode.removeChild(e); |
| 28 | } |
| 29 | return e; |
| 30 | }, |
| 31 | /** |
| 32 |
| --- src/fossil.dom.js | |
| +++ src/fossil.dom.js | |
| @@ -21,11 +21,11 @@ | |
| 21 | remove: function(e){ |
| 22 | if(e.forEach){ |
| 23 | e.forEach( |
| 24 | (x)=>x.parentNode.removeChild(x) |
| 25 | ); |
| 26 | }else if(e.parentNode){ |
| 27 | e.parentNode.removeChild(e); |
| 28 | } |
| 29 | return e; |
| 30 | }, |
| 31 | /** |
| 32 |
+151
-17
| --- src/fossil.page.chat.js | ||
| +++ src/fossil.page.chat.js | ||
| @@ -180,10 +180,45 @@ | ||
| 180 | 180 | activeUser: undefined, |
| 181 | 181 | match: function(uname){ |
| 182 | 182 | return this.activeUser===uname || !this.activeUser; |
| 183 | 183 | } |
| 184 | 184 | }, |
| 185 | + /** | |
| 186 | + The timer object is used to control connection throttling | |
| 187 | + when connection errors arrise. It starts off with a polling | |
| 188 | + delay of $initialDelay ms. If there's a connection error, | |
| 189 | + that gets bumped by some value for each subsequent error, up | |
| 190 | + to some max value. | |
| 191 | + | |
| 192 | + The timeing of resetting the delay when service returns is, | |
| 193 | + because of the long-poll connection and our lack of low-level | |
| 194 | + insight into the connection at this level, a bit wonky. | |
| 195 | + */ | |
| 196 | + timer:{ | |
| 197 | + id: undefined, | |
| 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: 7000 /* minimum delay time */, | |
| 203 | + idReconnect: undefined /*timer id for reconnection determination*/, | |
| 204 | + incrDelay: function(){ | |
| 205 | + if( this.maxDelay > this.currentDelay ){ | |
| 206 | + this.currentDelay = | |
| 207 | + (this.currentDelay < this.minDelay) | |
| 208 | + ? this.minDelay | |
| 209 | + : (this.currentDelay * 3); | |
| 210 | + } | |
| 211 | + return this.currentDelay; | |
| 212 | + }, | |
| 213 | + resetDelay: function(){ | |
| 214 | + return this.currentDelay = this.$initialDelay; | |
| 215 | + }, | |
| 216 | + isDelayed: function(){ | |
| 217 | + return this.currentDelay > this.$initialDelay; | |
| 218 | + } | |
| 219 | + }, | |
| 185 | 220 | /** |
| 186 | 221 | Gets (no args) or sets (1 arg) the current input text field |
| 187 | 222 | value, taking into account single- vs multi-line input. The |
| 188 | 223 | getter returns a trim()'d string and the setter returns this |
| 189 | 224 | object. As a special case, if arguments[0] is a boolean |
| @@ -606,11 +641,11 @@ | ||
| 606 | 641 | |
| 607 | 642 | /** |
| 608 | 643 | If animations are enabled, passes its arguments |
| 609 | 644 | to D.addClassBriefly(), else this is a no-op. |
| 610 | 645 | If cb is a function, it is called after the |
| 611 | - CSS class is removed. Returns this object; | |
| 646 | + CSS class is removed. Returns this object; | |
| 612 | 647 | */ |
| 613 | 648 | animate: function f(e,a,cb){ |
| 614 | 649 | if(!f.$disabled){ |
| 615 | 650 | D.addClassBriefly(e, a, 0, cb); |
| 616 | 651 | } |
| @@ -645,33 +680,59 @@ | ||
| 645 | 680 | cs.reportError = function(/*msg args*/){ |
| 646 | 681 | const args = argsToArray(arguments); |
| 647 | 682 | console.error("chat error:",args); |
| 648 | 683 | F.toast.error.apply(F.toast, args); |
| 649 | 684 | }; |
| 685 | + | |
| 686 | + let InternalMsgId = 0; | |
| 650 | 687 | /** |
| 651 | 688 | Reports an error in the form of a new message in the chat |
| 652 | 689 | feed. All arguments are appended to the message's content area |
| 653 | 690 | using fossil.dom.append(), so may be of any type supported by |
| 654 | 691 | that function. |
| 655 | 692 | */ |
| 656 | 693 | cs.reportErrorAsMessage = function f(/*msg args*/){ |
| 657 | - if(undefined === f.$msgid) f.$msgid=0; | |
| 658 | 694 | const args = argsToArray(arguments).map(function(v){ |
| 659 | 695 | return (v instanceof Error) ? v.message : v; |
| 660 | 696 | }); |
| 661 | - console.error("chat error:",args); | |
| 697 | + if(Chat.verboseErrors){ | |
| 698 | + console.error("chat error:",args); | |
| 699 | + } | |
| 662 | 700 | const d = new Date().toISOString(), |
| 663 | 701 | mw = new this.MessageWidget({ |
| 664 | 702 | isError: true, |
| 665 | - xfrom: null, | |
| 666 | - msgid: "error-"+(++f.$msgid), | |
| 703 | + xfrom: undefined, | |
| 704 | + msgid: "error-"+(++InternalMsgId), | |
| 705 | + mtime: d, | |
| 706 | + lmtime: d, | |
| 707 | + xmsg: args | |
| 708 | + }); | |
| 709 | + this.injectMessageElem(mw.e.body); | |
| 710 | + mw.scrollIntoView(); | |
| 711 | + return mw; | |
| 712 | + }; | |
| 713 | + | |
| 714 | + /** | |
| 715 | + For use by the connection poller to send a "connection | |
| 716 | + restored" message. | |
| 717 | + */ | |
| 718 | + cs.reportReconnection = function f(/*msg args*/){ | |
| 719 | + const args = argsToArray(arguments).map(function(v){ | |
| 720 | + return (v instanceof Error) ? v.message : v; | |
| 721 | + }); | |
| 722 | + const d = new Date().toISOString(), | |
| 723 | + mw = new this.MessageWidget({ | |
| 724 | + isError: false, | |
| 725 | + xfrom: undefined, | |
| 726 | + msgid: "reconnect-"+(++InternalMsgId), | |
| 667 | 727 | mtime: d, |
| 668 | 728 | lmtime: d, |
| 669 | 729 | xmsg: args |
| 670 | 730 | }); |
| 671 | 731 | this.injectMessageElem(mw.e.body); |
| 672 | 732 | mw.scrollIntoView(); |
| 733 | + return mw; | |
| 673 | 734 | }; |
| 674 | 735 | |
| 675 | 736 | cs.getMessageElemById = function(id){ |
| 676 | 737 | return qs('[data-msgid="'+id+'"]'); |
| 677 | 738 | }; |
| @@ -1624,10 +1685,45 @@ | ||
| 1624 | 1685 | const theMsg = findMessageWidgetParent(w); |
| 1625 | 1686 | if(theMsg) Chat.deleteMessageElem(theMsg); |
| 1626 | 1687 | })); |
| 1627 | 1688 | Chat.reportErrorAsMessage(w); |
| 1628 | 1689 | }; |
| 1690 | + | |
| 1691 | + /* Assume the connection has been established, reset | |
| 1692 | + the Chat.timer.idReconnect, and alert the user | |
| 1693 | + that the outage appears to be over. */ | |
| 1694 | + const reportConnectionReestablished = function(){ | |
| 1695 | + if( Chat.timer.idReconnect ){ | |
| 1696 | + clearTimeout(Chat.timer.idReconnect); | |
| 1697 | + Chat.timer.idReconnect = 0; | |
| 1698 | + } | |
| 1699 | + if( Chat.timer.isDelayed() ){ | |
| 1700 | + Chat.timer.resetDelay(); | |
| 1701 | + Chat.reportReconnection( | |
| 1702 | + "Connection restored after outage." | |
| 1703 | + ); | |
| 1704 | + setTimeout( Chat.poll, 0 ); | |
| 1705 | + } | |
| 1706 | + }; | |
| 1707 | + | |
| 1708 | + /* If we're currently in delayed-retry mode, try to reset the delay | |
| 1709 | + if we're waiting for a while for the connection to complete, | |
| 1710 | + as that's an indication (not a guaranty) that we're connected | |
| 1711 | + and long-polling. */ | |
| 1712 | + const setupConnectionReestablished = function(){ | |
| 1713 | + if( !Chat.timer.idReconnect && Chat.timer.isDelayed() ){ | |
| 1714 | + Chat.timer.idReconnect = setTimeout(()=>{ | |
| 1715 | + Chat.timer.idReconnect = 0; | |
| 1716 | + if( poll.running ){ | |
| 1717 | + reportConnectionReestablished(); | |
| 1718 | + } | |
| 1719 | + }, Chat.timer.$initialDelay * 5 ); | |
| 1720 | + Chat.e.viewMessages.querySelectorAll( | |
| 1721 | + '.message-widget.error-connection' | |
| 1722 | + ).forEach(e=>D.remove(e)); | |
| 1723 | + } | |
| 1724 | + }; | |
| 1629 | 1725 | |
| 1630 | 1726 | /** |
| 1631 | 1727 | Submits the contents of the message input field (if not empty) |
| 1632 | 1728 | and/or the file attachment field to the server. If both are |
| 1633 | 1729 | empty, this is a no-op. |
| @@ -1681,15 +1777,20 @@ | ||
| 1681 | 1777 | const self = this; |
| 1682 | 1778 | fd.set("lmtime", localTime8601(new Date())); |
| 1683 | 1779 | F.fetch("chat-send",{ |
| 1684 | 1780 | payload: fd, |
| 1685 | 1781 | responseType: 'text', |
| 1782 | + beforesend: function(){ | |
| 1783 | + Chat.ajaxStart(); | |
| 1784 | + setupConnectionReestablished(); | |
| 1785 | + }, | |
| 1686 | 1786 | onerror:function(err){ |
| 1687 | 1787 | self.reportErrorAsMessage(err); |
| 1688 | 1788 | recoverFailedMessage(fallback); |
| 1689 | 1789 | }, |
| 1690 | 1790 | onload:function(txt){ |
| 1791 | + reportConnectionReestablished(); | |
| 1691 | 1792 | if(!txt) return/*success response*/; |
| 1692 | 1793 | try{ |
| 1693 | 1794 | const json = JSON.parse(txt); |
| 1694 | 1795 | self.newContent({msgs:[json]}); |
| 1695 | 1796 | }catch(e){ |
| @@ -2444,29 +2545,56 @@ | ||
| 2444 | 2545 | } |
| 2445 | 2546 | } |
| 2446 | 2547 | ); |
| 2447 | 2548 | }/*Chat.submitSearch()*/; |
| 2448 | 2549 | |
| 2449 | - const afterFetch = function f(){ | |
| 2550 | + /** | |
| 2551 | + Deal with the last poll() response and maybe re-start poll(). | |
| 2552 | + */ | |
| 2553 | + const afterPollFetch = function f(isOkay = true){ | |
| 2450 | 2554 | if(true===f.isFirstCall){ |
| 2451 | 2555 | f.isFirstCall = false; |
| 2452 | 2556 | Chat.ajaxEnd(); |
| 2453 | 2557 | Chat.e.viewMessages.classList.remove('loading'); |
| 2454 | 2558 | setTimeout(function(){ |
| 2455 | 2559 | Chat.scrollMessagesTo(1); |
| 2456 | 2560 | }, 250); |
| 2457 | 2561 | } |
| 2458 | - if(Chat._gotServerError && Chat.intervalTimer){ | |
| 2459 | - clearInterval(Chat.intervalTimer); | |
| 2562 | + if(Chat.timer.id) { | |
| 2563 | + clearTimeout(Chat.timer.id); | |
| 2564 | + Chat.timer.id = 0; | |
| 2565 | + } | |
| 2566 | + if(Chat._gotServerError){ | |
| 2460 | 2567 | Chat.reportErrorAsMessage( |
| 2461 | 2568 | "Shutting down chat poller due to server-side error. ", |
| 2462 | 2569 | "Reload this page to reactivate it."); |
| 2463 | - delete Chat.intervalTimer; | |
| 2570 | + Chat.timer.id = undefined; | |
| 2571 | + poll.running = false; | |
| 2572 | + } else { | |
| 2573 | + poll.running = false; | |
| 2574 | + if( isOkay ){ | |
| 2575 | + Chat.timer.id = setTimeout( | |
| 2576 | + poll, Chat.timer.resetDelay() | |
| 2577 | + ); | |
| 2578 | + }else{ | |
| 2579 | + const delay = Chat.timer.incrDelay(); | |
| 2580 | + const msg = D.addClass( | |
| 2581 | + Chat.reportErrorAsMessage( | |
| 2582 | + "Connection error. Retrying in ", | |
| 2583 | + delay, " ms.").e.body, | |
| 2584 | + 'error-connection' | |
| 2585 | + ); | |
| 2586 | + Chat.timer.id = setTimeout(()=>{ | |
| 2587 | + D.remove(msg); | |
| 2588 | + poll(); | |
| 2589 | + }, delay); | |
| 2590 | + } | |
| 2591 | + //console.log("isOkay =",isOkay,"currentDelay =",Chat.timer.currentDelay); | |
| 2464 | 2592 | } |
| 2465 | - poll.running = false; | |
| 2466 | 2593 | }; |
| 2467 | - afterFetch.isFirstCall = true; | |
| 2594 | + afterPollFetch.isFirstCall = true; | |
| 2595 | + | |
| 2468 | 2596 | /** |
| 2469 | 2597 | FIXME: when polling fails because the remote server is |
| 2470 | 2598 | reachable but it's not accepting HTTP requests, we should back |
| 2471 | 2599 | off on polling for a while. e.g. if the remote web server process |
| 2472 | 2600 | is killed, the poll fails quickly and immediately retries, |
| @@ -2489,42 +2617,48 @@ | ||
| 2489 | 2617 | Chat.ajaxStart(); |
| 2490 | 2618 | Chat.e.viewMessages.classList.add('loading'); |
| 2491 | 2619 | } |
| 2492 | 2620 | F.fetch("chat-poll",{ |
| 2493 | 2621 | timeout: 420 * 1000/*FIXME: get the value from the server*/, |
| 2622 | + //timeout: 8000, | |
| 2494 | 2623 | urlParams:{ |
| 2495 | 2624 | name: Chat.mxMsg |
| 2496 | 2625 | }, |
| 2497 | 2626 | responseType: "json", |
| 2498 | 2627 | // Disable the ajax start/end handling for this long-polling op: |
| 2499 | - beforesend: function(){}, | |
| 2500 | - aftersend: function(){}, | |
| 2628 | + beforesend: function(){ | |
| 2629 | + setupConnectionReestablished(); | |
| 2630 | + }, | |
| 2631 | + aftersend: function(){ | |
| 2632 | + }, | |
| 2501 | 2633 | onerror:function(err){ |
| 2502 | 2634 | Chat._isBatchLoading = false; |
| 2503 | - if(Chat.verboseErrors) console.error(err); | |
| 2635 | + if(Chat.verboseErrors) console.error("poll onerror:",err); | |
| 2504 | 2636 | /* ^^^ we don't use Chat.reportError() here b/c the polling |
| 2505 | 2637 | fails exepectedly when it times out, but is then immediately |
| 2506 | 2638 | resumed, and reportError() produces a loud error message. */ |
| 2507 | - afterFetch(); | |
| 2639 | + afterPollFetch(false); | |
| 2508 | 2640 | }, |
| 2509 | 2641 | onload:function(y){ |
| 2642 | + reportConnectionReestablished(); | |
| 2510 | 2643 | newcontent(y); |
| 2511 | 2644 | if(Chat._isBatchLoading){ |
| 2512 | 2645 | Chat._isBatchLoading = false; |
| 2513 | 2646 | Chat.updateActiveUserList(); |
| 2514 | 2647 | } |
| 2515 | - afterFetch(); | |
| 2648 | + afterPollFetch(true); | |
| 2516 | 2649 | } |
| 2517 | 2650 | }); |
| 2518 | 2651 | }; |
| 2519 | 2652 | poll.isFirstCall = true; |
| 2653 | + Chat.poll = poll; | |
| 2520 | 2654 | Chat._gotServerError = poll.running = false; |
| 2521 | 2655 | if( window.fossil.config.chat.fromcli ){ |
| 2522 | 2656 | Chat.chatOnlyMode(true); |
| 2523 | 2657 | } |
| 2524 | - Chat.intervalTimer = setInterval(poll, 1000); | |
| 2658 | + Chat.timer.id = setTimeout(poll, Chat.timer.resetDelay()); | |
| 2525 | 2659 | delete ForceResizeKludge.$disabled; |
| 2526 | 2660 | ForceResizeKludge(); |
| 2527 | 2661 | Chat.animate.$disabled = false; |
| 2528 | 2662 | setTimeout( ()=>Chat.inputFocus(), 0 ); |
| 2529 | 2663 | F.page.chat = Chat/* enables testing the APIs via the dev tools */; |
| 2530 | 2664 | }); |
| 2531 | 2665 |
| --- src/fossil.page.chat.js | |
| +++ src/fossil.page.chat.js | |
| @@ -180,10 +180,45 @@ | |
| 180 | activeUser: undefined, |
| 181 | match: function(uname){ |
| 182 | return this.activeUser===uname || !this.activeUser; |
| 183 | } |
| 184 | }, |
| 185 | /** |
| 186 | Gets (no args) or sets (1 arg) the current input text field |
| 187 | value, taking into account single- vs multi-line input. The |
| 188 | getter returns a trim()'d string and the setter returns this |
| 189 | object. As a special case, if arguments[0] is a boolean |
| @@ -606,11 +641,11 @@ | |
| 606 | |
| 607 | /** |
| 608 | If animations are enabled, passes its arguments |
| 609 | to D.addClassBriefly(), else this is a no-op. |
| 610 | If cb is a function, it is called after the |
| 611 | CSS class is removed. Returns this object; |
| 612 | */ |
| 613 | animate: function f(e,a,cb){ |
| 614 | if(!f.$disabled){ |
| 615 | D.addClassBriefly(e, a, 0, cb); |
| 616 | } |
| @@ -645,33 +680,59 @@ | |
| 645 | cs.reportError = function(/*msg args*/){ |
| 646 | const args = argsToArray(arguments); |
| 647 | console.error("chat error:",args); |
| 648 | F.toast.error.apply(F.toast, args); |
| 649 | }; |
| 650 | /** |
| 651 | Reports an error in the form of a new message in the chat |
| 652 | feed. All arguments are appended to the message's content area |
| 653 | using fossil.dom.append(), so may be of any type supported by |
| 654 | that function. |
| 655 | */ |
| 656 | cs.reportErrorAsMessage = function f(/*msg args*/){ |
| 657 | if(undefined === f.$msgid) f.$msgid=0; |
| 658 | const args = argsToArray(arguments).map(function(v){ |
| 659 | return (v instanceof Error) ? v.message : v; |
| 660 | }); |
| 661 | console.error("chat error:",args); |
| 662 | const d = new Date().toISOString(), |
| 663 | mw = new this.MessageWidget({ |
| 664 | isError: true, |
| 665 | xfrom: null, |
| 666 | msgid: "error-"+(++f.$msgid), |
| 667 | mtime: d, |
| 668 | lmtime: d, |
| 669 | xmsg: args |
| 670 | }); |
| 671 | this.injectMessageElem(mw.e.body); |
| 672 | mw.scrollIntoView(); |
| 673 | }; |
| 674 | |
| 675 | cs.getMessageElemById = function(id){ |
| 676 | return qs('[data-msgid="'+id+'"]'); |
| 677 | }; |
| @@ -1624,10 +1685,45 @@ | |
| 1624 | const theMsg = findMessageWidgetParent(w); |
| 1625 | if(theMsg) Chat.deleteMessageElem(theMsg); |
| 1626 | })); |
| 1627 | Chat.reportErrorAsMessage(w); |
| 1628 | }; |
| 1629 | |
| 1630 | /** |
| 1631 | Submits the contents of the message input field (if not empty) |
| 1632 | and/or the file attachment field to the server. If both are |
| 1633 | empty, this is a no-op. |
| @@ -1681,15 +1777,20 @@ | |
| 1681 | const self = this; |
| 1682 | fd.set("lmtime", localTime8601(new Date())); |
| 1683 | F.fetch("chat-send",{ |
| 1684 | payload: fd, |
| 1685 | responseType: 'text', |
| 1686 | onerror:function(err){ |
| 1687 | self.reportErrorAsMessage(err); |
| 1688 | recoverFailedMessage(fallback); |
| 1689 | }, |
| 1690 | onload:function(txt){ |
| 1691 | if(!txt) return/*success response*/; |
| 1692 | try{ |
| 1693 | const json = JSON.parse(txt); |
| 1694 | self.newContent({msgs:[json]}); |
| 1695 | }catch(e){ |
| @@ -2444,29 +2545,56 @@ | |
| 2444 | } |
| 2445 | } |
| 2446 | ); |
| 2447 | }/*Chat.submitSearch()*/; |
| 2448 | |
| 2449 | const afterFetch = function f(){ |
| 2450 | if(true===f.isFirstCall){ |
| 2451 | f.isFirstCall = false; |
| 2452 | Chat.ajaxEnd(); |
| 2453 | Chat.e.viewMessages.classList.remove('loading'); |
| 2454 | setTimeout(function(){ |
| 2455 | Chat.scrollMessagesTo(1); |
| 2456 | }, 250); |
| 2457 | } |
| 2458 | if(Chat._gotServerError && Chat.intervalTimer){ |
| 2459 | clearInterval(Chat.intervalTimer); |
| 2460 | Chat.reportErrorAsMessage( |
| 2461 | "Shutting down chat poller due to server-side error. ", |
| 2462 | "Reload this page to reactivate it."); |
| 2463 | delete Chat.intervalTimer; |
| 2464 | } |
| 2465 | poll.running = false; |
| 2466 | }; |
| 2467 | afterFetch.isFirstCall = true; |
| 2468 | /** |
| 2469 | FIXME: when polling fails because the remote server is |
| 2470 | reachable but it's not accepting HTTP requests, we should back |
| 2471 | off on polling for a while. e.g. if the remote web server process |
| 2472 | is killed, the poll fails quickly and immediately retries, |
| @@ -2489,42 +2617,48 @@ | |
| 2489 | Chat.ajaxStart(); |
| 2490 | Chat.e.viewMessages.classList.add('loading'); |
| 2491 | } |
| 2492 | F.fetch("chat-poll",{ |
| 2493 | timeout: 420 * 1000/*FIXME: get the value from the server*/, |
| 2494 | urlParams:{ |
| 2495 | name: Chat.mxMsg |
| 2496 | }, |
| 2497 | responseType: "json", |
| 2498 | // Disable the ajax start/end handling for this long-polling op: |
| 2499 | beforesend: function(){}, |
| 2500 | aftersend: function(){}, |
| 2501 | onerror:function(err){ |
| 2502 | Chat._isBatchLoading = false; |
| 2503 | if(Chat.verboseErrors) console.error(err); |
| 2504 | /* ^^^ we don't use Chat.reportError() here b/c the polling |
| 2505 | fails exepectedly when it times out, but is then immediately |
| 2506 | resumed, and reportError() produces a loud error message. */ |
| 2507 | afterFetch(); |
| 2508 | }, |
| 2509 | onload:function(y){ |
| 2510 | newcontent(y); |
| 2511 | if(Chat._isBatchLoading){ |
| 2512 | Chat._isBatchLoading = false; |
| 2513 | Chat.updateActiveUserList(); |
| 2514 | } |
| 2515 | afterFetch(); |
| 2516 | } |
| 2517 | }); |
| 2518 | }; |
| 2519 | poll.isFirstCall = true; |
| 2520 | Chat._gotServerError = poll.running = false; |
| 2521 | if( window.fossil.config.chat.fromcli ){ |
| 2522 | Chat.chatOnlyMode(true); |
| 2523 | } |
| 2524 | Chat.intervalTimer = setInterval(poll, 1000); |
| 2525 | delete ForceResizeKludge.$disabled; |
| 2526 | ForceResizeKludge(); |
| 2527 | Chat.animate.$disabled = false; |
| 2528 | setTimeout( ()=>Chat.inputFocus(), 0 ); |
| 2529 | F.page.chat = Chat/* enables testing the APIs via the dev tools */; |
| 2530 | }); |
| 2531 |
| --- src/fossil.page.chat.js | |
| +++ src/fossil.page.chat.js | |
| @@ -180,10 +180,45 @@ | |
| 180 | activeUser: undefined, |
| 181 | match: function(uname){ |
| 182 | return this.activeUser===uname || !this.activeUser; |
| 183 | } |
| 184 | }, |
| 185 | /** |
| 186 | The timer object is used to control connection throttling |
| 187 | when connection errors arrise. It starts off with a polling |
| 188 | delay of $initialDelay ms. If there's a connection error, |
| 189 | that gets bumped by some value for each subsequent error, up |
| 190 | to some max value. |
| 191 | |
| 192 | The timeing of resetting the delay when service returns is, |
| 193 | because of the long-poll connection and our lack of low-level |
| 194 | insight into the connection at this level, a bit wonky. |
| 195 | */ |
| 196 | timer:{ |
| 197 | id: undefined, |
| 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: 7000 /* minimum delay time */, |
| 203 | idReconnect: undefined /*timer id for reconnection determination*/, |
| 204 | incrDelay: function(){ |
| 205 | if( this.maxDelay > this.currentDelay ){ |
| 206 | this.currentDelay = |
| 207 | (this.currentDelay < this.minDelay) |
| 208 | ? this.minDelay |
| 209 | : (this.currentDelay * 3); |
| 210 | } |
| 211 | return this.currentDelay; |
| 212 | }, |
| 213 | resetDelay: function(){ |
| 214 | return this.currentDelay = this.$initialDelay; |
| 215 | }, |
| 216 | isDelayed: function(){ |
| 217 | return this.currentDelay > this.$initialDelay; |
| 218 | } |
| 219 | }, |
| 220 | /** |
| 221 | Gets (no args) or sets (1 arg) the current input text field |
| 222 | value, taking into account single- vs multi-line input. The |
| 223 | getter returns a trim()'d string and the setter returns this |
| 224 | object. As a special case, if arguments[0] is a boolean |
| @@ -606,11 +641,11 @@ | |
| 641 | |
| 642 | /** |
| 643 | If animations are enabled, passes its arguments |
| 644 | to D.addClassBriefly(), else this is a no-op. |
| 645 | If cb is a function, it is called after the |
| 646 | CSS class is removed. Returns this object; |
| 647 | */ |
| 648 | animate: function f(e,a,cb){ |
| 649 | if(!f.$disabled){ |
| 650 | D.addClassBriefly(e, a, 0, cb); |
| 651 | } |
| @@ -645,33 +680,59 @@ | |
| 680 | cs.reportError = function(/*msg args*/){ |
| 681 | const args = argsToArray(arguments); |
| 682 | console.error("chat error:",args); |
| 683 | F.toast.error.apply(F.toast, args); |
| 684 | }; |
| 685 | |
| 686 | let InternalMsgId = 0; |
| 687 | /** |
| 688 | Reports an error in the form of a new message in the chat |
| 689 | feed. All arguments are appended to the message's content area |
| 690 | using fossil.dom.append(), so may be of any type supported by |
| 691 | that function. |
| 692 | */ |
| 693 | cs.reportErrorAsMessage = function f(/*msg args*/){ |
| 694 | const args = argsToArray(arguments).map(function(v){ |
| 695 | return (v instanceof Error) ? v.message : v; |
| 696 | }); |
| 697 | if(Chat.verboseErrors){ |
| 698 | console.error("chat error:",args); |
| 699 | } |
| 700 | const d = new Date().toISOString(), |
| 701 | mw = new this.MessageWidget({ |
| 702 | isError: true, |
| 703 | xfrom: undefined, |
| 704 | msgid: "error-"+(++InternalMsgId), |
| 705 | mtime: d, |
| 706 | lmtime: d, |
| 707 | xmsg: args |
| 708 | }); |
| 709 | this.injectMessageElem(mw.e.body); |
| 710 | mw.scrollIntoView(); |
| 711 | return mw; |
| 712 | }; |
| 713 | |
| 714 | /** |
| 715 | For use by the connection poller to send a "connection |
| 716 | restored" message. |
| 717 | */ |
| 718 | cs.reportReconnection = function f(/*msg args*/){ |
| 719 | const args = argsToArray(arguments).map(function(v){ |
| 720 | return (v instanceof Error) ? v.message : v; |
| 721 | }); |
| 722 | const d = new Date().toISOString(), |
| 723 | mw = new this.MessageWidget({ |
| 724 | isError: false, |
| 725 | xfrom: undefined, |
| 726 | msgid: "reconnect-"+(++InternalMsgId), |
| 727 | mtime: d, |
| 728 | lmtime: d, |
| 729 | xmsg: args |
| 730 | }); |
| 731 | this.injectMessageElem(mw.e.body); |
| 732 | mw.scrollIntoView(); |
| 733 | return mw; |
| 734 | }; |
| 735 | |
| 736 | cs.getMessageElemById = function(id){ |
| 737 | return qs('[data-msgid="'+id+'"]'); |
| 738 | }; |
| @@ -1624,10 +1685,45 @@ | |
| 1685 | const theMsg = findMessageWidgetParent(w); |
| 1686 | if(theMsg) Chat.deleteMessageElem(theMsg); |
| 1687 | })); |
| 1688 | Chat.reportErrorAsMessage(w); |
| 1689 | }; |
| 1690 | |
| 1691 | /* Assume the connection has been established, reset |
| 1692 | the Chat.timer.idReconnect, and alert the user |
| 1693 | that the outage appears to be over. */ |
| 1694 | const reportConnectionReestablished = function(){ |
| 1695 | if( Chat.timer.idReconnect ){ |
| 1696 | clearTimeout(Chat.timer.idReconnect); |
| 1697 | Chat.timer.idReconnect = 0; |
| 1698 | } |
| 1699 | if( Chat.timer.isDelayed() ){ |
| 1700 | Chat.timer.resetDelay(); |
| 1701 | Chat.reportReconnection( |
| 1702 | "Connection restored after outage." |
| 1703 | ); |
| 1704 | setTimeout( Chat.poll, 0 ); |
| 1705 | } |
| 1706 | }; |
| 1707 | |
| 1708 | /* If we're currently in delayed-retry mode, try to reset the delay |
| 1709 | if we're waiting for a while for the connection to complete, |
| 1710 | as that's an indication (not a guaranty) that we're connected |
| 1711 | and long-polling. */ |
| 1712 | const setupConnectionReestablished = function(){ |
| 1713 | if( !Chat.timer.idReconnect && Chat.timer.isDelayed() ){ |
| 1714 | Chat.timer.idReconnect = setTimeout(()=>{ |
| 1715 | Chat.timer.idReconnect = 0; |
| 1716 | if( poll.running ){ |
| 1717 | reportConnectionReestablished(); |
| 1718 | } |
| 1719 | }, Chat.timer.$initialDelay * 5 ); |
| 1720 | Chat.e.viewMessages.querySelectorAll( |
| 1721 | '.message-widget.error-connection' |
| 1722 | ).forEach(e=>D.remove(e)); |
| 1723 | } |
| 1724 | }; |
| 1725 | |
| 1726 | /** |
| 1727 | Submits the contents of the message input field (if not empty) |
| 1728 | and/or the file attachment field to the server. If both are |
| 1729 | empty, this is a no-op. |
| @@ -1681,15 +1777,20 @@ | |
| 1777 | const self = this; |
| 1778 | fd.set("lmtime", localTime8601(new Date())); |
| 1779 | F.fetch("chat-send",{ |
| 1780 | payload: fd, |
| 1781 | responseType: 'text', |
| 1782 | beforesend: function(){ |
| 1783 | Chat.ajaxStart(); |
| 1784 | setupConnectionReestablished(); |
| 1785 | }, |
| 1786 | onerror:function(err){ |
| 1787 | self.reportErrorAsMessage(err); |
| 1788 | recoverFailedMessage(fallback); |
| 1789 | }, |
| 1790 | onload:function(txt){ |
| 1791 | reportConnectionReestablished(); |
| 1792 | if(!txt) return/*success response*/; |
| 1793 | try{ |
| 1794 | const json = JSON.parse(txt); |
| 1795 | self.newContent({msgs:[json]}); |
| 1796 | }catch(e){ |
| @@ -2444,29 +2545,56 @@ | |
| 2545 | } |
| 2546 | } |
| 2547 | ); |
| 2548 | }/*Chat.submitSearch()*/; |
| 2549 | |
| 2550 | /** |
| 2551 | Deal with the last poll() response and maybe re-start poll(). |
| 2552 | */ |
| 2553 | const afterPollFetch = function f(isOkay = true){ |
| 2554 | if(true===f.isFirstCall){ |
| 2555 | f.isFirstCall = false; |
| 2556 | Chat.ajaxEnd(); |
| 2557 | Chat.e.viewMessages.classList.remove('loading'); |
| 2558 | setTimeout(function(){ |
| 2559 | Chat.scrollMessagesTo(1); |
| 2560 | }, 250); |
| 2561 | } |
| 2562 | if(Chat.timer.id) { |
| 2563 | clearTimeout(Chat.timer.id); |
| 2564 | Chat.timer.id = 0; |
| 2565 | } |
| 2566 | if(Chat._gotServerError){ |
| 2567 | Chat.reportErrorAsMessage( |
| 2568 | "Shutting down chat poller due to server-side error. ", |
| 2569 | "Reload this page to reactivate it."); |
| 2570 | Chat.timer.id = undefined; |
| 2571 | poll.running = false; |
| 2572 | } else { |
| 2573 | poll.running = false; |
| 2574 | if( isOkay ){ |
| 2575 | Chat.timer.id = setTimeout( |
| 2576 | poll, Chat.timer.resetDelay() |
| 2577 | ); |
| 2578 | }else{ |
| 2579 | const delay = Chat.timer.incrDelay(); |
| 2580 | const msg = D.addClass( |
| 2581 | Chat.reportErrorAsMessage( |
| 2582 | "Connection error. Retrying in ", |
| 2583 | delay, " ms.").e.body, |
| 2584 | 'error-connection' |
| 2585 | ); |
| 2586 | Chat.timer.id = setTimeout(()=>{ |
| 2587 | D.remove(msg); |
| 2588 | poll(); |
| 2589 | }, delay); |
| 2590 | } |
| 2591 | //console.log("isOkay =",isOkay,"currentDelay =",Chat.timer.currentDelay); |
| 2592 | } |
| 2593 | }; |
| 2594 | afterPollFetch.isFirstCall = true; |
| 2595 | |
| 2596 | /** |
| 2597 | FIXME: when polling fails because the remote server is |
| 2598 | reachable but it's not accepting HTTP requests, we should back |
| 2599 | off on polling for a while. e.g. if the remote web server process |
| 2600 | is killed, the poll fails quickly and immediately retries, |
| @@ -2489,42 +2617,48 @@ | |
| 2617 | Chat.ajaxStart(); |
| 2618 | Chat.e.viewMessages.classList.add('loading'); |
| 2619 | } |
| 2620 | F.fetch("chat-poll",{ |
| 2621 | timeout: 420 * 1000/*FIXME: get the value from the server*/, |
| 2622 | //timeout: 8000, |
| 2623 | urlParams:{ |
| 2624 | name: Chat.mxMsg |
| 2625 | }, |
| 2626 | responseType: "json", |
| 2627 | // Disable the ajax start/end handling for this long-polling op: |
| 2628 | beforesend: function(){ |
| 2629 | setupConnectionReestablished(); |
| 2630 | }, |
| 2631 | aftersend: function(){ |
| 2632 | }, |
| 2633 | onerror:function(err){ |
| 2634 | Chat._isBatchLoading = false; |
| 2635 | if(Chat.verboseErrors) console.error("poll onerror:",err); |
| 2636 | /* ^^^ we don't use Chat.reportError() here b/c the polling |
| 2637 | fails exepectedly when it times out, but is then immediately |
| 2638 | resumed, and reportError() produces a loud error message. */ |
| 2639 | afterPollFetch(false); |
| 2640 | }, |
| 2641 | onload:function(y){ |
| 2642 | reportConnectionReestablished(); |
| 2643 | newcontent(y); |
| 2644 | if(Chat._isBatchLoading){ |
| 2645 | Chat._isBatchLoading = false; |
| 2646 | Chat.updateActiveUserList(); |
| 2647 | } |
| 2648 | afterPollFetch(true); |
| 2649 | } |
| 2650 | }); |
| 2651 | }; |
| 2652 | poll.isFirstCall = true; |
| 2653 | Chat.poll = poll; |
| 2654 | Chat._gotServerError = poll.running = false; |
| 2655 | if( window.fossil.config.chat.fromcli ){ |
| 2656 | Chat.chatOnlyMode(true); |
| 2657 | } |
| 2658 | Chat.timer.id = setTimeout(poll, Chat.timer.resetDelay()); |
| 2659 | delete ForceResizeKludge.$disabled; |
| 2660 | ForceResizeKludge(); |
| 2661 | Chat.animate.$disabled = false; |
| 2662 | setTimeout( ()=>Chat.inputFocus(), 0 ); |
| 2663 | F.page.chat = Chat/* enables testing the APIs via the dev tools */; |
| 2664 | }); |
| 2665 |