Skip to content

Process Module ​

The process module provides facilities for launching and managing external programs from Fennel/Lua code. It supports both synchronous (blocking) and asynchronous (non-blocking) execution modes with full control over stdin/stdout/stderr, environment variables, working directory, and process lifecycle.

Usage ​

fennel
(local process (require :process))

API Reference ​

process.run ​

Synchronously executes a command and waits for completion.

fennel
(local result (process.run opts))

Options table:

KeyTypeRequiredDescription
argstableyesList of strings: command and arguments (e.g., ["ls" "-la"])
cwdstringnoWorking directory for the process
envtablenoEnvironment variables to set (key-value pairs)
clear-envbooleannoIf true, clears inherited environment before applying env
stdinstringnoData to write to the process's stdin
timeoutnumbernoTimeout in seconds; process is killed if exceeded
merge-stderrbooleannoIf true, stderr is redirected to stdout

Returns: Result table (see Result Table)

process.spawn ​

Asynchronously spawns a process without blocking.

fennel
(local id (process.spawn opts callback))

Parameters:

  • opts - Options table (same as process.run)
  • callback - Optional function called with result when process completes

Returns: Process ID (integer) for use with other functions

When a callback is provided, it will be invoked via the engine's callback dispatch system when the process completes. Without a callback, use process.poll or process.wait to retrieve results.

process.write ​

Writes data to a spawned process's stdin.

fennel
(local bytes-written (process.write id data))

Parameters:

  • id - Process ID from process.spawn
  • data - String to write

Returns: Number of bytes written

Throws: Error if process ID is invalid or stdin is closed

process.close-stdin ​

Closes the stdin pipe of a spawned process, signaling EOF.

fennel
(local success (process.close-stdin id))

Parameters:

  • id - Process ID from process.spawn

Returns: true if stdin was open and is now closed, false otherwise

process.kill ​

Sends a signal to a spawned process.

fennel
(process.kill id)        ; sends SIGTERM (15)
(process.kill id 9)      ; sends SIGKILL (9)

Parameters:

  • id - Process ID from process.spawn
  • signal - Optional signal number (default: SIGTERM/15)

Returns: true if signal was sent successfully, false otherwise

process.running ​

Checks if a spawned process is still running.

fennel
(if (process.running id)
    (print "still running")
    (print "finished"))

Parameters:

  • id - Process ID from process.spawn

Returns: true if process is running, false if finished or invalid ID

process.wait ​

Blocks until a spawned process completes and returns its result.

fennel
(local result (process.wait id))

Parameters:

  • id - Process ID from process.spawn

Returns: Result table (see Result Table)

Throws: Error if process ID is invalid

Note: If the process had a callback, the callback is unregistered and not invoked.

process.poll ​

Polls for completed processes that were spawned without callbacks.

fennel
(local results (process.poll))
(local results (process.poll max-count))

Parameters:

  • max-count - Optional maximum number of results to return (0 = unlimited)

Returns: Table of result tables, each with an additional id field

Processes spawned with callbacks are not included in poll results; they are dispatched through the callback system instead.

Result Table ​

All completion functions return a result table with these fields:

FieldTypeDescription
exit-codeintegerProcess exit code (0 typically means success)
signalinteger or nilSignal number if killed by signal, nil otherwise
timed-outbooleantrue if process was killed due to timeout
stdoutstringCaptured stdout output
stderrstringCaptured stderr output (empty if merge-stderr was true)
duration-msintegerExecution time in milliseconds

Exit code conventions:

  • 0: Success
  • 1-125: Command-specific error codes
  • 126: Command found but not executable (e.g., permission denied)
  • 127: Command not found
  • 128+N: Killed by signal N (e.g., 137 = 128+9 = SIGKILL)

Examples ​

Simple command execution ​

fennel
(local result (process.run {:args ["echo" "hello world"]}))
(print result.stdout)  ; "hello world\n"
(print result.exit-code)  ; 0

Command with working directory and environment ​

fennel
(local result (process.run {:args ["sh" "-c" "echo $MY_VAR from $(pwd)"]
                            :cwd "/tmp"
                            :env {:MY_VAR "test"}}))
