Hey there! Last time, we stopped after implementing the list
command, which prints a list of all the users connected to the server. As a part of that, we also did a lot of bug squashing. Today weāre gonna tackle the rest of the commands mentioned in last log, starting with glide
to send a share request to someone. But before that we should probably do some
Structuring š·š¾āāļø!
Letās start by making enum variants, and a method to parse a command into one of those enum variants. This can help us evaluate the commands.
enum Command {
List,
Requests,
Glide { path: String, to: String },
Ok(String),
No(String),
Help(Option<String>),
InvalidCommand(String),
}
impl Command {
fn parse(input: &str) -> Command {
let glide_re = Regex::new(r"^glide\s+(.+)\s+@(.+)$").unwrap();
let ok_re = Regex::new(r"^ok\s+@(.+)$").unwrap();
let no_re = Regex::new(r"^no\s+@(.+)$").unwrap();
let help_re = Regex::new(r"^help(?:\s+(.+))?$").unwrap();
if input == "list" {
Command::List
} else if input == "reqs" {
Command::Requests
} else if let Some(caps) = glide_re.captures(input) {
let path = caps[1].to_string();
let to = caps[2].to_string();
Command::Glide { path, to }
} else if let Some(caps) = ok_re.captures(input) {
let username = caps[1].to_string();
Command::Ok(username)
} else if let Some(caps) = no_re.captures(input) {
let username = caps[1].to_string();
Command::No(username)
} else if let Some(caps) = help_re.captures(input) {
let command = caps.get(1).map(|m| m.as_str().to_string());
Command::Help(command)
} else {
Command::InvalidCommand(input.to_string())
}
}
fn get_str(&self) -> Result<String, String> {
Ok(match self {
Command::List => "list".to_string(),
Command::Requests => "reqs".to_string(),
Command::Glide { path, to } => format!("glide {} @{}", path, to),
Command::Ok(user) => format!("ok @{}", user),
Command::No(user) => format!("no @{}", user),
Command::Help(command) => {
format!("help {}", command.as_ref().unwrap_or(&String::new()))
.trim()
.to_string()
}
Command::InvalidCommand(s) => return Err(s.to_string()),
})
}
}
I have also implemented a get_str
method which converts a Command
back to a String
. It is not immediately useful to us, but I have gut feeling that it will come in handy. Now letās implement this logic in our handle_commands
function.
async fn handle_command(
command: &str,
_username: &str,
socket: &mut TcpStream,
state: &SharedState,
) -> Result<(), Box<dyn std::error::Error>> {
let command = Command::parse(command);
match command {
Command::List => {
// -- snip --
}
Command::Requests => todo!(),
Command::Glide { path, to } => todo!(),
Command::Ok(user) => todo!(),
Command::No(user) => todo!(),
Command::Help(cmd) => {
// -- snip --
}
Command::InvalidCommand(cmd) => {
let response = format!(
"Unknown command: {}\nType 'help' for available commands.",
cmd,
);
socket.write_all(response.as_bytes()).await?;
}
}
Ok(())
}
And now, we are back on track! See commit for more details
Adding Commands
Letās get to adding the rest of the commands now!
Command - reqs
So you may have noticed this reqs
or Requests
command. It wasnāt mentioned in the commands description, but the purpose of this command will be to see if we have any requests coming to us. But for that we need to keep track of all the requests right? So letās take a
Detour - Modifying State to Handle Requests
Right now, our shared state looks something like
type SharedState = Arc<Mutex<HashMap<String, String>>>;
So letās modify the value of the hash map to be a custom Request
struct which can handle both the socket and requests. But wait, should it be outgoing or incoming requests? With how small scale our project is, and our current CLI structureās limitations, we wont be having more than one request at a time from one user, so it doesnāt matter. But this is purely a problem with our client application. Whatās the problem? Well, skill issue š¬. Right now in the client, our inputs and outputs are blocking, meaning, when weāre waiting for input we canāt run anything in the background until the input is submitted, meaning, we canāt notify the users of a new request (hence the reqs
command), nor can we notify the sender that their request. So, for now, our client application is a bit lacking.
We could receive many requests and have the receiver check with reqs
, but thereās no reliable way to notify the sender that their requests have been accepted or rejected. Unless we show both incoming and outgoing requests with reqs
. Progress bars on the senders side is out of question though. I will cook up something with the crossterm
crate soon for a better client, but for now, this is what we have.
So letās consider that yes there can be multiple requests, as the number of users increase, viewing and accepting requests become far easier if we store incoming requests instead, because look up time for a user is constant (because of the HashMap
), and the receiver only has to check itās requests list instead of looking at every other user.
struct Request {
from_username: String,
filename: String,
size: u64,
}
struct UserData {
socket: String,
incoming_requests: Vec<Request>,
}
type SharedState = Arc<Mutex<HashMap<String, UserData>>>;
Here we have a HashMap
which maps usernames to UserData
. Now letās add a method to register a new user, and also modify remove_client
to also remove all outgoing requests the user may have sent.
async fn add_client(
username: &str,
socket: &mut TcpStream,
state: &SharedState,
) -> Result<(), Box<dyn std::error::Error>> {
let mut clients = state.lock().await;
clients.insert(
username.to_string(),
UserData {
socket: socket.peer_addr()?.to_string(),
incoming_requests: vec![],
},
);
Ok(())
}
async fn remove_client(username: &str, state: &SharedState) {
let mut clients = state.lock().await;
// Remove the client
clients.remove(username);
// Collect requests to be removed
let mut to_remove = Vec::new();
for (user, client) in clients.iter() {
for (i, req) in client.incoming_requests.iter().enumerate() {
if req.from_username == username {
to_remove.push((user.clone(), i));
}
}
}
// Remove the collected requests
for (user, index) in to_remove {
if let Some(client) = clients.get_mut(&user) {
client.incoming_requests.remove(index);
}
}
println!("Client @{} disconnected", username);
}
See commit for more details.
Roadblock
Wait. What good is the reqs
command without any way to give new requests to users?? š¬. My bad, guys. So we will have to implement the glide
command first. So letās get that done real quick
Command - glide path/to/file @username
So the glide command takes 2 arguments, path/to/file
and @username
. So we need to validate
- If the file exists
- If the recipient exists
So whatāre we waiting for?
async fn handle_command(
command: &str,
username: &str,
socket: &mut TcpStream,
state: &SharedState,
) -> Result<(), Box<dyn std::error::Error>> {
let command = Command::parse(command);
match command {
Command::List => {
// -- snip --
}
Command::Requests => todo!(),
Command::Glide { path, to } => {
socket
.write_all(cmd_glide(state, username, &path, &to).await.as_bytes())
.await?
}
Command::Ok(user) => todo!(),
Command::No(user) => todo!(),
Command::Help(_) => {
// --snip--
}
Command::InvalidCommand(cmd) => {
// --snip--
}
}
Ok(())
}
async fn cmd_glide(state: &SharedState, from: &str, path: &str, to: &str) -> String {
// Check if file exists
if !Path::new(path).exists() && fs::metadata(&path).unwrap().is_file() {
return format!("Path '{}' is invalid. File does not exist", path);
}
// Check if user exists
let mut clients = state.lock().await;
if !clients.contains_key(to) {
return format!("User @{} does not exist", to);
}
let file_size = fs::metadata(&path).unwrap().size();
// Add request
clients
.get_mut(to)
.unwrap()
.incoming_requests
.push(Request {
from_username: from.to_string(),
filename: path.to_string(),
size: file_size,
});
format!("Successfully sent share request to @{} for {}", to, path)
}
As easy as that we have the glide
command all ready to go. Shall we test this out? Itās been a while since weāve done some testing hasnāt it. Here goes nothing!
Server is running on 127.0.0.1:8080
New connection from: 127.0.0.1:63593
Connected to server!
Enter your username: nandu
Uhh? The server seems unresponsive to us sending the username through. I suspect an infinite loop somewhere on the server. Because even after the client disconnects, thereās no response on the sever. So that specific thread on the server must be occupied by something. Time to put our debugging caps on š©š¤Ø.
Itās debugging time.
After some debug statements in the client code
async fn main() -> Result<...> {
dbg!("username validated");
// Send the username to the server
stream.write_all(username.as_bytes()).await?;
dbg!("username sent");
// Wait for the server's response
let mut response = vec![0; CHUNK_SIZE];
let bytes_read = stream.read(&mut response).await?;
if bytes_read == 0 {
println!("Server disconnected unexpectedly.");
return Err("Connection closed by the server".into());
}
dbg!("Server responded");
let response_str = String::from_utf8_lossy(&response[..bytes_read])
.trim()
.to_string();
}
and examining the output,
Connected to server!
Enter your username: nandu
[glide-client/src/main.rs:38:9] "username validated" = "username validated"
[glide-client/src/main.rs:43:9] "username sent" = "username sent"
we can confirm that our suspicions are correct!
async fn handle_client(
socket: &mut TcpStream,
state: SharedState,
) -> Result<(), Box<dyn std::error::Error>> {
let mut buffer = vec![0; CHUNK_SIZE];
let mut username = String::new();
// Loop until a valid username is provided
loop {
let bytes_read = socket.read(&mut buffer).await?;
if bytes_read == 0 {
return Ok(()); // Client disconnected
}
dbg!(bytes_read);
username.clear();
username.push_str(
&String::from_utf8_lossy(&buffer[..bytes_read])
.trim()
.to_string(),
);
dbg!("username read", &username);
// Check if the username is valid and available
let response = {
let clients = state.lock().await;
if !validate_username(&username) {
"INVALID_USERNAME"
} else if clients.contains_key(&username) {
"USERNAME_TAKEN"
} else {
add_client(&username, socket, &state).await?;
"OK"
}
};
dbg!(&response);
// Send the response to the client
socket.write_all(response.as_bytes()).await?;
if response == "OK" {
println!("Client @{} connected", username);
break;
}
}
// --snip--
}
with the help of these debug statements, and this output,
Server is running on 127.0.0.1:8080
New connection from: 127.0.0.1:63640
[glide-server/src/main.rs:200:9] bytes_read = 5
[glide-server/src/main.rs:209:9] "username read" = "username read"
[glide-server/src/main.rs:209:9] &username = "nandu"
I think itās fair to assume the problem is somewhere in add_client
. Letās head over there and see how things are.
After messing around in there I realized that let mut clients = state.lock().await;
is not returning anything. Why? Well, I didnāt know either. Iām pretty new to async programming, but after enough head scratches and googling, I found that
// Check if the username is valid and available
let response = {
let clients = state.lock().await;
if !validate_username(&username) {
"INVALID_USERNAME"
} else if clients.contains_key(&username) {
"USERNAME_TAKEN"
} else {
add_client(&username, socket, &state).await?;
"OK"
}
};
the state.lock()
in handle_clients
, blocks state
from being accessed in add_client
. Basically, we donāt want multiple asynchronous functions to access the same variable to eliminate race conditions. So after adding a drop(clients)
before calling add_client
, Everything seems to be working! Well,
Server is running on 127.0.0.1:8080
New connection from: 127.0.0.1:63717
Client @nandu connected
New connection from: 127.0.0.1:63720
Client @nandu2 connected
Connected to server!
Enter your username: nandu2
You are now connected as @nandu2
Type 'help' to see available commands.
glide> list
Connected users:
@nandu2
@nandu
glide> glide src/main.rs @nandu
Successfully sent share request to @nandu for src/main.rs
glide>
Woo! Debugging sure is fun, huh? *he said, with eyes so sleepless they might just fall off*
Everything seems to be in order. Right? No. Why? Simple, how do we know without a reqs
command??? So letās get to
Finally implementing the reqs
command
async fn cmd_reqs(state: &SharedState, username: &str) -> String {
let clients = state.lock().await;
let incoming_user_list: Vec<String> = clients
.get(username)
.unwrap()
.incoming_requests
.iter()
.map(|x| {
format!(
" @{}, file: {}, size: {} bytes",
x.from_username, x.filename, x.size,
)
})
.collect();
if incoming_user_list.is_empty() {
"No incoming requests".to_string()
} else {
format!("Incoming requests:\n{}", incoming_user_list.join("\n"))
}
}
With this simple function, we can wrap up the reqs
command. Letās test it š¤š¾.
Connected to server!
Enter your username: nandu2
You are now connected as @nandu2
Type 'help' to see available commands.
glide> list
Connected users:
@nandu
@nandu2
glide> reqs
No incoming requests
glide> glide src/main.rs @nandu
Successfully sent share request to @nandu for src/main.rs
glide> exit
Thank you for using Glide. Goodbye!
And for the moment of truthā¦
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> reqs
Incoming requests:
nandu2, file: src/main.rs, size: 9509 bytes
glide> reqs
No incoming requests
Letās GOOOOOOOOOOOOOOOOOOO! Everything seems to be in order. remove_clients
is even deleting the requests when users exit! Letās end this on a high note.
Thoughts
Iāve been working on this one for the past couple days, admittedly. Uni and personal life took up a lot of my time, and just straight up lethargy, has held me back from publishing daily. But todayās was a good one, got work done, but actual file transfer keeps getting pushed further and further back as new things come along haha. Weāll get to it on the next one, no promises though. Haha. Thank you for your patience, and have a good one! Seeya soon.