debounced 2.0.0 → 2.1.0

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: 23ca0ee42ff088cb85dd495dc84f5cffb38b2a6af402bab7b23424f962e8f002
4
- data.tar.gz: 0e218d90fb5ad2d908cd20d3557dd6bf4aa7267f9461810c34d9f30b3c62d0ae
3
+ metadata.gz: f40aa8391039a8a5639ba8e5b77c64d38a07c0208eb158939df97770ae40b240
4
+ data.tar.gz: 05c33de396be8333e1945880ee7518b67d361d23aa01f68936a4740e6776acee
5
5
  SHA512:
6
- metadata.gz: 2f16ed706132085f5109692d233544893edb540b1500a498f5597caef5fa856e8e1bdc06c3eae9a46251e8104537908f7b7f24aca8356c16331465fd1c2de250
7
- data.tar.gz: '02438414f936b2d72c5dd185288892a9e12501e734977ae3d2103ca458c22e628c8ff83ea809f68c3c21084888ebc2fd2a7c5a1752fd42d9bdba5ab29feb7444'
6
+ metadata.gz: b3b7aa2939222ef134b925b70a7d9bb4e535876bd317901cb3d022c880f5c397d2fc4b2a9666db9674ca1e1a6112761da664fdc4c2841f157c38010c4de929e9
7
+ data.tar.gz: 2dc3ae74b658a1ab57bbabd8cef240629102700d49a7980ddd7694c458af36dd7c91e7a55e472a75acdf2188eab511c2612dc6a8f215d3124b08a711e685ae2c
data/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ### Security
11
+
12
+ - Callbacks are only dispatched to public methods defined by the application, never to core Ruby methods
13
+ - The server creates its socket owner-only, and the proxy refuses a socket owned by another user
14
+
15
+ ### Changed
16
+
17
+ - Requires Node.js 22 or later; Node.js 18 and 20 are past end of life
18
+
19
+ ### Fixed
20
+
21
+ - A second server no longer takes over the socket of a running one, and only removes a socket it created
22
+ - The server exits non-zero when it cannot listen
23
+ - Multi-byte characters split across socket reads are no longer corrupted
24
+ - The server serves any number of connected processes and publishes each callback to the process that requested it
25
+ - The listener no longer adds an extra idle timeout before delivering a callback
26
+ - Requests larger than the socket buffer are no longer truncated
27
+ - A failed send falls back to invoking the callback instead of raising
28
+ - `ServiceProxy#stop` works before `listen`
29
+
8
30
  ## [1.0.7](https://github.com/Flytedesk/debounced/compare/v1.0.6...v1.0.7) (2026-04-15)
9
31
 
10
32
 
data/README.md CHANGED
@@ -27,7 +27,7 @@ $ gem install debounced
27
27
 
28
28
  This gem requires Node.js to be installed on your system, as it uses a Node.js server to handle the debouncing logic. You'll need:
29
29
 
30
- - Node.js >= 20.0.0
30
+ - Node.js >= 22.0.0
31
31
 
32
32
  ## Usage
33
33
 
@@ -3,6 +3,8 @@ module Debounced
3
3
  ###
4
4
  # Represents a callback to be executed by the debounce service
5
5
  class Callback
6
+ CORE_RUBY_OWNERS = [BasicObject, Kernel, Object, Module, Class].freeze
7
+
6
8
  attr_accessor :class_name, :method_name, :args, :kwargs, :method_args, :method_kwargs
7
9
 
8
10
  ###
@@ -65,19 +67,32 @@ module Debounced
65
67
 
66
68
  def call
67
69
  Debounced.configuration.logger.debug("Invoking callback #{method_name}")
68
- klass = Object.const_get(class_name)
69
- unless klass.ancestors.include?(Debounced::Callbackable)
70
- raise ArgumentError, "#{class_name} is not an allowed Debounced callback target. Include Debounced::Callbackable in the class."
71
- end
70
+ klass = callback_class
72
71
  if klass.respond_to?(method_name)
