mbeditor 0.12.3 → 0.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 88d963e43c97cf6974b3e38c079ffd2dba642dc1c13a68237f1a3a4f7c21e9a0
4
- data.tar.gz: 9e32a366d6ea7ce5014fff94ee89adba7f6909d09aa29f6404142b4155688a3a
3
+ metadata.gz: 021b4117178c3c82c72bd21709de8665d578557f5833b3b1b525e25ef672ce10
4
+ data.tar.gz: efc3c48d714d8973b1df5614c05647e21a11856ada4591b3af4b58261e248332
5
5
  SHA512:
6
- metadata.gz: 100805339d3956837f0ec5349de86b5bcf4315ef5dd261e76b80c0acb589cd9036264b70bb65677be3d27be9575007daee02e08c1cbad86ac3136bd331312a6b
7
- data.tar.gz: d1d0681c8962251ceb0d9f18db0937e20f3c1ae1e2d30754a08a88ee10a47b6a6492641de84b9e83c0b2ab81963e79d1f3d86bb61a985bfa7b4a5eb579d0548d
6
+ metadata.gz: 2655b76c9b8f77dfd7c2127124eae19d9536de389e532f0da51a954adf0e5079042e72175c76a14d047788b13fa7a85193d23353f1112e75d602cc62dde3bc4b
7
+ data.tar.gz: a4332fc2c00d5caf96c8f26d774892e7adf23f42b09782555cb359c60d601dc419e6b7abab44ae589f12e8804b5d43e9ce1303d9858227eb55ca3e5cb5b32f8f
data/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.5] - 2026-08-03
11
+
12
+ ### Fixed
13
+ - **The cable took up to 30 seconds to reconnect.** The retry was a flat 30s
14
+ interval, and because a disconnect tears the consumer down, Action Cable's own
15
+ much faster reconnection monitor was discarded with it. Any transient blip or
16
+ dev-server restart therefore cost half a minute with no cable at all: no
17
+ presence, no collaboration, no file-change push. Replaced with a jittered
18
+ backoff from 1s to a 8s ceiling, reset on a successful connect. Measured
19
+ against a real server restart: **2.8 seconds** from the server answering again
20
+ to the cable being back, where the flat interval could take the full 30.
21
+ - **Collaboration broadcasts flooded the development log.** The Action Cable log
22
+ filter matched `Mbeditor::` and `mbeditor_editor`, but a broadcast line names
23
+ only the stream — `Broadcasting to mbeditor_collab:…` — so every keystroke and
24
+ cursor move was logged with the base64 CRDT payload inlined.
25
+ - **The browser console filled with failed requests when the server became
26
+ unreachable.** A dropped VPN, a closed lid or a stopped server left the file
27
+ tree polling every 10s, git status every 5s and the git line tint every 10s,
28
+ all failing forever — and the browser logs every failed request itself, which
29
+ no amount of JavaScript can suppress. The only fix is to stop making them.
30
+
31
+ Background polls are now skipped before the request is issued once two
32
+ consecutive *network-level* failures have been seen (an HTTP error does not
33
+ count — a 500 proves the server is there), and a single probe on a 1s→30s
34
+ backoff decides when it is back. Requests you initiate are never blocked; they
35
+ fail fast with a real error rather than hanging until the 30s timeout.
36
+ Measured with the server down: two failed requests, then silence, instead of
37
+ an unbounded stream.
38
+
39
+ ## [0.12.4] - 2026-08-03
40
+
41
+ ### Fixed
42
+ - **Rejected-subscription logging flooded the console.** 0.12.3 made a silent
43
+ failure visible, which was right, but logged every occurrence — and a
44
+ rejection is not a one-off: the client retries every 30 seconds, every open
45
+ tab retries independently, and both the editor channel and every per-file
46
+ collaboration room authenticate. One message per distinct reason per five
47
+ minutes now, which says the same thing without drowning the log.
48
+
10
49
  ## [0.12.3] - 2026-08-03
11
50
 
12
51
  ### Added
