Spawning a Subprocess With GLib and Gio

While I was working on my first GNOME application which I hope to publish soon I stumbled a small hindrance of how to spawn a subprocess efficiency without blocking the main thread. I could have left it as is with the straightforward pythonic way but I wanted the user interface to be fluent.

So how did I solved it? At first, I found a function in GLib that can spawn a subprocess called GLib.spawn_async, it was pretty simple to write to stdin and read from stdout which was necessary for what I wanted to do. When you spawn a process with the function you will get back a PID and file descriptors for stdin, stdout, and stderr. Writing to stdin was simply calling os.write with the given file descriptor and the bytes I wanted to write. Reading was also easy but a bit longer, I had to create a file object from the file descriptor using os.fdopen and read it like a normal file. The final code looked something like that:

import os
from gi.repository import GLib

pid, stdin_fd, stdout_fd, stderr_fd = GLib.spawn_async(cmd.split(), standard_input=True, standard_output=True, stndard_error=True)

os.write(stdin_fd, b'hello there')
os.close(stdin_fd)

with os.fdopen(stdout_fd) as stdout:
    print(stdout.read())

with os.fdopen(stderr_fd) as stderr:
    print(stderr.read())

GLib.spawn_close_pid(pid)

The problem with the code was that even though the process was spawned asynchronously, reading and writing is done on the main thread and will make the program unresponsive. Luckily for us, there’s a function called GLib.io_add_watch that can listen to a file descriptor and call a function when there’s new data. So to read stdout this way our code would look like that:

def callback(stdout_fd, cond):
    if cond != GLib.IO_IN:
        return False
	
    with os.fdopen(stdout_fd) as stdout:
    	print(stdout.read())

    return True

GLib.io_add_watch(stdout_fd, GLib.IO_IN, callback)

Great, now we have a callback that will be called when we’ve got something to read from stdout but how do we cancel it? At that point, I decided to ask for help on IRC and I got a recommendation to use Gio.Subprocess instead of GLib.spawn_async. Indeed, Gio.Subprocess felt a lot more high level with many helpful functions.

So to create a new instance of the Gio.Subprocess class and spawn a new subprocess we can use the Gio.Subprocess.new constructor that only accept two arguments, the command and a set of flags to set the behaviour of the subprocess. To write to stdin and read from stdout, we will use the Gio.SubprocessFlags.STDIN_PIPE flag and the Gio.SubprocessFlags.STDOUT_PIPE flag.

Now that we have a subprocess object we have more than one way to communicate with it but since we want to do it asynchronously we will use the communicate_utf8_async method that will send the string that we give it to stdin and call a callback with the process output. The callback will receive the subprocess and the result but to get the actual output of the process (stdout and stderr) we need to call communicate_utf8_finish in the callback. This is the complete code for it:

from gi.repository import Gio

def callback(sucprocess: Gio.Subprocess, result: Gio.AsyncResult, data):
    _, stdout, stderr = proc.communicate_utf8_finish(result)
    print(stdout)
    print(stderr)

proc = Gio.Subprocess.new(cmd.split(), Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE, Gio.SubprocessFlags.STDERR_PIPE)
proc.communicate_utf8_async('general kenobi', None, callback, None)

That’s it, you can test and see that even with a sleep in the subprocess your UI will continue to be responsive. But there was a reason I switched to Gio, I wanted to be able to cancel the callback. Notice that two arguments areNone in the call to communicate_utf8_async. The first one is of type Gio.Cancellable and the second is the user data to pass to the callback so of course, we care about the first None argument.

Creating an instance of Gio.Cancellable is easily done with the default constructor Gio.Cancellable.new does not have any required arguments. So we pass the cancellable object to the communicate_utf8_async method and if we want to cancel the operation we will just call the cancel method on Gio.Cancellable. Our previous code with the Gio.Cancellable object is this:

from gi.repository import Gio

def callback(sucprocess: Gio.Subprocess, result: Gio.AsyncResult, data):
    _, stdout, stderr = proc.communicate_utf8_finish(result)
    print(stdout)
    print(stderr)

proc = Gio.Subprocess.new(cmd.split(), Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE, Gio.SubprocessFlags.STDERR_PIPE)
cancellable = Gio.Cancellable.new()
proc.communicate_utf8_async('general kenobi', cancellable, callback, None)
cancellable.cancel()

That code would cancel the task immediately but it will also raise an exception inside the callback because this is our way to know our callback was canceled. The exception that was raised is a generic GLib.Error so we need to inspect the error to be sure it’s a cancellation error. How do we do it? The cancellation error will have a domain property of g-io-error-quark and a code property of 19 so inside the except block just check for those values like so:

def callback(sucprocess: Gio.Subprocess, result: Gio.AsyncResult, data):
    try:
        _, stdout, stderr = proc.communicate_utf8_finish(result)
        print(stdout)
        print(stderr)
    except GLib.Error as err:
        if err.domain == 'g-io-error-quark' and err.code == GIO_CANCEL_ERROR_CODE:
	    return

That’s it, we have spawned a subprocess and received its output without blocking the main thread.