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.

glide-server/src/main.rs 10:0
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

glide-server/src/main.rs 34:0
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:

glide-server/src/main.rs
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 and no @username - To accept or reject an incoming request from @username.
  • help <command> - A help command with an option command 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 🤫.
glide-client/src/main.rs
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.

glide-client/src/main.rs
#[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.

glide-server/src/main.rs
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!

client
Connected to server!
Enter your username: nandu
Type 'help' to see available commands.
glide> 

Everything looks good at the client side…

server
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.

glide-client/src/main 40:0
stream.write_all(username.as_bytes()).await?;

That’s it. Simple change. Lets see how the client sends the same inputs now.

client
Connected to server!
Enter your username: nandu
Type 'help' to see available commands.
glide> list
Welcome to the server!
 
glide> 
server
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.

client
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!
client
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.

glide-server/src/main.rs
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
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

glide-server/src/main
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:

client 1
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> 
client 2
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
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!