73
- klass.send(method_name, *args, **kwargs)
72
+ ensure_application_method(klass.method(method_name))
73
+ klass.public_send(method_name, *args, **kwargs)
74
74
  else
75
- instance = klass.new(*args, **kwargs)
76
- instance.send(method_name, *method_args, **method_kwargs)
75
+ ensure_application_method(klass.public_instance_method(method_name))
76
+ klass.new(*args, **kwargs).public_send(method_name, *method_args, **method_kwargs)
77
77
  end
78
78
  rescue StandardError => e
79
79
  Debounced.configuration.logger.warn("Unable to invoke callback #{as_json}: #{e.message}")
80
80
  Debounced.configuration.logger.warn(e.backtrace.join("\n"))
81
81
  end
82
+
83
+ private
84
+
85
+ def callback_class
86
+ klass = Object.const_get(class_name)
87
+ return klass if klass.ancestors.include?(Debounced::Callbackable)
88
+
89
+ raise ArgumentError, "#{class_name} is not an allowed Debounced callback target. Include Debounced::Callbackable in the class."
90
+ end
91
+
92
+ def ensure_application_method(method)
93
+ return unless CORE_RUBY_OWNERS.include?(method.owner)
94
+
95
+ raise ArgumentError, "#{method_name} is a core Ruby method and not an allowed Debounced callback"
96
+ end
82
97
  end
83
98
  end