(print result.stdout)  ; "test from /tmp\n"

Piping data to stdin ​

fennel
(local result (process.run {:args ["cat"]
                            :stdin "data from fennel"}))
(print result.stdout)  ; "data from fennel"

Timeout handling ​

fennel
(local result (process.run {:args ["sleep" "60"]
                            :timeout 1}))
(if result.timed-out
    (print "command timed out")
    (print "command completed"))

Async execution with callback ​

fennel
(process.spawn {:args ["long-running-task"]}
               (fn [result]
                 (print "Task finished with exit code:" result.exit-code)))

Interactive async process ​

fennel
(local id (process.spawn {:args ["cat"]}))

;; Write data incrementally
(process.write id "line 1\n")
(process.write id "line 2\n")

;; Signal end of input
(process.close-stdin id)

;; Wait for completion
(local result (process.wait id))
(print result.stdout)  ; "line 1\nline 2\n"

Multiple concurrent processes ​

fennel
(local ids [])
(for [i 1 5]
  (table.insert ids (process.spawn {:args ["echo" (tostring i)]})))

;; Wait for all to complete
(each [_ id (ipairs ids)]
  (local result (process.wait id))
  (print result.stdout))

Polling for completed processes ​

fennel
;; Spawn several processes without callbacks
(process.spawn {:args ["echo" "one"]})
(process.spawn {:args ["echo" "two"]})
(process.spawn {:args ["echo" "three"]})

;; Later, poll for results
(local results (process.poll))
(each [_ r (ipairs results)]
  (print (.. "Process " r.id " output: " r.stdout)))

Killing a runaway process ​

fennel
(local id (process.spawn {:args ["sleep" "3600"]}))

;; Do some work...

;; Decide to cancel
(when (process.running id)
  (process.kill id)  ; SIGTERM
  (local result (process.wait id))
  (print "Killed, signal:" result.signal))

Implementation Notes ​

Files ​

  • src/lua_process.h - Header declaring binding functions
  • src/lua_process.cpp - Implementation (~750 lines)
  • src/lua_runtime.cpp - Registers lua_bind_process
  • src/engine.cpp - Calls lua_process_dispatch and lua_process_drop

Architecture ​

The module uses POSIX fork/exec for process creation:

  1. Synchronous mode (process.run): Forks, sets up pipes, executes the command, and polls for output/completion in a loop until the child exits or times out.

  2. Asynchronous mode (process.spawn): Forks and returns immediately. A ProcessManager singleton tracks all spawned processes. Each frame, lua_process_dispatch is called from the engine loop to:

    • Poll all running processes for output
    • Check for completion or timeout
    • Enqueue callbacks for finished processes via lua_callbacks

Process Groups ​

Both parent and child call setpgid to establish process groups. This allows killpg to terminate the entire process tree (the spawned process and any children it may have created), not just the top-level process.

Non-blocking I/O ​

All pipe file descriptors are set to non-blocking mode using fcntl(F_SETFL, O_NONBLOCK). This allows the parent to interleave reading stdout/stderr without deadlocking when buffers fill.

Timeout Handling ​

When a timeout expires:

  1. SIGTERM is sent to the process group
  2. A 200ms grace period allows graceful shutdown
  3. If still running, SIGKILL is sent
  4. The process is reaped with waitpid

Cleanup ​

lua_process_drop (called during engine shutdown) kills all remaining spawned processes with SIGKILL and closes all open file descriptors to prevent resource leaks.

Testing ​

Tests are in assets/lua/tests/test-process.fnl (34 tests) covering:

  • Basic sync execution
  • Exit codes and signals
  • Stdout/stderr capture and merging
  • Stdin piping
  • Working directory
  • Environment variables
  • Timeout behavior
  • Async spawn/wait/poll
  • Process killing
  • Interactive stdin writing
  • Error handling
  • Concurrent processes

Run with:

sh
SPACE_DISABLE_AUDIO=1 SPACE_ASSETS_PATH=$(pwd)/assets ./build/space -m tests.test-process:main

See also ​