The goal of this project is to develop a
client
-server
communication program using UNIX signals only.
The mandatory implementation must behave as follows:
- First the
server
must be started, which will generate apid
and print it tostdout
. - The
client
should accept two parameters:- The
pid
of theserver
; - The
message
to be sent;
- The
- The
client
must send themessage
passed in as a parameter to theserver
. - Upon receiving the
message
theserver
must print it tostdout
almost instantly. - The
server
must be able to receivemessage
s from several different clients in a row without the need for a restart. (Note that Linux systems do NOT queue signals when a signal of the same type is already pending). client
-server
communication must be done usingSIGUSR1
andSIGUSR2
signals only.
- The
server
acknowledges receiving a message by sending back a signal to theclient
. - Support Unicode characters.
For this project I chose to implement both mandatory and bonus features together. The
server
andclient
can be found in theserver.c
andclient.c
files inside thesrc
folder plus two additional filesft_sigaction.c
andft_send.c
containing helper functions.
For the sake of simplicity the program uses a custom data type t_protocol
which holds all the data the server needs to perform its operations:
typedef struct s_protocol
{
int bits; // Number of bits received
int data; // Received data (One integer and a sequence of chars)
int received; // Flag indicating if "header" data has been received
char *msg; // Received message
} t_protocol;
To implement the server's signal handling functionality I chose to use sigaction()
over signal()
.
This is because
signal()
is deprecated due to its varying behaviour across UNIX versions, making it a non-portable option.
Important
Both functions listen for a user defined signals and change their default signal actions. The main difference between these functions is that sigaction()
employs a specialized struct to store extra information, giving the user finer control over what they can do when handling a signal.
The server
's main() function declares and initializes a struct sigaction
variable called sa
.
struct sigaction sa;
sigemptyset(&sa.sa_mask);
sa.sa_sigaction = ft_server_sighandler;
sa.sa_flags = SA_SIGINFO | SA_RESTART;
sa.sa_mask
specifies a mask of signals that should be ignored;- We use
sigemptyset()
to initialize a signal setsa.sa_mask
with all signals excluded from the set; sa.sa_sigaction
is set to the functionft_server_sighandler()
;sa.sa_flags
flag set has the bits forSA_SIGINFO
andSA_RESTART
turned on;
Note
-
SA_SIGINFO
: gives the user access to extended signal information; This flag makessigaction()
switch where it looks for the custom signal handler, changing it from thesa.sa_handler
member tosa.sa_sigaction
. -
SA_RESTART
: provides BSD compatible behaviour allowing certain system calls to be restartable across signals.
The sa
struct is then passed to ft_set_sigaction()
to initialize event handling for SIGUSR1
and SIGUSR2
signals.
ft_set_sigaction(&sa);
Note
See ft_sigaction
for more details on what sigaction()
does.
Then the server
prints its pid
to stdout
and enters an infinite loop, listening for a signal to catch.
ft_print_pid();
while (1)
pause();
static void ft_server_sighandler(int sig, siginfo_t *info, void *context);
Any time
SIGUSR1
orSIGUSR2
signal is received,ft_server_sighandler()
is called.
- All its local variables are static, therefore automatically initialized to 0, except for
client_pid
, which we initialize to -1 to mean an error condition.
static t_protocol server;
static int i;
static pid_t client_pid = -1;
usleep(PAUSE);
(void)context;
-
The server signal handler waits for
PAUSE
(100 microseconds) before it starts receiving data. -
context
is type cast tovoid *
to suppress compiler warnings since we do not need to use it.
if (client_pid == -1)
client_pid = info->si_pid;
else if (client_pid != info->si_pid)
{
if (server.msg)
free(server.msg);
ft_perror_exit("Client PID does not match\n");
}
-
If
client_pid
is -1, it means that theserver
hasn't connected to aclient
yet so the program setsclient_pid
toinfo->si_pid
, thepid
of theclient
currently connecting. -
If
client_pid
is not equal to thepid
of the currentclient
, theserver
frees the allocated message and prints an error and exits.
if (!server.bits)
server.data = 0;
- If
server.bits
is 0, it means that theserver
has not received any data yet so the program setsserver.data
to 0 to prepare to receive the incoming data.
The server
first receives an integer as "header information" specifying the length in bytes of the message about to be transferred, then come the actual bits of the message.
To store the bits according to the data type being received the following bitwise operations and conditionals are employed:
if ((sig == SIGUSR2) && !server.received)
server.data |= 1 << (((sizeof(int) * 8) - 1) - server.bits);
else if ((sig == SIGUSR2) && server.received)
server.data |= 1 << (((sizeof(char) * 8) - 1) - server.bits);
-
The conditional statements make sure that the first 32 bits of incoming data are saved in a space that fits an
int
. -
The bitwise operators
|
(OR) and<<
(Left-Shift) are used together to set the received bits in their right place in memory. -
After this
int
is received theserver
starts storing the following inbound bits intochar
sized chunks of memory.
Note
These memory-writing bitwise operations only happen when a SIGUSR2
is received.
Any time a SIGUSR1
is caught, the server simply acknowledges by sending back a SIGUSR1
to the client
.
- Because the memory in
server.data
is initially set to 0, theserver
only needs to act when a 1 is received, and flip the appropriate bit in its right place in memory.
Important
SIGUSR1
and SIGUSR2
are therefore used to signify 0 and 1 respectively.
static void ft_strlen_received(t_protocol *server);
Once the
int
has been received the conditions for triggering the code block insideft_strlen_received()
are met:
if ((server->bits == (sizeof(int) * 8)) && !server->received) { ... }
- This function first sets the
server.received
flag to 1, signifying that the header data has been received.
The server
prints the length of the message to stdout
, then takes this value plus 1 (to account for the NULL terminator) and allocates memory for a message with that many bytes with ft_calloc()
so that the memory is all set to zero:
server->msg = ft_calloc((server->data + 1), sizeof(char));
if (!server->msg)
ft_perror_exit("ft_calloc() failed\n");
The memory space for the message is then NULL terminated, and the server->bits
are reset to 0 to prepare the server to receive the bits of the message.
server->msg[server->data] = '\0';
server->bits = 0;
The function ends and the server
continues receiving the message bit by bit until every char
in the message has been transferred successfully.
static void ft_print_msg(t_protocol *server, int *i, pid_t *pid);
Once 8 bits have been received and the header information has already been transferred, the first layer of logic is triggered:
if ((server->bits == 8) && server->received) { ... }
- The received byte stored in
server.data
is copied to thei
-th index ofserver->msg
.
Then i
is incremented so that when indexed server->msg[i]
points to the next byte in memory where the next char
or Unicode
segment (code point) is gonna be stored.
server->msg[*i] = server->data;
++(*i);
Important
For more about Unicode
check the Appendix, Unicode
Character Encoding.
Notice that server.bits
is reset to 0 after the char
has been stored, in preparation to receive the next.
server->bits = 0;
And so the server
receives each byte of the message until the whole message has been received.
The server knows all the data in the message has been received when the current server.data
value is the NULL terminator.
if (server->data == '\0') { ... }
- The server then prints the message to
stdout
followed by theserver
'spid
.
ft_printf("Message:\n%s%s%s\n", GRN, server->msg, NC)
ft_print_pid();
Now the
server
performs some clean up to prepare to receive the next message.
free(server->msg);
server->msg = NULL;
server->received = 0;
*i = 0;
*pid = -1;
- Since we are done with the
server.msg
, we free the memory space allocated to store it. - We set the
server->msg
pointer to NULL. - And set
i
andserver->received
flag to 0. - We reset the
pid
to -1 to prepare the server to receive the next message from a differentpid
.
ft_send_bit(pid, 1, 0);
Finally we send a bit back to the client
to signal that the message has been received.
Before starting operations the client must check if its input arguments are valid.
if (argc != 3)
ft_perror_exit("Usage: ./client [PID] [message]\n");
else if (kill(ft_atoi(argv[1]), 0) < 0)
ft_perror_exit("PID does not exist\n");
-
It first checks if
argc
is not equal to 3, if so the program will print an error tostderr
and exit. -
Then checks if the
pid
of the server (argv[1]
) is valid by test-callingkill()
(with a zero instead of a signal identifier). -
If it is NOT valid the program will also print an error to
stderr
and exit.
The client
, like the server
, uses sigaction()
to handle incoming UNIX signals:
struct sigaction sa;
sigemptyset(&sa.sa_mask);
sa.sa_handler = ft_client_sighandler;
sa.sa_flags = SA_RESTART;
ft_set_sigaction(&sa);
A struct sigaction
is declared as sa
and:
-
Initializes its signal set
sa.sa_mask
with all signals excluded from the set usingsigemptyset()
; -
sa.sa_handler
is set to the functionft_client_sighandler()
; -
sa.sa_flags
flag set has the bit forSA_RESTART
turned on; -
The
sa
struct is then passed intoft_set_sigaction()
to set event handling forSIGUSR1
andSIGUSR2
;
When the
client
event handler receives a signal, it checks if it isSIGUSR1
orSIGUSR2
:
If the incoming signal is
SIGUSR1
(Data Reception Acknowledgement), it prints a*
tostdout
.Else if it receives
SIGUSR2
(Data Transmission Done), it prints a success message tostdout
and exits.
The client
then prints the server
's pid
to stdout
and calls ft_send_msg()
:
ft_print_pid();
...
ft_send_msg(ft_atoi(argv[1]), argv[2]);
static void ft_send_msg(pid_t pid, char *msg);
- To keep track of the current index of the message being sent, a local integer variable
i
is created and initialized to 0. - Before sending the message we must first take the
message
's length into the integer variablemsglen
.
int i;
int msglen;
i = 0;
msglen = ft_strlen(msg);
ft_printf("%sOutbound msg's length = %d%s\n", CYN, msglen, NC);
- The message length is bit-by-bit using the function
ft_send_int
.
ft_send_int(pid, msglen);
Then it loops through the message and sends each character to the server
bit-by-bit:
ft_printf("\n%sSending Message%s\n", GRN, NC);
while (msg[i] != '\0')
ft_send_char(pid, msg[i++]);
ft_printf("\n");
ft_sep_color('0', '=', 28, GRN);
ft_printf("%sSending NULL Terminator\n", MAG, NC);
ft_sep_color('0', '=', 28, GRN);
Then all there's left to do is to send a NULL terminator to the server
and terminate the message appropriately:
ft_send_char(pid, '\0');
To send char
s and int
s to the server
two helper functions were implemented: ft_send_char()
and ft_send_int()
.
void ft_send_int(pid_t pid, int num);
void ft_send_char(pid_t pid, char c);
These two functions work in similar ways.
- They first initialize a
bitshift
integer variable with the size of the binary representation of the data type about to be sent:
int bitshift;
bitshift = ((sizeof(int) * 8) - 1); // Prepare the server to receive 32 bits
...
bitshift = ((sizeof(char) * 8) - 1); // Prepare the server to receive 8 bits
Important
bitshift
will be used to iterate through each byte of data being sent from the most significant (MSB
) to the least significant bit (LSB
).
The client
enters a loop running from bitshift
to 0:
-
It breaks the
char
/int
into its individual bits; -
Each bit is passed as an argument to
ft_send_bit()
where it triggers the appropriate signal and is sent to theserver
; -
bitshift
is decremented to move to the next bit of the binary representation of thechar
/int
, from left to right;
while (bitshift >= 0)
{
bit = (num >> bitshift) & 1; // Get the current bit
ft_send_bit(pid, bit, 1); // Send the current bit
--bitshift; // Move to the next bit
}
void ft_send_bit(pid_t pid, char bit, char pause_flag);
ft_send_bit()
sends information to the server
bit-by-bit.
-
It simply checks if the passed
bit
is 1 or 0 and sends the appropriate signal usingkill()
. -
If the call to
kill()
fails, the program writes an error message tostderr
and exits.
if (bit == 0)
{
if (kill(pid, SIGUSR1) < 0)
ft_perror_exit("kill() failed sending SIGUSR1\n");
}
else if (bit == 1)
{
if (kill(pid, SIGUSR2) < 0)
ft_perror_exit("kill() failed sending SIGUSR2\n");
}
If the pause_flag
is set to 1, the server
waits for the next data chunk to be sent.
if (pause_flag != 0)
pause();
Note
This function is called with pause_flag = 1
when used in the context of ft_send_char()
and ft_send_int()
, so that for each bit sent the client
waits for a confirmation signal from the server
before proceeding to send the data.
This file contains only a wrapper for sigaction
used to set both the server
's event handler and the client
's event handlers for SIGUSR1
and SIGUSR2
signals:
Once again, error handling is done using control expressions inside
if
statements.
void ft_set_sigaction(struct sigaction *sa)
{
if (sigaction(SIGUSR1, sa, NULL) < 0)
ft_perror_exit("sigaction() failed to handle SIGUSR1");
if (sigaction(SIGUSR2, sa, NULL) < 0)
ft_perror_exit("sigaction() failed to handle SIGUSR2");
}
To try and test minitalk
:
- First clone the repository:
git clone git@github.com:PedroZappa/42_minitalk.git
- Then fetch the project's dependencies and compile the executables:
cd 42_minitalk
make
- Get the
server
spinning:
./server
- Now, on a different terminal, run the
client
:
./client [server-pid] [message]
- The
client
will send the passedmessage
to the targetserver
with givenpid
.
If you're like me and use tmux
you can quickly test the project using the following make rules:
- Conveniently spin up a
server
on a newtmux
window-split:
make serve
- To automatically launch a few
client
s on newtmux
window-splits:
make test
- To run harder tests with longer messages including Unicode characters:
make stress_test
Unicode
, like other character encodings, functions as a lookup table mapping code points to characters.
The most important difference between Unicode
and ASCII
is that Unicode
allows character encodings to be up to 32-bits wide, allowing for over 4 billion unique values (way too much space than we'll ever need to include every character set in existence).
Unicode
takes a smart approach when it comes to character encoding. If a character can be represented by just 1 byte that's all the space that will be used. This memory efficient technique is known as variable length encoding.
- For example a common character like a
C
takes 8 bits in memory, while special, rarer characters like๐ฉ
need up to 32 bytes to be stored in memory. - This means a document like the present README takes about four times less space when encoded in UTF-8 than it would if encoded in UTF-32, making the page take less space in memory and load substantially faster.
Unicode
characters can be referenced by their code point.
- A code point is a (irreducible) atomic unit of information.
- A text document is a sequence of code points.
- Each code point represents a number with a particular meaning in the
Unicode
standard. - The current
Unicode
standard defines 1,114,112 code points. - These code points are further divided into 17 planes or groundings.
- Each plane is identified by a number from 0 to 16.
- The number of code points in each plane is 65,536 (
$2^{16}$ ).
To access a given code point we use the following syntax:
U+(hexadecimal representation of a code point)
Note
Hexadecimal values are used to represent the code points because they make it easier to reference large values.
Character | Code Point | Binary Representation |
---|---|---|
๐ฉ | U+1F4A9 | 0001 1111 0100 1010 1001 |
๐ | U+1F31F | 0001 1111 0011 0001 1111 |
Some characters can be expressed as a combination of multiple code points known as grapheme clusters
.
Character | Code Point |
---|---|
๐ง | U+1F9D1 |
๐พ | U+1F33E |
๐งโ๐พ | U+1F9D1 U+200D U+1F33E |
Important
U+200D
is a zero-width joiner.
This work is published under the terms of 42 Unlicense.