fractor 0.1.3 → 0.1.6
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 +4 -4
- data/.rubocop-https---raw-githubusercontent-com-riboseinc-oss-guides-main-ci-rubocop-yml +552 -0
- data/.rubocop.yml +14 -8
- data/.rubocop_todo.yml +154 -48
- data/README.adoc +1371 -317
- data/examples/auto_detection/README.adoc +52 -0
- data/examples/auto_detection/auto_detection.rb +170 -0
- data/examples/continuous_chat_common/message_protocol.rb +53 -0
- data/examples/continuous_chat_fractor/README.adoc +217 -0
- data/examples/continuous_chat_fractor/chat_client.rb +303 -0
- data/examples/continuous_chat_fractor/chat_common.rb +83 -0
- data/examples/continuous_chat_fractor/chat_server.rb +167 -0
- data/examples/continuous_chat_fractor/simulate.rb +345 -0
- data/examples/continuous_chat_server/README.adoc +135 -0
- data/examples/continuous_chat_server/chat_client.rb +303 -0
- data/examples/continuous_chat_server/chat_server.rb +359 -0
- data/examples/continuous_chat_server/simulate.rb +343 -0
- data/examples/hierarchical_hasher/hierarchical_hasher.rb +12 -8
- data/examples/multi_work_type/multi_work_type.rb +30 -29
- data/examples/pipeline_processing/pipeline_processing.rb +15 -15
- data/examples/producer_subscriber/producer_subscriber.rb +20 -16
- data/examples/scatter_gather/scatter_gather.rb +29 -28
- data/examples/simple/sample.rb +38 -6
- data/examples/specialized_workers/specialized_workers.rb +44 -37
- data/lib/fractor/continuous_server.rb +188 -0
- data/lib/fractor/result_aggregator.rb +1 -1
- data/lib/fractor/supervisor.rb +291 -108
- data/lib/fractor/version.rb +1 -1
- data/lib/fractor/work_queue.rb +68 -0
- data/lib/fractor/work_result.rb +1 -1
- data/lib/fractor/worker.rb +2 -1
- data/lib/fractor/wrapped_ractor.rb +12 -2
- data/lib/fractor.rb +2 -0
- metadata +17 -2
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "socket"
|
|
5
|
+
require "json"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module ContinuousChat
|
|
9
|
+
# Simple Chat Client using Ruby's standard socket library
|
|
10
|
+
class ChatClient
|
|
11
|
+
def initialize(username, server_host = "localhost", server_port = 3000,
|
|
12
|
+
log_file_path = nil)
|
|
13
|
+
@username = username
|
|
14
|
+
@server_host = server_host
|
|
15
|
+
@server_port = server_port
|
|
16
|
+
@running = true
|
|
17
|
+
|
|
18
|
+
# Set up logging
|
|
19
|
+
@log_file_path = log_file_path || "logs/client_#{username}_messages.log"
|
|
20
|
+
FileUtils.mkdir_p(File.dirname(@log_file_path))
|
|
21
|
+
@log_file = File.open(@log_file_path, "w")
|
|
22
|
+
|
|
23
|
+
log_message("Client initialized for #{username}, connecting to #{server_host}:#{server_port}")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def connect
|
|
27
|
+
puts "Connecting to server at #{@server_host}:#{@server_port}..."
|
|
28
|
+
log_message("Connecting to server at #{@server_host}:#{@server_port}")
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
@socket = TCPSocket.new(@server_host, @server_port)
|
|
32
|
+
|
|
33
|
+
# Send join message
|
|
34
|
+
join_data = {
|
|
35
|
+
type: "join",
|
|
36
|
+
data: {
|
|
37
|
+
username: @username,
|
|
38
|
+
},
|
|
39
|
+
timestamp: Time.now.to_i,
|
|
40
|
+
}
|
|
41
|
+
@socket.puts(JSON.generate(join_data))
|
|
42
|
+
log_message("Sent join message: #{join_data}")
|
|
43
|
+
|
|
44
|
+
puts "Connected to chat server!"
|
|
45
|
+
log_message("Connected to chat server")
|
|
46
|
+
|
|
47
|
+
true
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
puts "Failed to connect: #{e.message}"
|
|
50
|
+
log_message("Failed to connect: #{e.message}")
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def start
|
|
56
|
+
return false unless @socket
|
|
57
|
+
|
|
58
|
+
# Main event loop that handles both sending and receiving messages
|
|
59
|
+
# without creating separate threads
|
|
60
|
+
main_event_loop
|
|
61
|
+
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Main event loop that handles both user input and server messages
|
|
66
|
+
def main_event_loop
|
|
67
|
+
log_message("Starting main event loop")
|
|
68
|
+
|
|
69
|
+
# Print initial prompt
|
|
70
|
+
print "(#{@username})> "
|
|
71
|
+
$stdout.flush
|
|
72
|
+
|
|
73
|
+
while @running
|
|
74
|
+
# Use IO.select to wait for input from either STDIN or the socket
|
|
75
|
+
# This is non-blocking and allows us to handle both in a single loop
|
|
76
|
+
readable, = IO.select([@socket, $stdin], nil, nil, 0.1)
|
|
77
|
+
|
|
78
|
+
next unless readable # Nothing to process this iteration
|
|
79
|
+
|
|
80
|
+
readable.each do |io|
|
|
81
|
+
if io == $stdin
|
|
82
|
+
# Handle user input
|
|
83
|
+
handle_user_input
|
|
84
|
+
elsif io == @socket
|
|
85
|
+
# Handle server message
|
|
86
|
+
handle_server_message
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
rescue Interrupt
|
|
91
|
+
log_message("Client interrupted")
|
|
92
|
+
@running = false
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
log_message("Error in main event loop: #{e.message}")
|
|
95
|
+
@running = false
|
|
96
|
+
ensure
|
|
97
|
+
disconnect
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Handle user input (non-blocking)
|
|
101
|
+
def handle_user_input
|
|
102
|
+
text = $stdin.gets&.chomp
|
|
103
|
+
return unless text
|
|
104
|
+
|
|
105
|
+
# Check if client wants to quit
|
|
106
|
+
if text == "/quit" || text.nil?
|
|
107
|
+
@running = false
|
|
108
|
+
return
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Create message packet
|
|
112
|
+
message_data = {
|
|
113
|
+
type: "message",
|
|
114
|
+
data: {
|
|
115
|
+
content: text,
|
|
116
|
+
recipient: "all", # Default to broadcast
|
|
117
|
+
},
|
|
118
|
+
timestamp: Time.now.to_i,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Send to server
|
|
122
|
+
@socket.puts(JSON.generate(message_data))
|
|
123
|
+
log_message("Sent message: #{text}")
|
|
124
|
+
|
|
125
|
+
# Print prompt for next input
|
|
126
|
+
print "(#{@username})> "
|
|
127
|
+
$stdout.flush
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
log_message("Error handling user input: #{e.message}")
|
|
130
|
+
@running = false
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Handle server message (non-blocking)
|
|
134
|
+
def handle_server_message
|
|
135
|
+
line = @socket.gets&.chomp
|
|
136
|
+
|
|
137
|
+
if line.nil?
|
|
138
|
+
# Server closed the connection
|
|
139
|
+
log_message("Connection to server lost")
|
|
140
|
+
@running = false
|
|
141
|
+
return
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Parse and handle the message
|
|
145
|
+
message = JSON.parse(line)
|
|
146
|
+
log_message("Received: #{line}")
|
|
147
|
+
|
|
148
|
+
# Display formatted message based on type
|
|
149
|
+
case message["type"]
|
|
150
|
+
when "broadcast"
|
|
151
|
+
puts "\r#{message['data']['from']}: #{message['data']['content']}"
|
|
152
|
+
when "direct_message"
|
|
153
|
+
puts "\r[DM] #{message['data']['from']}: #{message['data']['content']}"
|
|
154
|
+
when "server_message"
|
|
155
|
+
puts "\r[Server] #{message['data']['message']}"
|
|
156
|
+
when "user_list"
|
|
157
|
+
puts "\r[Server] Users online: #{message['data']['users'].join(', ')}"
|
|
158
|
+
when "error"
|
|
159
|
+
puts "\r[Error] #{message['data']['message']}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Reprint the prompt
|
|
163
|
+
print "(#{@username})> "
|
|
164
|
+
$stdout.flush
|
|
165
|
+
rescue StandardError => e
|
|
166
|
+
log_message("Error handling server message: #{e.message}")
|
|
167
|
+
# Don't immediately break for connection errors, may be temporary
|
|
168
|
+
# Just log and continue, IO.select will catch closed connections
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def run_with_messages(messages, _delay_between_messages = 1)
|
|
172
|
+
return false unless @socket && @running
|
|
173
|
+
|
|
174
|
+
log_message("Running with #{messages.size} predefined messages")
|
|
175
|
+
puts "Sending #{messages.size} predefined messages"
|
|
176
|
+
|
|
177
|
+
# Send all messages in a non-blocking way
|
|
178
|
+
batch_send_messages(messages)
|
|
179
|
+
|
|
180
|
+
log_message("Finished sending all predefined messages")
|
|
181
|
+
|
|
182
|
+
# Start the event loop to receive responses
|
|
183
|
+
main_event_loop
|
|
184
|
+
|
|
185
|
+
true
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Helper to send a batch of messages without blocking
|
|
189
|
+
def batch_send_messages(messages)
|
|
190
|
+
messages.each_with_index do |msg, index|
|
|
191
|
+
content = msg[:content]
|
|
192
|
+
recipient = msg[:recipient] || "all"
|
|
193
|
+
|
|
194
|
+
log_message("Sending message #{index + 1}: '#{content}' to #{recipient}")
|
|
195
|
+
|
|
196
|
+
message_data = {
|
|
197
|
+
type: "message",
|
|
198
|
+
data: {
|
|
199
|
+
content: content,
|
|
200
|
+
recipient: recipient,
|
|
201
|
+
},
|
|
202
|
+
timestamp: Time.now.to_i,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@socket.puts(JSON.generate(message_data))
|
|
206
|
+
log_message("Sent message to #{recipient}: #{content}")
|
|
207
|
+
|
|
208
|
+
# Small delay between messages for stability
|
|
209
|
+
sleep(0.1)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def disconnect
|
|
214
|
+
return unless @running
|
|
215
|
+
|
|
216
|
+
@running = false
|
|
217
|
+
log_message("Disconnecting from server")
|
|
218
|
+
|
|
219
|
+
if @socket && !@socket.closed?
|
|
220
|
+
# Send leave message
|
|
221
|
+
leave_data = {
|
|
222
|
+
type: "leave",
|
|
223
|
+
data: {
|
|
224
|
+
username: @username,
|
|
225
|
+
},
|
|
226
|
+
timestamp: Time.now.to_i,
|
|
227
|
+
}
|
|
228
|
+
@socket.puts(JSON.generate(leave_data))
|
|
229
|
+
log_message("Sent leave message")
|
|
230
|
+
|
|
231
|
+
@socket.close
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
@log_file&.close
|
|
235
|
+
|
|
236
|
+
puts "Disconnected from server."
|
|
237
|
+
true
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
def log_message(message)
|
|
243
|
+
timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S.%L")
|
|
244
|
+
log_entry = "[#{timestamp}] #{message}"
|
|
245
|
+
|
|
246
|
+
@log_file.puts(log_entry)
|
|
247
|
+
@log_file.flush # Ensure it's written immediately
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# When run directly, start the client
|
|
253
|
+
if __FILE__ == $PROGRAM_NAME
|
|
254
|
+
require "fileutils"
|
|
255
|
+
require "json"
|
|
256
|
+
|
|
257
|
+
puts "Chat Client"
|
|
258
|
+
puts "==========="
|
|
259
|
+
puts "This is the chat client that connects to the chat server."
|
|
260
|
+
puts "All messages are logged to a file for later analysis."
|
|
261
|
+
puts
|
|
262
|
+
|
|
263
|
+
# Get username from command line or prompt
|
|
264
|
+
username = ARGV[0]
|
|
265
|
+
|
|
266
|
+
unless username
|
|
267
|
+
print "Enter your username: "
|
|
268
|
+
username = gets.chomp
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Get port from command line or use default
|
|
272
|
+
port = ARGV[1]&.to_i || 3000
|
|
273
|
+
log_file = ARGV[2] || "logs/client_#{username}_messages.log"
|
|
274
|
+
|
|
275
|
+
# Check for messages file
|
|
276
|
+
messages_file = "logs/client_#{username}_send_messages.json"
|
|
277
|
+
|
|
278
|
+
# Create and run the client
|
|
279
|
+
client = ContinuousChat::ChatClient.new(username, "localhost", port, log_file)
|
|
280
|
+
|
|
281
|
+
if client.connect
|
|
282
|
+
begin
|
|
283
|
+
# Load and send messages if the file exists
|
|
284
|
+
if File.exist?(messages_file)
|
|
285
|
+
puts "Loading messages from #{messages_file}"
|
|
286
|
+
messages = JSON.parse(File.read(messages_file), symbolize_names: true)
|
|
287
|
+
puts "Loaded #{messages.size} messages"
|
|
288
|
+
|
|
289
|
+
# Send the messages
|
|
290
|
+
client.run_with_messages(messages)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Start the client
|
|
294
|
+
client.start
|
|
295
|
+
rescue Interrupt
|
|
296
|
+
puts "\nClient interrupted."
|
|
297
|
+
ensure
|
|
298
|
+
client.disconnect
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
puts "Chat client exited."
|
|
303
|
+
end
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "socket"
|
|
5
|
+
require "json"
|
|
6
|
+
require "time"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
require_relative "../continuous_chat_common/message_protocol"
|
|
9
|
+
|
|
10
|
+
# Simple Chat Server using Ruby's standard socket library
|
|
11
|
+
# Based on the approach from https://dev.to/aurelieverrot/create-a-chat-in-the-command-line-with-ruby-2po9
|
|
12
|
+
# but modified to use JSON for message passing.
|
|
13
|
+
puts "Starting chat server..."
|
|
14
|
+
|
|
15
|
+
# Parse command line args
|
|
16
|
+
port = ARGV[0]&.to_i || 3000
|
|
17
|
+
log_file_path = ARGV[1] || "logs/server_messages.log"
|
|
18
|
+
|
|
19
|
+
# Create logs directory if it doesn't exist
|
|
20
|
+
FileUtils.mkdir_p(File.dirname(log_file_path))
|
|
21
|
+
log_file = File.open(log_file_path, "w")
|
|
22
|
+
|
|
23
|
+
def log_message(message, log_file)
|
|
24
|
+
timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S.%L")
|
|
25
|
+
log_entry = "[#{timestamp}] #{message}"
|
|
26
|
+
|
|
27
|
+
# Check if log file is still open before writing
|
|
28
|
+
if log_file && !log_file.closed?
|
|
29
|
+
log_file.puts(log_entry)
|
|
30
|
+
log_file.flush # Ensure it's written immediately
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Also print to console for debugging
|
|
34
|
+
puts log_entry
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Create the server socket
|
|
38
|
+
server = TCPServer.new("0.0.0.0", port)
|
|
39
|
+
log_message("Server started on port #{port}", log_file)
|
|
40
|
+
puts "Server bound to port #{port}"
|
|
41
|
+
|
|
42
|
+
# Array to store connected clients
|
|
43
|
+
clients = {}
|
|
44
|
+
|
|
45
|
+
# Broadcast a message to all clients
|
|
46
|
+
def announce_to_everyone(clients, message, log_file)
|
|
47
|
+
log_message("Broadcasting: #{message}", log_file)
|
|
48
|
+
|
|
49
|
+
# Convert to JSON if it's not already a string
|
|
50
|
+
message_json = message.is_a?(String) ? message : JSON.generate(message)
|
|
51
|
+
|
|
52
|
+
# Create a copy of the clients hash to avoid modification during iteration
|
|
53
|
+
clients_copy = clients.dup
|
|
54
|
+
|
|
55
|
+
clients_copy.each do |username, client|
|
|
56
|
+
client.puts(message_json)
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
log_message("Error broadcasting to #{username}: #{e.message}", log_file)
|
|
59
|
+
# We don't disconnect here, that's handled in the client thread
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Handle a new client joining the chat
|
|
64
|
+
def handle_new_client(clients, client, log_file)
|
|
65
|
+
line = client.gets&.chomp
|
|
66
|
+
return client.close unless line
|
|
67
|
+
|
|
68
|
+
log_message("Received join message: #{line}", log_file)
|
|
69
|
+
|
|
70
|
+
# Parse the join message
|
|
71
|
+
message = JSON.parse(line)
|
|
72
|
+
|
|
73
|
+
if message["type"] == "join" && message["data"] && message["data"]["username"]
|
|
74
|
+
username = message["data"]["username"]
|
|
75
|
+
|
|
76
|
+
# Store the client with username as key
|
|
77
|
+
clients[username] = client
|
|
78
|
+
|
|
79
|
+
# Send welcome message to the client
|
|
80
|
+
welcome_msg = {
|
|
81
|
+
type: "server_message",
|
|
82
|
+
data: {
|
|
83
|
+
message: "Hello #{username}! Connected clients: #{clients.count}",
|
|
84
|
+
},
|
|
85
|
+
timestamp: Time.now.to_i,
|
|
86
|
+
}
|
|
87
|
+
client.puts(JSON.generate(welcome_msg))
|
|
88
|
+
|
|
89
|
+
# Broadcast to all clients that a new user joined
|
|
90
|
+
join_msg = {
|
|
91
|
+
type: "server_message",
|
|
92
|
+
data: {
|
|
93
|
+
message: "#{username} joined the chat!",
|
|
94
|
+
},
|
|
95
|
+
timestamp: Time.now.to_i,
|
|
96
|
+
}
|
|
97
|
+
announce_to_everyone(clients, join_msg, log_file)
|
|
98
|
+
|
|
99
|
+
# Broadcast the updated user list
|
|
100
|
+
user_list_msg = {
|
|
101
|
+
type: "user_list",
|
|
102
|
+
data: {
|
|
103
|
+
users: clients.keys,
|
|
104
|
+
},
|
|
105
|
+
timestamp: Time.now.to_i,
|
|
106
|
+
}
|
|
107
|
+
announce_to_everyone(clients, user_list_msg, log_file)
|
|
108
|
+
|
|
109
|
+
true
|
|
110
|
+
else
|
|
111
|
+
# Invalid join message
|
|
112
|
+
error_msg = {
|
|
113
|
+
type: "error",
|
|
114
|
+
data: {
|
|
115
|
+
message: "First message must be a valid join command",
|
|
116
|
+
},
|
|
117
|
+
timestamp: Time.now.to_i,
|
|
118
|
+
}
|
|
119
|
+
client.puts(JSON.generate(error_msg))
|
|
120
|
+
client.close
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
rescue JSON::ParserError => e
|
|
124
|
+
log_message("Error parsing initial join message: #{e.message}", log_file)
|
|
125
|
+
client.puts(JSON.generate({
|
|
126
|
+
type: "error",
|
|
127
|
+
data: {
|
|
128
|
+
message: "Invalid JSON format in join message",
|
|
129
|
+
},
|
|
130
|
+
timestamp: Time.now.to_i,
|
|
131
|
+
}))
|
|
132
|
+
client.close
|
|
133
|
+
false
|
|
134
|
+
rescue StandardError => e
|
|
135
|
+
log_message("Error handling new client: #{e.message}", log_file)
|
|
136
|
+
client.close
|
|
137
|
+
false
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Process a message from an existing client
|
|
141
|
+
def process_client_message(clients, client, username, log_file)
|
|
142
|
+
# Read a message from the client (non-blocking with timeout)
|
|
143
|
+
readable, = IO.select([client], nil, nil, 0)
|
|
144
|
+
return true unless readable # No data to read yet
|
|
145
|
+
|
|
146
|
+
line = client.gets&.chomp
|
|
147
|
+
return false unless line # Client disconnected
|
|
148
|
+
|
|
149
|
+
begin
|
|
150
|
+
message = JSON.parse(line)
|
|
151
|
+
log_message("Received from #{username}: #{line}", log_file)
|
|
152
|
+
|
|
153
|
+
case message["type"]
|
|
154
|
+
when "message"
|
|
155
|
+
content = message["data"]["content"]
|
|
156
|
+
recipient = message["data"]["recipient"] || "all"
|
|
157
|
+
|
|
158
|
+
if content.start_with?("/")
|
|
159
|
+
# Handle commands
|
|
160
|
+
case content
|
|
161
|
+
when "/list"
|
|
162
|
+
list_msg = {
|
|
163
|
+
type: "user_list",
|
|
164
|
+
data: {
|
|
165
|
+
users: clients.keys,
|
|
166
|
+
},
|
|
167
|
+
timestamp: Time.now.to_i,
|
|
168
|
+
}
|
|
169
|
+
client.puts(JSON.generate(list_msg))
|
|
170
|
+
else
|
|
171
|
+
# Unknown command
|
|
172
|
+
client.puts(JSON.generate({
|
|
173
|
+
type: "error",
|
|
174
|
+
data: {
|
|
175
|
+
message: "Unknown command: #{content}",
|
|
176
|
+
},
|
|
177
|
+
timestamp: Time.now.to_i,
|
|
178
|
+
}))
|
|
179
|
+
end
|
|
180
|
+
elsif recipient == "all"
|
|
181
|
+
# Broadcast message
|
|
182
|
+
broadcast_msg = {
|
|
183
|
+
type: "broadcast",
|
|
184
|
+
data: {
|
|
185
|
+
from: username,
|
|
186
|
+
content: content,
|
|
187
|
+
},
|
|
188
|
+
timestamp: Time.now.to_i,
|
|
189
|
+
}
|
|
190
|
+
announce_to_everyone(clients, broadcast_msg, log_file)
|
|
191
|
+
elsif clients[recipient]
|
|
192
|
+
# Direct message
|
|
193
|
+
dm_msg = {
|
|
194
|
+
type: "direct_message",
|
|
195
|
+
data: {
|
|
196
|
+
from: username,
|
|
197
|
+
content: content,
|
|
198
|
+
},
|
|
199
|
+
timestamp: Time.now.to_i,
|
|
200
|
+
}
|
|
201
|
+
clients[recipient].puts(JSON.generate(dm_msg))
|
|
202
|
+
# Also send to sender if not the same person
|
|
203
|
+
client.puts(JSON.generate(dm_msg)) if username != recipient
|
|
204
|
+
else
|
|
205
|
+
# Recipient not found
|
|
206
|
+
client.puts(JSON.generate({
|
|
207
|
+
type: "error",
|
|
208
|
+
data: {
|
|
209
|
+
message: "User #{recipient} not found",
|
|
210
|
+
},
|
|
211
|
+
timestamp: Time.now.to_i,
|
|
212
|
+
}))
|
|
213
|
+
end
|
|
214
|
+
when "leave"
|
|
215
|
+
# Client wants to leave
|
|
216
|
+
return false
|
|
217
|
+
end
|
|
218
|
+
rescue JSON::ParserError => e
|
|
219
|
+
log_message("Error parsing JSON from #{username}: #{e.message}", log_file)
|
|
220
|
+
client.puts(JSON.generate({
|
|
221
|
+
type: "error",
|
|
222
|
+
data: {
|
|
223
|
+
message: "Invalid JSON format",
|
|
224
|
+
},
|
|
225
|
+
timestamp: Time.now.to_i,
|
|
226
|
+
}))
|
|
227
|
+
rescue StandardError => e
|
|
228
|
+
log_message("Error processing message from #{username}: #{e.message}",
|
|
229
|
+
log_file)
|
|
230
|
+
return false
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
true # Client still connected
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Handle client disconnection
|
|
237
|
+
def handle_client_disconnect(clients, client, log_file)
|
|
238
|
+
username = clients.key(client)
|
|
239
|
+
return unless username
|
|
240
|
+
|
|
241
|
+
# Remove from clients list
|
|
242
|
+
clients.delete(username)
|
|
243
|
+
log_message("Client disconnected: #{username}", log_file)
|
|
244
|
+
|
|
245
|
+
# Notify everyone
|
|
246
|
+
leave_msg = {
|
|
247
|
+
type: "server_message",
|
|
248
|
+
data: {
|
|
249
|
+
message: "#{username} left the chat.",
|
|
250
|
+
},
|
|
251
|
+
timestamp: Time.now.to_i,
|
|
252
|
+
}
|
|
253
|
+
announce_to_everyone(clients, leave_msg, log_file)
|
|
254
|
+
|
|
255
|
+
# Update user list
|
|
256
|
+
user_list_msg = {
|
|
257
|
+
type: "user_list",
|
|
258
|
+
data: {
|
|
259
|
+
users: clients.keys,
|
|
260
|
+
},
|
|
261
|
+
timestamp: Time.now.to_i,
|
|
262
|
+
}
|
|
263
|
+
announce_to_everyone(clients, user_list_msg, log_file)
|
|
264
|
+
|
|
265
|
+
# Close the client socket
|
|
266
|
+
begin
|
|
267
|
+
client.close
|
|
268
|
+
rescue StandardError
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Main server loop
|
|
274
|
+
begin
|
|
275
|
+
log_message("Server ready to accept connections", log_file)
|
|
276
|
+
|
|
277
|
+
# Add the server socket to the list of sockets to monitor
|
|
278
|
+
sockets = [server]
|
|
279
|
+
|
|
280
|
+
# Main event loop
|
|
281
|
+
loop do
|
|
282
|
+
# Use IO.select to check which sockets have data to read
|
|
283
|
+
# This is non-blocking and allows us to handle multiple clients sequentially
|
|
284
|
+
readable, _, errored = IO.select(sockets, [], sockets, 0.1)
|
|
285
|
+
|
|
286
|
+
# Handle errors first
|
|
287
|
+
if errored && !errored.empty?
|
|
288
|
+
errored.each do |socket|
|
|
289
|
+
raise "Server socket error" if socket == server
|
|
290
|
+
|
|
291
|
+
# Server socket error - critical
|
|
292
|
+
|
|
293
|
+
# Client socket error
|
|
294
|
+
username = clients.key(socket)
|
|
295
|
+
log_message("Error on client socket: #{username || 'unknown'}",
|
|
296
|
+
log_file)
|
|
297
|
+
handle_client_disconnect(clients, socket, log_file)
|
|
298
|
+
sockets.delete(socket)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Nothing to process this iteration
|
|
303
|
+
next unless readable
|
|
304
|
+
|
|
305
|
+
# Process readable sockets
|
|
306
|
+
readable.each do |socket|
|
|
307
|
+
if socket == server
|
|
308
|
+
# New client connection
|
|
309
|
+
begin
|
|
310
|
+
client = server.accept
|
|
311
|
+
log_message(
|
|
312
|
+
"New client connection from #{client.peeraddr[2]}:#{client.peeraddr[1]}", log_file
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Add the client socket to our monitoring list
|
|
316
|
+
sockets << client
|
|
317
|
+
|
|
318
|
+
# We'll process the initial join message in the next iteration
|
|
319
|
+
rescue StandardError => e
|
|
320
|
+
log_message("Error accepting client: #{e.message}", log_file)
|
|
321
|
+
end
|
|
322
|
+
elsif clients.key(socket)
|
|
323
|
+
# Existing client sent data
|
|
324
|
+
username = clients.key(socket)
|
|
325
|
+
|
|
326
|
+
# Process message, remove client if it disconnected
|
|
327
|
+
unless process_client_message(clients, socket, username, log_file)
|
|
328
|
+
handle_client_disconnect(clients, socket, log_file)
|
|
329
|
+
sockets.delete(socket)
|
|
330
|
+
end
|
|
331
|
+
else
|
|
332
|
+
# This is a new client that needs to send their join message
|
|
333
|
+
unless handle_new_client(clients, socket, log_file)
|
|
334
|
+
# Join failed, remove from sockets
|
|
335
|
+
sockets.delete(socket)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
rescue Interrupt
|
|
341
|
+
log_message("Server interrupted, shutting down...", log_file)
|
|
342
|
+
rescue StandardError => e
|
|
343
|
+
log_message("Server error: #{e.message}", log_file)
|
|
344
|
+
ensure
|
|
345
|
+
# Close all client connections
|
|
346
|
+
clients.each_value do |client|
|
|
347
|
+
client.close
|
|
348
|
+
rescue StandardError
|
|
349
|
+
# Ignore errors when closing
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Close the server socket
|
|
353
|
+
server&.close
|
|
354
|
+
|
|
355
|
+
# Close the log file
|
|
356
|
+
log_file&.close
|
|
357
|
+
|
|
358
|
+
log_message("Server stopped", log_file)
|
|
359
|
+
end
|