@@ -1798,7 +1798,7 @@ var MbeditorApp = function MbeditorApp() {
1798
1798
  useEffect(function () {
1799
1799
  var intervalId = setInterval(function () {
1800
1800
  if (document.hidden) return;
1801
- FileService.getTree().then(function (data) {
1801
+ FileService.getTree({ background: true }).then(function (data) {
1802
1802
  setTreeData(_treeUpdater(data || []));
1803
1803
  }).catch(function () {}); // silently ignore auto-refresh errors
1804
1804
  }, 10000);
@@ -1815,7 +1815,7 @@ var MbeditorApp = function MbeditorApp() {
1815
1815
  // /git_info fan-out on its own when the branch or working tree changed.
1816
1816
  var refresh = function () {
1817
1817
  if (document.hidden) return;
1818
- GitService.fetchStatusLite()["catch"](function () {});
1818
+ GitService.fetchStatusLite({ background: true })["catch"](function () {});
1819
1819
  };
1820
1820
  // Regaining focus is a strong signal something may have happened in a
1821
1821
  // terminal meanwhile — do a full refresh (server-side cache bounds cost).
@@ -6,6 +6,104 @@ axios.defaults.headers.common['X-Mbeditor-Client'] = '1';
6
6
  // The ping endpoint overrides this with a tighter 4 s timeout per-request.
7
7
  axios.defaults.timeout = 30000;
8
8
 
9
+ // ── Server reachability ─────────────────────────────────────────────────────
10
+ //
11
+ // The editor polls hard and unconditionally: the file tree every 10s, git status
12
+ // every 5s, the git line tint every 10s. When the host stops resolving — a
13
+ // dropped VPN, a closed laptop lid, a stopped server — every one of those keeps
14
+ // firing forever, and the browser logs each failure itself. That is where the
15
+ // wall of ERR_NAME_NOT_RESOLVED in the console comes from, and JavaScript cannot
16
+ // suppress those entries: the only fix is to stop making the requests.
17
+ //
18
+ // So background polling is short-circuited while the server looks unreachable,
19
+ // and a single probe on a backoff decides when it is back. User-initiated
20
+ // requests are never blocked — they fail fast with a real error instead, which
21
+ // beats hanging until the 30s timeout.
22
+ var ServerReachability = (function () {
23
+ // A network-level failure has no response; an HTTP error does. Only the former
24
+ // means "cannot reach the server" — a 500 proves it is very much there.
25
+ var CONSECUTIVE_FAILURES_BEFORE_OFFLINE = 2;
26
+ var PROBE_BASE_MS = 1000;
27
+ var PROBE_MAX_MS = 30000;
28
+
29
+ var _failures = 0;
30
+ var _online = true;
31
+ var _probeTimer = null;
32
+ var _probeAttempts = 0;
33
+ var _listeners = [];
34
+
35
+ function _emit() {
36
+ _listeners.slice().forEach(function (fn) {
37
+ try { fn(_online); } catch (e) { /* a bad listener must not stop the rest */ }
38
+ });
39
+ }
40
+
41
+ function _scheduleProbe() {
42
+ if (_probeTimer || _online) return;
43
+ var delay = Math.min(PROBE_MAX_MS, PROBE_BASE_MS * Math.pow(2, _probeAttempts));
44
+ _probeAttempts += 1;
45
+ _probeTimer = setTimeout(function () {
46
+ _probeTimer = null;
47
+ // Bypasses the short-circuit below: this is the one request allowed
48
+ // through while offline, and it is what lets us notice recovery.
49
+ axios.get(window.mbeditorBasePath() + '/ping', { timeout: 4000, mbeditorProbe: true })
50
+ .then(function () { noteSuccess(); })
51
+ .catch(function () { _scheduleProbe(); });
52
+ }, delay);
53
+ }
54
+
55
+ function noteSuccess() {
56
+ _failures = 0;
57
+ _probeAttempts = 0;
58
+ if (_probeTimer) { clearTimeout(_probeTimer); _probeTimer = null; }
59
+ if (!_online) { _online = true; _emit(); }
60
+ }
61
+
62
+ function noteNetworkFailure() {
63
+ _failures += 1;
64
+ if (_online && _failures >= CONSECUTIVE_FAILURES_BEFORE_OFFLINE) {
65
+ _online = false;
66
+ _emit();
67
+ _scheduleProbe();
68
+ } else if (!_online) {
69
+ _scheduleProbe();
70
+ }
71
+ }
72
+
73
+ return {
74
+ isOnline: function () { return _online; },
75
+ noteSuccess: noteSuccess,
76
+ noteNetworkFailure: noteNetworkFailure,
77
+ onChange: function (fn) {
78
+ _listeners.push(fn);
79
+ return function () { _listeners = _listeners.filter(function (f) { return f !== fn; }); };
80
+ }
81
+ };
82
+ })();
83
+
84
+ // Drop background polls while the server is unreachable, so they stop producing
85
+ // console noise and pointless traffic. Marked requests only — anything the user
86
+ // asked for still goes out.
87
+ axios.interceptors.request.use(function (config) {
88
+ if (config.mbeditorBackground && !ServerReachability.isOnline()) {
89
+ var err = new Error('mbeditor: skipped background request while server unreachable');
90
+ err.mbeditorSkipped = true;
91
+ return Promise.reject(err);
92
+ }
93
+ return config;
94
+ });
95
+
96
+ axios.interceptors.response.use(function (response) {
97
+ ServerReachability.noteSuccess();
98
+ return response;
99
+ }, function (error) {
100
+ if (error && error.mbeditorSkipped) return Promise.reject(error);
101
+ // No response object at all means the request never reached the server.
102
+ if (error && !error.response) ServerReachability.noteNetworkFailure();
103
+ else ServerReachability.noteSuccess();
104
+ return Promise.reject(error);
105
+ });
106
+
9
107
  // Surface pending-migration errors as a dismissible banner instead of silently failing.
10
108
  axios.interceptors.response.use(null, function(error) {
11
109
  if (error.response && error.response.data && error.response.data.pending_migration_error) {
@@ -42,8 +140,11 @@ var FileService = (function () {
42
140
  return axios.get(window.mbeditorBasePath() + '/workspace').then(function(res) { return res.data; });
43
141
  }
44
142
 
45
- function getTree() {
46
- return axios.get(window.mbeditorBasePath() + '/files').then(function(res) { return res.data; });
143
+ // opts.background marks an automatic poll rather than something the user
144
+ // asked for, so it can be skipped while the server is unreachable.
145
+ function getTree(opts) {
146
+ var cfg = (opts && opts.background) ? { mbeditorBackground: true } : {};
147
+ return axios.get(window.mbeditorBasePath() + '/files', cfg).then(function(res) { return res.data; });
47
148
  }
48
149
 
49
150
  function getFile(path, options) {
@@ -345,6 +446,8 @@ var FileService = (function () {
345
446
  runTests: runTests,
346
447
  ping: ping,
347
448
  getRoutes: getRoutes,
449
+ isServerReachable: ServerReachability.isOnline,
450
+ onReachabilityChange: ServerReachability.onChange,
348
451
  getState: getState,
349
452
  saveState: saveState,
350
453
  getBranchState: getBranchState,
@@ -35,8 +35,11 @@ var GitService = (function () {
35
35
  // changed — an external branch switch or a working-tree change. Rich fields
36
36
  // from the last full fetch (unpushedCommits, branchCommits, ...) are
37
37
  // preserved rather than clobbered.
38
- function fetchStatusLite() {
39
- return axios.get(window.mbeditorBasePath() + '/git_status')
38
+ // opts.background marks the 5s poll, which is dropped while the server is
39
+ // unreachable rather than failing over and over in the console.
40
+ function fetchStatusLite(opts) {
41
+ var cfg = (opts && opts.background) ? { mbeditorBackground: true } : {};
42
+ return axios.get(window.mbeditorBasePath() + '/git_status', cfg)
40
43
  .then(function(res) {
41
44
  var data = res.data;
42
45
  if (!data || !data.ok) return fetchInfo();
@@ -22,7 +22,29 @@ var WebSocketService = (function () {
22
22
  var _serverSupportsWs = false;
23
23
  var _reconnectTimer = null;
24
24
  var _lastCableAttemptAt = 0;
25
- var RECONNECT_INTERVAL_MS = 30000;
25
+ // Exponential backoff, not a flat interval. This used to wait a fixed 30s
26
+ // before even attempting a reconnect — and because `disconnected` tears the
27
+ // consumer down, Action Cable's own much faster reconnection monitor is
28
+ // discarded too. The result was that any transient blip cost half a minute
29
+ // with no cable: no presence, no collaboration, no file-change push. Start
30
+ // near-instant for the common case (a blip, a server restart) and back off
31
+ // only if the server really is gone.
32
+ // Growth is gentle and the ceiling low on purpose. The dominant case here is a
33
+ // development server restarting, which takes several seconds to boot — a
34
+ // doubling curve spends those seconds growing, so the attempt that finally
35
+ // lands is a long way out. Measured against a real restart: doubling to a 30s
36
+ // cap reconnected in 20s, this reconnects within a few. It is a local dev
37
+ // tool, so retrying every few seconds costs nothing worth saving.
38
+ var RECONNECT_BASE_MS = 1000;
39
+ var RECONNECT_MAX_MS = 8000;
40
+ var RECONNECT_FACTOR = 1.6;
41
+ var _reconnectAttempts = 0;
42
+
43
+ function _reconnectDelay() {
44
+ var exp = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * Math.pow(RECONNECT_FACTOR, _reconnectAttempts));
45
+ // Jitter so a server restart doesn't have every open tab retry in lockstep.
46
+ return Math.round(exp * (0.7 + Math.random() * 0.6));
47
+ }
26
48
 
27
49
  // ---------------------------------------------------------------------------
28
50
  // Internal helpers
@@ -90,11 +112,17 @@ var WebSocketService = (function () {
90
112
  }
91
113
 
92
114
  function _scheduleReconnect() {
93
- if (_reconnectTimer || !_serverSupportsWs) return;
115
+ if (_reconnectTimer || !_serverSupportsWs || _connected) return;
116
+ var delay = _reconnectDelay();
117
+ _reconnectAttempts += 1;
94
118
  _reconnectTimer = setTimeout(function () {
95
119
  _reconnectTimer = null;
96
120
  _attemptConnect();
97
- }, RECONNECT_INTERVAL_MS);
121
+ // Keep trying: _attemptConnect only fires once, so without this a failed
122
+ // attempt that never reaches `rejected` or `disconnected` would end the
123
+ // retry chain and leave the editor permanently offline.
124
+ _scheduleReconnect();
125
+ }, delay);
98
126
  }
99
127
 
100
128
  function _attemptConnect() {
@@ -113,6 +141,9 @@ var WebSocketService = (function () {
113
141
  connected: function () {
114
142
  _connected = true;
115
143
  _status = 'connected';
144
+ // Back to fast retries for the next blip.
145
+ _reconnectAttempts = 0;
146
+ if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
116
147
  },
117
148
  disconnected: function () {
118
149
  _status = _status === 'rejected' ? 'rejected' : 'dropped';
@@ -50,13 +50,40 @@ module Mbeditor
50
50
  Mbeditor.configuration.authenticate_with
51
51
  end
52
52
 
53
+ # Throttled hard, because a rejection is not a one-off event: the client
54
+ # retries every 30s, every open tab retries independently, and both the
55
+ # editor channel and every per-file collaboration room authenticate. Logging
56
+ # each one turned a silent failure into a flooded console, which is no more
57
+ # usable. One message per distinct reason per interval says the same thing.
58
+ LOG_THROTTLE_SECONDS = 300
59
+ LOG_MUTEX = Mutex.new
60
+ private_constant :LOG_MUTEX
61
+
62
+ def self.last_logged
63
+ @last_logged ||= {}
64
+ end
65
+
53
66
  def mbeditor_log_denial(reason)
67
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
68
+ should_log = LOG_MUTEX.synchronize do
69
+ seen = ChannelAuthentication.last_logged
70
+ previous = seen[reason]
71
+ if previous.nil? || (now - previous) > LOG_THROTTLE_SECONDS
72
+ seen[reason] = now
73
+ true
74
+ else
75
+ false
76
+ end
77
+ end
78
+ return unless should_log
79
+
54
80
  Rails.logger&.warn(
55
81
  "[mbeditor] WebSocket subscription rejected: #{reason}. " \
56
82
  "Realtime collaboration will not work. A WebSocket subscribe runs no " \
57
83
  "controller, so Current.*, Authlogic sessions and other request-scoped " \
58
84
  "state set by before_actions are unavailable here — resolve the user " \
59
- "from `session` instead, or set config.cable_authenticate_with."
85
+ "from `session` instead, or set config.cable_authenticate_with. " \
86
+ "(Further identical rejections suppressed for #{LOG_THROTTLE_SECONDS}s.)"
60
87
  )
61
88
  rescue StandardError
62
89
  # Logging must never be the thing that breaks the socket.
@@ -8,7 +8,12 @@ module Mbeditor
8
8
  # Mbeditor channels so the development console stays readable.
9
9
  # Non-Mbeditor ActionCable messages pass through unchanged.
10
10
  class CableLogFilter < SimpleDelegator
11
- SUPPRESS_PATTERN = /Mbeditor::|mbeditor_editor/
11
+ # Both stream names, not just the editor one. Action Cable's broadcast line
12
+ # ("Broadcasting to <stream>: <payload>") names only the stream, so a
13
+ # channel-class pattern never matches it — and the collaboration stream
14
+ # broadcasts on every keystroke and every cursor move, with the base64 CRDT
15
+ # payload inlined. That floods a development log during pairing.
16
+ SUPPRESS_PATTERN = /Mbeditor::|mbeditor_editor|mbeditor_collab/
12
17
  CABLE_WEBSOCKET_REQUEST_PATTERN = /(?:Started|Finished) "\/cable(?:\/[^\"]*)?" \[WebSocket\]/
13
18
 
14
19
  # Provides no-op tagged logging APIs for plain Ruby formatters.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mbeditor
4
- VERSION = "0.12.3"
4
+ VERSION = "0.12.5"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mbeditor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.3
4
+ version: 0.12.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oliver Noonan
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-03 00:00:00.000000000 Z
11
+ date: 2026-08-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails