💾 Archived View for gemini.circumlunar.space › users › kraileth › neunix › eerie › 2019 › daemon_fre… captured on 2023-04-26 at 14:09:41. Gemini links have been rewritten to link to archived content
⬅️ Previous capture (2023-03-20)
-=-=-=-=-=-=-
Here I'm republishing an old blog post of mine originally from November 2019. The article has been slightly improved.
Part 1 of this series covered Python fundamentals, signal handling and logging. We wrote an init script as well as a program that can be daemonized by daemon(8).
Writing a daemon using FreeBSD and Python pt.1
In the previous part we modified the program as well as the init script so that it can daemonize itself using the Python daemon module. I also covered a few topics that people totally new to programming (or Python) might want to know to better understand what's happening.
Writing a daemon using FreeBSD and Python pt.2
This part is about exploring a simple means of IPC (inter-program communication) by using named pipes.
What is a _named pipe_ - also known as a _fifo_ (first in, first out)? It is a way of connecting two processes together, where one can sequentially send data and the other receives it in exactly the same order. It's basically what us Unix lovers use for our command lines all the time when we pipe the input of one program into another. E.g.:
ls | wc -l
In this case the output of _ls_ is piped to _wc_ which will then print the amount of lines to stdout (which could be used as input for another program with another pipe). This kind of pipe between two programs is usually short lived. When the first program is done sending output and the second one has received all the data, the pipe goes away with the two processes. It also only exists between the two processes involved.
A named pipe in contrast is something a bit more permanent and more flexible. It has a representation in the filesystem (which is why it's a _named_ pipe in the first place). One program creates a named pipe (usually in /var/run) and attaches to the receiving end of the pipe. Another process can then attach to the sending end and start putting data into it which will then be received by the former. Named pipes have their own character (p) showing that a file is of type named pipe, looking like this when you _ls -l_:
prw-rw-r--
Here's what the next version of the code looks like:
#!/usr/local/bin/python3.6 # Imports # import daemon, daemon.pidfile, logging, os, signal, time # Globals # IN_PIPE = '/var/run/bd_in.pipe' # Fuctions # def handler_sigterm(signum, frame): logging.debug("Caught SIGTERM! Cleaning up...") if os.path.exists(IN_PIPE): try: os.unlink(IN_PIPE) except: raise logging.info("All done, terminating now.") exit(0) def start_logging(): try: logging.basicConfig(filename='/var/log/bdaemon.log', format='%(levelname)s:%(message)s', level=logging.DEBUG) except: print("Error: Could not create log file! Exiting...") exit(1) def assert_no_pipe_exists(): if os.path.exists(IN_PIPE): logging.critical("Cannot start: Pipe file \"" + IN_PIPE + "\" already exists!") exit(1) def make_pipe(): try: os.mkfifo(IN_PIPE) except: logging.critical("Cannot start: Creating pipe file \"" + IN_PIPE + "\" failed!") exit(1) logging.debug("Created pipe \"" + IN_PIPE) # Main # with daemon.DaemonContext(pidfile=daemon.pidfile.TimeoutPIDLockFile("/var/run/bdaemon.pid"), umask=0o002): signal.signal(signal.SIGTERM, handler_sigterm) start_logging() assert_no_pipe_exists() make_pipe() logging.info("Baby Daemon started up and ready!") while True: time.sleep(1)
We're using a new import here: "os". It gives the programmer access to various OS-dependent functions (like pipes which are not existent on Windows for example). I've also added a global definition for the location of the named pipe.
The next thing that you'll notice is that the signal handler function got some new code. Before the daemon terminates it tries to clean up. If the named pipe exists the program will attempt to delete it. I'm not handling what could possibly go wrong here as this is just an example. That's why in this case I just re-raise the exception and let the program error out.
Then we have a new "start_logging()" function that I put the logging stuff into to unclutter main. Except for that changed structure, there's really nothing new here.
The next new function, "assert_no_pipe_exists()" should be fairly easy to read: It checks if a file by the name it wants to use is already present in the filesystem (be it as a leftover from an unclean exit or by chance from some other program). If it is found, the daemon aborts because it cannot really continue. If the filename is not taken, however, "make_pipe()" will attempt to create the named pipe.
The other thing that I did was moving the main part back from being a function directly to the program. And since we're doing small incremental steps, that's it for today's step 1. Fire up the daemon using the init script and you should see that the named pipe was created in /var/run. Stop the process and the pipe should be gone.
Creating and removing the named pipe is a good first step, but now let's use it! To do so we must first modify the daemon to attach to the receiving end of the pipe:
#!/usr/local/bin/python3.6 # Imports # import daemon, daemon.pidfile, errno, logging, os, signal, time # Globals # IN_PIPE = '/var/run/bd_in.pipe' # Fuctions # def handler_sigterm(signum, frame): try: close(inpipe) except: pass logging.debug("Caught SIGTERM! Cleaning up...") if os.path.exists(IN_PIPE): try: os.unlink(IN_PIPE) except: raise logging.info("All done, terminating now.") exit(0) def start_logging(): try: logging.basicConfig(filename='/var/log/bdaemon.log', format='%(levelname)s:%(message)s', level=logging.DEBUG) except: print("Error: Could not create log file! Exiting...") exit(1) def assert_no_pipe_exists(): if os.path.exists(IN_PIPE): logging.critical("Cannot start: Pipe file \"" + IN_PIPE + "\" already exists!") exit(1) def make_pipe(): try: os.mkfifo(IN_PIPE) except: logging.critical("Cannot start: Creating pipe file \"" + IN_PIPE + "\" failed!") exit(1) logging.debug("Created pipe \"" + IN_PIPE) def read_from_pipe(): try: buffer = os.read(inpipe, 255) except OSError as err: if err.errno == errno.EAGAIN or err.errno == errno.EWOULDBLOCK: buffer = None else: raise if buffer is None or len(buffer) == 0: logging.debug("Inpipe not ready.") else: logging.debug("Got data from the pipe: " + buffer.decode()) # Main # with daemon.DaemonContext(pidfile=daemon.pidfile.TimeoutPIDLockFile("/var/run/bdaemon.pid"), umask=0o002): signal.signal(signal.SIGTERM, handler_sigterm) start_logging() assert_no_pipe_exists() make_pipe() inpipe = os.open(IN_PIPE, os.O_RDONLY | os.O_NONBLOCK) logging.info("Baby Daemon started up and ready!") while True: time.sleep(5) read_from_pipe()
Apart from one most import, _errno_, we have three important changes here. First, the cleanup has been extended, there is a new function, called "read_from_pipe()" and then main has been modified as well. We'll take a look at the latter first.
There's a ton of examples on named pipes on the net, but they usually use just one program that forks off a child process and then communicates over the pipe with that. That's pretty simple to do and works nicely by just copying and pasting the example code in a file. But adapting it for our little daemon does _not_ work: The daemon just seems to "hang" after first trying to read something from the pipe. What's happening there?
By default, reads from the pipe are in _blocking_ mode, which means that on the attempt to read, the system just waits for data if there is none! The solution is to use _non-blocking_ mode, which however means to use the raw "os.open" function (that supports flags to be passed to the operating system) instead of the nice Python "open" function with its convenient file object.
So what does the line starting with "inpipe" do? It calls the function _os.open_ and tells it to open IN_PIPE where we defined the location of our pipe. Then it gives the flags, so that the operating system knows _how_ to open the file, in this case in _read-only_ and in _non-blocking_ mode. We need to open it _read-only_, because the daemon should be at the receiving side of the pipe. And, yes, we want _non-blocking_, so that the program continues on if there is no data in the pipe without waiting for it all the time!
What might look a little strange to you, is the "|" character between the two flags. Especially since on the terminal it's known as the pipe character and we're talking about pipes here, right? In this case it's something completely unrelated however. That symbol just happens to be Python's choice for representing the _bit-wise OR_ operator. Let's leave it at that (I'll explain a bit more of it in a future "Python pieces" section, but this article will be long enough without it).
However that's still not all that the line we're just discussing does. The "os.open()" function returns a _file descriptor_ that we're then assigning to the inpipe variable to keep it around.
What's left is a new infinite loop that calls read_from_pipe() every 5 seconds.
Speaking of that function, let's take a closer look at what it does. It tries to use the "os.read" function to read up to 255 bytes from the pipe into the variable named _buffer_. We're doing so in a try/except block, because the read is somewhat likely to fail (e.g. if the pipe is empty). When there's an exception, the code checks for the exact error that happened and if it's _EAGAIN_ or _EWOULDBLOCK_, we deliberately empty the buffer. If some other error occurred, it's something that we didn't expect, so let's better take the straight way out by raising the exception again and crashing the program.
On FreeBSD the error numbers are defined in "/usr/include/errno.h". If you take a look at it, you see that EAGAIN and EWOULDBLOCK are the same thing, so checking for one of them would be enough. But it makes sense to know that on some systems these are separate errors and that it's good practice to check for both.
If the buffer either has the _None_ value or has a length of 0, we assume that the read failed. Otherwise we put the data into the log. To make it readable we have to use _decode_, because we will be receiving encoded data.
All that's left is the cleanup function. I've added another try/except block that simply tries to close the pipe file before trying to delete it. This is example code, so to make things not even more complex, I just silently ignore if the attempt fails.
Ok, great! That were quite few things to cover, but now we have a daemon that creates a pipe and tries to read data from it. There's just one problem: How can we test it? By creating another, separate program, that puts data in the pipe of course! For that let's create another file with the name "bdaemonctl.py":
#!/usr/local/bin/python3.6 # Imports # import os, time # Globals # OUT_PIPE = '/var/run/bd_in.pipe' # Main # try: outpipe = os.open(OUT_PIPE, os.O_WRONLY) except: raise for i in range(0, 21): print(i) try: os.write(outpipe, bytes(str(i).encode('utf-8'))) except BrokenPipeError: print("Pipe has disappeared, exiting!") os.close(outpipe) exit(1) time.sleep(3) os.close(outpipe)
Fortunately this one is fairly simple. We do our imports and define a variable for the pipe. We could skip the latter, because we're using it on only one occasion but in general it's a good idea to keep it as it is. Why? Because hiding things deep in the code may not be such a smart move. Defining things like this at the top of the file increases the maintainability of your code a lot. And since we want to _send_ data this time, of course we name our variable OUT_PIPE appropriately.
In the main section we just try to open the pipe file and crash if that doesn't work. It's pretty obvious that such a case (e.g. the pipe is not there because the daemon is not running) should be better handled. But I wanted to keep things simple here because it's just an example after all.
Then we have a loop that counts from 0 to 20, outputs the current number to stdout and tries to also send the data down the pipe. If that works, the program waits three seconds and then continues the loop.
To be able to write to the pipe we need a _byte stream_ but we only have numbers. We first convert them to a _string_ and use a proper _encoding_ (utf8) and then convert them to _bytes_ that can be sent over the pipe.
When the loop is over, we close the pipe file properly because we as the sender are done with it. I added a little bit of code to handle the case when the daemon exits while the control script runs and still tries to send data over the pipe. This results in a "broken pipe" error. If that happens, we just print an error message, close the file (to not leak the file descriptor) and exit with an error code of 1.
So for today we're done! We can now send data from a control program to the daemon and thus have achieved uni-directional communication between two processes.
I'll take a break from these programming-related posts and write about something else next.
However I plan to continue with a 4th part later which will cover argument parsing. With that we could e.g. modify our control program to send arbitrary data to the daemon from the command line - which would of course be much more useful than the simple test case that we have right now.