Merry Christmas 🎄🎁! It has been a hot minute since we last worked on the project, where we restructured the whole project for future-proofing. Warning: This one’s a doozy. Today I plan on tackling connecting multiple clients and sharing files between them. But why?
Why? 🤔
This is a pretty good representation of how our app works right now. We can definitely connect multiple clients to the same server. But all of our communications are one-way, i.e. the client can send files to the server, but not the other way around. If you think about it, this is a pretty crappy file sharing app in all aspects.
Either we could have the server just store files, which we can do now, but what good is storing files if you can’t access them when you need them? Another way we could go from here is to have the server act as a bridge between two clients, but right now, the bridge only goes one way. So either way the situation right now is not ideal.
We are going with the latter, having the server act as a bridge between two clients sharing files with each other.
So where do we start?
Tracking all the connected clients 📋
We can use an Arc<Mutex<HashMap<String, TcpStream>>>
to keep track of all the usernames and their respective sockets.
type SharedState = Arc<Mutex<HashMap<String, String>>>;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("Server is running on 127.0.0.1:8080");
// Shared state for tracking connected clients
let state: SharedState = Arc::new(Mutex::new(HashMap::new()));
loop {
let (mut socket, addr) = listener.accept().await?;
let state = Arc::clone(&state); // Clone the state for each connection
println!("New connection from: {}", addr);
tokio::spawn(async move {
if let Err(e) = handle_client(&mut socket, state).await {
eprintln!("Error handling client {}: {}", addr, e);
}
});
}
}
Next we can modify handle_client
to register the user
async fn handle_client(
socket: &mut TcpStream,
state: SharedState,
) -> Result<(), Box<dyn std::error::Error>> {
let mut buffer = vec![0; CHUNK_SIZE];
// Step 1: Register the client with a username
let bytes_read = socket.read(&mut buffer).await?;
if bytes_read == 0 {
return Ok(()); // Client disconnected
}
// Extract the username
let username =
String::from_utf8_lossy(&buffer[..bytes_read]).trim().to_string();
{
let mut clients = state.lock().await;
if clients.contains_key(&username) {
socket.write_all(b"Username already taken").await?;
return Err("Username conflict".into());
}
clients.insert(username.clone(), socket.peer_addr()?.to_string());
}
println!("Client '{}' connected", username);
socket.write_all(b"Welcome to the server!\n").await?;
// Snip Snip
}
Let’s also implement a function to remove a client:
async fn remove_client(username: &str, state: &SharedState) {
let mut clients = state.lock().await;
clients.remove(username);
println!("Client '{}' disconnected", username);
}
See the commit on GitHub for clarifications.
Commands
I’m a user, trying to send a very important file to a friend of mine. We both hop on the terminal and type in glide @nandu
and we’re in the app! I get greeted by a message that tells me I’m connected to the server as @nandu
. I type in list
to see the list of all users connected to the server. I find my friend’s username within the list, I type in glide filename.ext @friend
. On my friends screen they see, @nandu wants to share 'filename.ext', XX Bytes with you. Type 'ok @nandu' to recieve the file. Type 'no @nandu' to reject the request
. My friend types in ok @nandu
and they start receiving the file! Easy as that!
This is how I want a usual interaction on Glide to go. There could be a few changes as we go, but this is the essence of it. So we have a handful of commands to implement to allow the user to interact with the app, namely
list
- To print out the usernames of all the clients connected to the server, could hold a few filter conditions in the future.glide @username path/to/filename.ext
- To send a share request to the client connected with@username
.ok @username
andno @username
- To accept or reject an incoming request from@username
.help <command>
- A help command with an optioncommand
to show help for a specific command or help in general.
Command Support for Client
Let’s start by adding command support for the client first, then we can add commands one by one as we go, without modifying the client much.
We’ll start by adding the regex
crate and writing a validate_username
function, which returns a boolean value depending on the usernames validity. The rules I’m going to impose on the usernames are that
- At most 10 characters
- Can only contain alphanumeric characters and periods.
- Cannot start or end with periods
- No consecutive periods These rules are mostly arbitrary lol. But let that be a secret between me and you 🤫.
fn validate_username(username: &str) -> bool {
let re = Regex::new(r"^[a-zA-Z0-9](?:[a-zA-Z0-9\.]{0,8}[a-zA-Z0-9])?$").unwrap();
re.is_match(username)
}
Now lets update main
to accept a username, and commands following it.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut stream = TcpStream::connect("127.0.0.1:8080").await?;
println!("Connected to server!");
let mut username = String::new();
loop {
username.clear();
print!("Enter your username: ");
io::stdout().flush()?;
io::stdin().read_line(&mut username)?;
let username = username.trim();
if validate_username(&username) {
break;
}
println!(
"Invalid username!
Usernames must follow these rules:
• Only alphanumeric characters and periods (.) are allowed.
• Must be 1 to 10 characters long.
• Cannot start or end with a period (.).
• Cannot contain consecutive periods (..).
Please try again with a valid username."
)
}
stream
.write_all(format!("username {}\n", username).as_bytes())
.await?;
// Command loop
let stdin = io::stdin();
let mut input = String::new();
println!("Type 'help' to see available commands.");
loop {
// Get user input
input.clear();
print!("glide> ");
io::stdout().flush()?;
stdin.lock().read_line(&mut input)?;
let command = input.trim();
if command == "exit" {
println!("Thank you for using Glide. Goodbye!");
break;
}
// Send command to the server
stream
.write_all(format!("{}\n", command).as_bytes())
.await?;
// Await server response
let mut response = vec![0; CHUNK_SIZE];
let bytes_read = stream.read(&mut response).await?;
if bytes_read == 0 {
println!("Server disconnected.");
break;
}
// Print server response
let response_str = String::from_utf8_lossy(&response[..bytes_read]);
println!("{}", response_str);
}
Ok(())
}
Check out the commit on GitHub
Implementing Commands on the Server
Let’s start with seemingly the easiest command, list
which prints a list of all the users.
async fn handle_client(
socket: &mut TcpStream,
state: SharedState,
) -> Result<(), Box<dyn std::error::Error>> {
// Snip Snip
// Commands loop
loop {
let bytes_read = socket.read(&mut buffer).await?;
if bytes_read == 0 {
break;
}
let input = String::from_utf8_lossy(&buffer[..bytes_read]);
if input == "list" {
let client_list = {
let clients = state.lock().await;
clients
.keys()
.cloned()
.map(|x| format!(" @{}", x))
.collect::<Vec<_>>()
.join("\n")
};
socket
.write_all(format!("Connected users:\n {}\n", client_list).as_bytes())
.await?;
} else {
socket.write_all(b"Unknown command\n").await?;
}
remove_client(&username, &state).await;
}
Ok(())
}
Let’s remove all the file transfer logic we worked hard on, for now. Don’t worry, if we need it we can get it back from git commits, or this very log! Now for the moment we have all been waiting for. Testing!
Connected to server!
Enter your username: nandu
Type 'help' to see available commands.
glide>
Everything looks good at the client side…
Server is running on 127.0.0.1:8080
New connection from: 127.0.0.1:58118
Client '@username nandu' connected
Uh…? I know a lot of you were probably yelling at me when you noticed this error, in the code, it was a pretty obvious one, but what’s development without stupid bugs right? Lets fix this right up.
stream.write_all(username.as_bytes()).await?;
That’s it. Simple change. Lets see how the client sends the same inputs now.
Connected to server!
Enter your username: nandu
Type 'help' to see available commands.
glide> list
Welcome to the server!
glide>
Server is running on 127.0.0.1:8080
New connection from: 127.0.0.1:58128
Client '@nandu' connected
Client 'nandu' disconnected
What’s wrong now? Our culprit is the welcome message we have on line 60 in glide-server/src/main.rs
. Let’s just take it out and see if it fixes things.
Connected to server!
Enter your username: nandu
Type 'help' to see available commands.
glide> list
Connected users:
@nandu
glide> exit
Thank you for using Glide. Goodbye!
Server is running on 127.0.0.1:8080
New connection from: 127.0.0.1:58154
Client @nandu connected
Woo! Finally. The problem was a pesky newline when were sending the command to the server. But we still have one blatant issue and one that’s been hidden so far. First, our client doesn’t send a disconnect message to the server, which means the client will still be a part of the client list on the server, even after disconnecting. Another one is when accepting a username, we do nothing when the username is taken, leading to an error in the server.
Man, this one’s turning out to be longer than I expected.
Extermination Time 🐞
Disconnect Signal
We need the client to send a disconnect signal when it exits, but wait, it already does, right? Remember in Log 02, when the client disconnected, it sent 0 bytes, to combat which we added 0 byte read check? “But we have it here too, so what’s the error?” is what I thought too until I gave it a second look.
let bytes_read = socket.read(&mut buffer).await?;
if bytes_read == 0 {
break;
}
Notice anything? A serious lack of remove_client
🤦🏾♂️. And sure enough that simple addition yields us the result we’re looking for!
Server is running on 127.0.0.1:8080
New connection from: 127.0.0.1:58280
Client @nandu connected
Client @nandu disconnected
Bug 1: Exterminated 😎!
Username is Taken
We have the following logic in the server
loop {
username.clear();
print!("Enter your username: ");
io::stdout().flush()?;
io::stdin().read_line(&mut username)?;
let username = username.trim();
if validate_username(&username) {
break;
}
println!(
"Invalid username!
Usernames must follow these rules:
• Only alphanumeric characters and periods (.) are allowed.
• Must be 1 to 10 characters long.
• Cannot start or end with a period (.).
• Cannot contain consecutive periods (..).
Please try again with a valid username."
)
}
stream.write_all(username.as_bytes()).await?;
Notice how we send the username over to the server before even checking if the username is taken? Well, after a few changes and refactoring, This is what our output looks like:
Connected to server!
Enter your username: nandu
You are now connected as 'nandu'
Type 'help' to see available commands.
glide> list
Connected users:
@nandu
glide>
Enter your username: nandu
Server rejected username: USERNAME_TAKEN
Enter your username: nandu2
You are now connected as 'nandu2'
Type 'help' to see available commands.
glide> list
Connected users:
@nandu
@nandu2
glide>
Server is running on 127.0.0.1:8080
New connection from: 127.0.0.1:58431
Client 'nandu' connected
New connection from: 127.0.0.1:58432
Client 'nandu2' connected
Everything’s perfect! So let’s wrap things up for today.
Reflections
Today was a doozy. There was a lot of time spent because of all the trail and error, and things are getting harder to manage with the codebase getting bigger. So tomorrow we’re gonna do some restructuring. Thank you for having the patience for staying with me this long day. Hope you could learn something from my ramblings and bug fixes. Catch you on the next one! Happy Holidays!