@@ -10,7 +10,7 @@ export default class DebounceService {
10
10
  constructor(socketDescriptor) {
11
11
  this._socketDescriptor = socketDescriptor;
12
12
  this._timers = {};
13
- this._client = null;
13
+ this._clients = new Set();
14
14
  this.publishEvent = this.publishEvent.bind(this);
15
15
  this.debounceEvent = this.debounceEvent.bind(this);
16
16
  this.reset = this.reset.bind(this);
@@ -18,28 +18,25 @@ export default class DebounceService {
18
18
  this.handleError = this.handleError.bind(this);
19
19
  this.sendMessage = this.sendMessage.bind(this);
20
20
  this.onClientConnected = this.onClientConnected.bind(this);
21
- this.onClientDisconnected = this.onClientDisconnected.bind(this);
22
- this.onConnectionError = this.onConnectionError.bind(this);
23
21
  this.handleMessage = this.handleMessage.bind(this);
24
22
  this.configureServer();
25
23
  }
26
24
 
27
25
  onConnectionError(err) {
28
26
  log('DebounceService client connection error');
29
- this._client = null
30
27
  this.handleError(err);
31
28
  }
32
29
 
33
- onClientDisconnected() {
30
+ onClientDisconnected(socket) {
34
31
  log('DebounceService client disconnected');
35
- this._client = null
32
+ this._clients.delete(socket);
36
33
  }
37
34
 
38
- handleMessage(message) {
35
+ handleMessage(message, socket) {
39
36
  try {
40
37
  const object = JSON.parse(message);
41
38
  if (object.type === 'debounceEvent') {
42
- this.debounceEvent(object.data);
39
+ this.debounceEvent(object.data, socket);
43
40
  } else if (object.type === 'reset') {
44
41
  this.reset();
45
42
  } else {
@@ -51,68 +48,80 @@ export default class DebounceService {
51
48
  }
52
49
 
53
50
  onClientConnected(socket) {
54
- if (this._client) {
55
- log('DebounceService rejecting connection: client already connected');
56
- this.sendMessage(socket, JSON.stringify({ type: 'rejectClient'}));
57
- socket.destroy();
58
- return;
59
- }
60
-
61
51
  log('DebounceService client connected');
62
52
  let connectionBuffer = '';
63
- this._client = socket;
64
- socket.on('end', this.onClientDisconnected)
65
- socket.on('error', this.onConnectionError)
53
+ this._clients.add(socket);
54
+ socket.on('close', () => this.onClientDisconnected(socket));
55
+ socket.on('error', this.onConnectionError);
56
+ socket.setEncoding('utf8');
66
57
  socket.on('data', data => {
67
- log('DebounceService data received', data.toString());
68
- connectionBuffer += data.toString();
58
+ log('DebounceService data received', data);
59
+ connectionBuffer += data;
69
60
  const messages = connectionBuffer.split('\f');
70
- connectionBuffer = ''
71
- if (!connectionBuffer.endsWith('\f')) {
72
- connectionBuffer = messages.pop();
73
- }
74
- messages.forEach(this.handleMessage);
61
+ connectionBuffer = messages.pop();
62
+ messages.forEach(message => this.handleMessage(message, socket));
75
63
  })
76
64
  }
77
65
 
78
66
  configureServer() {
79
67
  this.server = net.createServer(this.onClientConnected)
80
- this.server.on('error', this.handleError);
68
+ this.server.on('error', (err) => {
69
+ this.handleError(err);
70
+ process.exit(1);
71
+ });
81
72
  }
82
73
 
83
74
  cleanupDescriptor() {
84
75
  if (fs.existsSync(this._socketDescriptor)) {
85
- log('DebounceEventService removing stale socket file ', this._socketDescriptor);
76
+ log('DebounceEventService removing socket file ', this._socketDescriptor);
86
77
  fs.unlinkSync(this._socketDescriptor);
87
78
  }
88
79
  }
89
80
 
90
81
  listen() {
91
- // Remove the existing socket file if it exists
92
- this.cleanupDescriptor()
82
+ if (!fs.existsSync(this._socketDescriptor)) {
83
+ this.bind();
84
+ return;
85
+ }
93
86
 
94
- process.on('exit', (code) => {
95
- log(`Process exiting with code: ${code}`);
96
- this.server.close();
97
- this.cleanupDescriptor()
87
+ const probe = net.connect(this._socketDescriptor);
88
+ probe.on('connect', () => {
89
+ probe.destroy();
90
+ log('DebounceEventService another server is listening on', this._socketDescriptor);
91
+ process.exit(1);
92
+ });
93
+ probe.on('error', (err) => {
94
+ if (err.code !== 'ECONNREFUSED') {
95
+ this.handleError(err);
96
+ process.exit(1);
97
+ }
98
+ log('DebounceEventService removing stale socket file ', this._socketDescriptor);
99
+ fs.unlinkSync(this._socketDescriptor);
100
+ this.bind();
98
101
  });
102
+ }
99
103
 
100
- process.on('SIGTERM', () => {
101
- log('Process received SIGTERM');
102
- this.server.close();
103
- this.cleanupDescriptor()
104
- process.exit(0);
104
+ bind() {
105
+ const previousUmask = process.umask(0o177);
106
+ this.server.listen(this._socketDescriptor, () => {
107
+ log('DebounceService listening on', this._socketDescriptor);
108
+ this.cleanupOnExit();
105
109
  });
110
+ process.umask(previousUmask);
111
+ }
106
112
 
107
- process.on('SIGINT', () => {
108
- log('Process received SIGINT');
113
+ cleanupOnExit() {
114
+ process.on('exit', (code) => {
115
+ log(`Process exiting with code: ${code}`);
109
116
  this.server.close();
110
- this.cleanupDescriptor()
111
- process.exit(0);
117
+ this.cleanupDescriptor();
112
118
  });
113
119
 
114
- this.server.listen(this._socketDescriptor, () => {
115
- log('DebounceService listening on', this._socketDescriptor);
120
+ ['SIGTERM', 'SIGINT'].forEach((signal) => {
121
+ process.on(signal, () => {
122
+ log(`Process received ${signal}`);
123
+ process.exit(0);
124
+ });
116
125
  });
117
126
  }
118
127
 
@@ -125,16 +134,21 @@ export default class DebounceService {
125
134
  }
126
135
  }
127
136
 
128
- publishEvent(descriptor, callback) {
137
+ publishEvent(descriptor, callback, socket) {
138
+ if (!this._clients.has(socket)) {
139
+ log(`Debounce period expired for ${descriptor} - client disconnected, dropping`);
140
+ return;
141
+ }
142
+
129
143
  log(`Debounce period expired for ${descriptor} - sending to client`);
130
144
  const message = JSON.stringify({
131
145
  type: 'publishEvent',
132
146
  callback: callback
133
147
  });
134
- this.sendMessage(this._client, message);
148
+ this.sendMessage(socket, message);
135
149
  }
136
150
 
137
- debounceEvent({ descriptor, timeout, callback }) {
151
+ debounceEvent({ descriptor, timeout, callback }, socket) {
138
152
  if (this._timers[descriptor]) {
139
153
  clearTimeout(this._timers[descriptor]);
140
154
  }
@@ -142,7 +156,7 @@ export default class DebounceService {
142
156
  log("Debouncing", descriptor);
143
157
  this._timers[descriptor] = setTimeout(() => {
144
158
  delete this._timers[descriptor];
145
- this.publishEvent(descriptor, callback);
159
+ this.publishEvent(descriptor, callback, socket);
146
160
  }, timeout * 1000);
147
161
  }
148
162
 
@@ -48,6 +48,10 @@ module Debounced
48
48
  transmit(build_request(activity_descriptor, timeout, callback))
49
49
  end
50
50
  end
51
+ rescue IOError, SystemCallError, NoServerError => e
52
+ logger.warn("Unable to send #{activity_descriptor} to #{server_name} (#{e.message}); skipping debounce step.")
53
+ close
54
+ callback.call
51
55
  end
52
56
 
53
57
  ###
@@ -65,7 +69,6 @@ module Debounced
65
69
  next unless message
66
70
 
67
71
  payload = deserialize_message(message)
68
- raise SocketConflictError if payload['type'] == 'rejectClient'
69
72
 
70
73
  instantiate_callback(payload['callback']).call
71
74
  rescue Debounced::NoServerError => e
@@ -75,7 +78,7 @@ module Debounced
75
78
 
76
79
  close
77
80
  end
78
- rescue SocketConflictError, StandardError => e
81
+ rescue StandardError => e
79
82
  logger.warn("Unable to listen for messages from #{server_name}: #{e.message}")
80
83
  logger.warn(e.backtrace.join("\n"))
81
84
  ensure
@@ -83,17 +86,19 @@ module Debounced
83
86
  end
84
87
 
85
88
  def stop
86
- @abort_signal.make_true
89
+ @abort_signal&.make_true
87
90
  end
88
91
 
89
92
  private
90
93
 
91
94
  def close
92
- return unless @socket
95
+ @mutex.synchronize do
96
+ return unless @socket
93
97
 
94
- logger.debug("Closing connection to #{server_name}")
95
- @socket.close
96
- @socket = nil
98
+ logger.debug("Closing connection to #{server_name}")
99
+ @socket.close
100
+ @socket = nil
101
+ end
97
102
  end
98
103
 
99
104
  def receive_message_from_server
@@ -108,7 +113,6 @@ module Debounced
108
113
  message
109
114
  rescue IO::TimeoutError
110
115
  logger_trace { "Timeout waiting for data" }
111
- sleep wait_timeout
112
116
  nil
113
117
  rescue Errno::EPIPE, IOError, Errno::ECONNRESET
114
118
  close
@@ -127,7 +131,8 @@ module Debounced
127
131
  end
128
132
 
129
133
  def transmit(message)
130
- socket.send serialize_message(message), 0
134
+ connection = socket or raise NoServerError, "#{server_name} at #{socket_descriptor} not running"
135
+ connection.write(serialize_message(message))
131
136
  end
132
137
 
133
138
  def server_name
@@ -154,6 +159,7 @@ module Debounced
154
159
  def socket
155
160
  @mutex.synchronize do
156
161
  return @socket if @socket
162
+ return unless File.owned?(socket_descriptor)
157
163
 
158
164
  logger_trace { "Connecting to #{server_name} at #{socket_descriptor}" }
159
165
  @socket = UNIXSocket.new(socket_descriptor).tap { |s| s.timeout = wait_timeout }
@@ -1,3 +1,3 @@
1
1
  module Debounced
2
- VERSION = "2.0.0"
2
+ VERSION = "2.1.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: debounced
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gary Passero