|
|
Log in / Subscribe / Register

The beginning of a process-builder API

By Jonathan Corbet
August 4, 2026
The recent discussion on "spawn templates" raised questions about whether it was time to provide an alternative to the classic Unix fork()/exec() pattern for process creation. One idea that was raised there was to shift the template pattern into an interface that could be used to efficiently assemble new processes from bare cloth, without duplicating the parent process. Preferably, that interface would be able to implement posix_spawn(). Li Chen, the author of the spawn-template work, has now responded with a patch series (written with significant LLM assistance) showing what a process-builder API for Linux might look like.

In the Unix model, a call to fork() (which ends up being a variant of clone() on Linux systems) creates a copy of the calling process, which involves a fair amount of work. The child then typically modifies its environment in whatever ways are necessary — opening or closing files, for example — before making a call to execve() to run a new program. That latter call ends up throwing away most of the work that was done to copy the parent process, which is not entirely efficient. In cases where the intent is to immediately run a different program, a better model might be to piece together the new process from the beginning, without involving (much of) the parent process's state.

Process creation

In Chen's patch series, the way to do that is to start by creating an empty process with a call to the existing pidfd_open() system call:

    new_process_fd = pidfd_open(0, PIDFD_EMPTY);

The new PIDFD_EMPTY flag requests the creation of a process shell that will have its details filled out later. The return value is a real pidfd, but most of the resources associated with a process are not yet present. There is no process ID, no task structure in the kernel, and no charge against the parent's process-count resource limit. Most kernel operations that act on a pidfd will refuse to do anything with this one at this stage.

The next step is to put together the information that drives the construction of the new process; this work is centered around this structure:

    struct pidfd_spawn_run_args {
	__u32 flags;
	__u32 nr_actions;
	__aligned_u64 path;
	__aligned_u64 argv;
	__aligned_u64 envp;
	__aligned_u64 actions;
	__u32 action_size;
	__u32 reserved0;
	__u64 reserved[2];
    };

The path field is a pointer to a string containing the path to the executable that the new process should run; as described below, it can be NULL in some cases. The argv and envp parameters point to the usual argument and environment arrays. The flags field must be zero, as must the reserved fields. Filling in those fields (the rest will be covered shortly) provides enough information to build and run the process with a call to the first of two new system calls:

    int pidfd_spawn_run(int pidfd, struct pidfd_spawn_run_args *args, int arg_size);

Here, pidfd is the pidfd for the under-construction process, args is a pointer to the above structure, and arg_size is the size of that structure. If all goes well, this call will create the full process and set it running with the indicated program; the return value will be the ID of the now fully fleshed-out process. The original pidfd remains valid and attached to this process. Should some sort of error happen, instead, the process shell will be left in a sort of dead state, and any further attempts to operate on it will fail.

Configuration

Providing an executable image, arguments, and environment is normally just the beginning of configuring a new process; a typical application will want to set up the process's file descriptors and, perhaps, adjust many other things. That is what the actions field of the pidfd_spawn_run_args structure is for. It points to an array of this structure type:

    struct pidfd_spawn_action {
	__u32 type;
	__u32 flags;
	__u32 fd;
	__u32 newfd;
	__u64 reserved[2];
    };

The type field describes an action that should be carried out before the process is instantiated and launched. The actions currently defined in this patch set (which are a small subset of what would eventually be needed) are:

  • PIDFD_SPAWN_ACTION_DUP2: duplicates an existing file descriptor using dup2(). The existing file descriptor should be passed in the fd field, while the intended new descriptor goes in newfd.
  • PIDFD_SPAWN_ACTION_CLOSE_RANGE: closes the range of file descriptors between fd and newfd (inclusive).
  • PIDFD_SPAWN_ACTION_FCHDIR: changes the new process's working directory to the one identified by fd.

PIDFD_SPAWN_ACTION_CLOSE_RANGE allows the CLOSE_RANGE_CLOEXEC and CLOSE_RANGE_UNSHARE flags supported by the close_range() system call; flags must be zero for the other two actions. The nr_actions field in the pidfd_spawn_run_args structure indicates how many actions are present, while action_size is the size of the pidfd_spawn_action structures. These actions will be carried out before the executable image is located, meaning that changing the working directory affects how a relative path to that image is resolved.

If any of the actions fail at pidfd_spawn_run() time, the entire system call will fail and the new process will not be launched.

The second new system call is an alternative configuration interface for some aspects of the new process:

    int pidfd_config(int pidfd, unsigned int cmd, char *ukey, char *value,
    		     int aux);

This system call is meant to be useful beyond the spawn functionality; in this patch set, though, it can only do one thing. With a cmd of PIDFD_CONFIG_SET_STRING, it can set a string-based parameter to the given value. The only such parameter in this set is PIDFD_CONFIG_KEY_PATH, which sets the path to the executable to run. This call must be made if the path pointer passed to pidfd_spawn_run() will be NULL. If a non-NULL path is passed to that system call, it will override a path configured with pidfd_config().

What's missing

This series is meant to be a proof of the concept that would help the community decide whether the overall direction makes sense or not. So it does not implement much of what would be wanted in the final result. As mentioned above, the set of available actions is much smaller than it would eventually need to be. To be able to implement posix_spawn(), the kernel would have to support actions to define signal handling, control scheduler parameters, open files, and more. For the most part, these are just a matter of programming once the form of the desired interface is established.

The other big gap is that pidfd_spawn_run() does not actually create a new process from scratch; instead, the implementation is based on vfork() internally. In theory, it should be possible to replace the implementation transparently, with the only change visible to user space being better performance, once the API is agreed upon. In practice, that may not be a small job. The kernel only ever creates two special-purpose processes (init and kthreadd) from scratch; the machinery to do that for the general case does not exist. That, too, is a matter of programming — perhaps a fair amount of it.

First, though, the API must be agreed upon. The series was posted on July 16, but has only garnered a single review comment as of this writing. It will need to attract a lot more eyeballs before a fuller implementation can be considered. A number of people have been asking for this kind of process-creation interface for years; now would be a good time for them to take a look to see whether this proposal is close to what they have in mind.

Index entries for this article
KernelSystem calls/clone()


to post comments

A better fork

Posted Aug 4, 2026 14:06 UTC (Tue) by magfr (subscriber, #16052) [Link]

This could, at a first glance, also serve as a better fork. A fork where you can set up file descriptors to be closed or renumbered atomically or perform all the other tasks if you just support a special action which replaces the current implicit action of "launch this file" with "return as fork would have done but only after all actions finished".

Should take an FD to the executable

Posted Aug 4, 2026 14:10 UTC (Tue) by bluca (subscriber, #118303) [Link] (4 responses)

> The path field is a pointer to a string containing the path to the executable that the new process should run

This really should also take an FD to the executable...

Should take an FD to the executable

Posted Aug 4, 2026 14:27 UTC (Tue) by chris_se (subscriber, #99706) [Link] (1 responses)

> > The path field is a pointer to a string containing the path to the executable that the new process should run
>
> This really should also take an FD to the executable...

I tend to agree with this - but should that be restricted to only O_PATH fds referencing existing files on disk, or could one also consider e.g. a memfd (possibly requiring it to be sealed) where e.g. a JIT previously put the result of a run-time compilation? The latter could be quite useful in some circumstances (but also quite useful for hiding malware in non-obvious ways, so YMMV).

Should take an FD to the executable

Posted Aug 7, 2026 9:27 UTC (Fri) by justincormack (subscriber, #70439) [Link]

You can already exec a memfd, you can pass the procfs path to the fd.

Should take an FD to the executable

Posted Aug 4, 2026 14:51 UTC (Tue) by vetse (subscriber, #143022) [Link] (1 responses)

I recall an article about execveat causing problems with comm being set unhelpfully, which would presumably also happen here.

Should take an FD to the executable

Posted Aug 4, 2026 14:56 UTC (Tue) by bluca (subscriber, #118303) [Link]

Right, but I'd think also passing the path for displaying purposes should hopefully allow to set that properly

More multiplexing, why?

Posted Aug 4, 2026 17:18 UTC (Tue) by Cyberax (✭ supporter ✭, #52523) [Link] (4 responses)

This is yet another multiplexing syscall with its own set of quirks. Please, don't do that.

It's _fine_ to do multiple syscalls. No need to cram everything into one giant call. Just allow the process to remain in the "spawning" state after `pidfd_spawn` and have a separate call to actually launch it.

More multiplexing, why?

Posted Aug 4, 2026 17:29 UTC (Tue) by josh (subscriber, #17465) [Link] (2 responses)

Agreed. We already have a good mechanism for running multiple system calls in a batch for performance: io_uring. We don't need *another* one.

Also, ideally this shouldn't be starting from the parent set of file descriptors, or at least shouldn't *only* operate that way. Ideally, it should be able to start from nothing and install only the desired file descriptors.

More multiplexing, why?

Posted Aug 5, 2026 10:09 UTC (Wed) by k3ninho (subscriber, #50375) [Link] (1 responses)

>We already have a good mechanism for running multiple system calls in a batch for performance: io_uring.

I was under the impression that races and Time-of-Check/Time-of-Use windows drive the need for batched syscalls under the demand for increased security. Here, they need to be batched into transactions for clear fail-and-no-change or succeed-and-progress-forward goals.

Maybe, I'm wrong there and my impression is wrong.

K3n.

More multiplexing, why?

Posted Aug 5, 2026 15:49 UTC (Wed) by kleptog (subscriber, #1183) [Link]

I don't even think you need something as sophisticated as io_uring. You could make a syscall that spawned an empty process with a few parameters:

* envp
* argv
* location+length of statically linked binary (program loader), or filename/file descriptor perhaps
* location+length of data segment of instructions
* flags (ofcourse)

Then the program loader could interpret the information in the data segment as a list of commands how to configure the new process. It could be passed data the same way the command are structured in io_uring, but it could be anything. You could make a special shell-like language specifically to configure new processes. It would still inherit a lot of state from the parent but no memory.

Like:

mapfd 3 1
close_range 3-
signal 15 clear

... etc...

Afterward the process would simply execve the actual target program. This requires only a single syscall to create an empty program with some data passed in. It's basically posix_spawn, but moving all the extra structs into a separate memory block to be processed by a userspace program. I think setuid/setcap are the only things that are not obvious.

More multiplexing, why?

Posted Aug 4, 2026 20:20 UTC (Tue) by quotemstr (subscriber, #45331) [Link]

As I've said on LKML over the years, I just don't get the undying fascination kernel people have with multiplexers and ioctls. I think there's an intuitive appeal to object-orientation, to hierarchical divisions of responsibility, and while this intuition is good *in general*, I think it hurts us in UAPI. System call numbers aren't precious.

Also, you can still do *_ops structs and object-orientation with explicit system calls. You can have myobj_foo, myobj_bar, myobj_qux, etc. dispatch through ops structures even if they're exposed as separate top-level calls, so IMHO, there's no real tidiness advantage to a multiplexer.

Anything that can be userspace should be userspace

Posted Aug 4, 2026 20:16 UTC (Tue) by quotemstr (subscriber, #45331) [Link] (12 responses)

I wholly support the goal of providing an extensible build-and-launch process API and encouraging its use over fork/exec and posix_spawn. But does the *kernel* need to provide this API?

Imagine you had a special process-launch helper (say, a souped-up /usr/bin/env) that you could spawn, give process-building instructions, and, when done, let run? And this process would do all the environment configuration you wanted, then execve the final binary, reporting over an inherited pipe any errors in the setup process?

ISTM the Linux kernel is already too big a TCB and that we'd do well to implement new functionality where possible in userspace and userspace libraries, leaving to the kernel the jobs only a privileged executive can do.

Anything that can be userspace should be userspace

Posted Aug 4, 2026 21:05 UTC (Tue) by geofft (subscriber, #59789) [Link] (1 responses)

I think this does need to be in kernelspace for both performance and functionality reasons.

The previous discussion that inspired this one was specifically about performance of code that spawns several helper processes, in the UNIX tradition of "do one thing and do it well." If the way to make things work is to run a userspace helper that then execs the actual userspace binary, that will make things strictly slower from the status quo.

On the functionality side, the only way you could have the userspace helper implement preserving/renumbering file descriptors is to have the helper inherit every single file descriptor and then choose which ones to close. This runs into the same problem as we currently have with the lack of O_CLOFORK, i.e., there is a period of time where another process on the system, marching at its own pace, has a file open because it happened to be open at the time that that process was spawned. Even if the parent process has since closed the file descriptor, the underlying file is still open. This impacts both things like when a flush to disk happens and also e.g. whether you can exec the file without running into ETXTBSY. A spawn-like syscall has the ability to atomically (from the perspective of userspace) create a new process that never has unwanted file descriptors open.

(I guess you can technically get this right in userspace if you have a clone flag that only preserves stdio, and you redirect stdio to a UNIX socketpair, and you pass fds over that socket and have the helper install them in the right places. But that is complicated, and I also recently learned that it fails if you compile a kernel without networking support since sockets are networking.)

We do already have fork/exec, which is actually very well designed under the constraint that you want all the complexity to be in userspace; it gets you full flexibility to run any syscall between fork and exec and to start with the entire parent process's state to borrow from as you wish. The goal of this new interface is to solve the problems that are unavoidable with that approach.

BTW, I think there is a somewhat stronger argument to move execve into userspace. The only thing that it does right now that you can't really do from userspace is set /proc/self/exe correctly. Other than that, the task of opening a file, mapping in the executable segments, unmapping everything currently in memory, and jumping to the start address is pretty straightforward from userspace, and there are both complexity and functionality arguments for moving ELF parsing out of the kernel. (The obvious downside is now you get fun cases like running a static or emulated binary that doesn't know how to parse the executables in /usr/bin. If this functionality is in the kernel, it's guaranteed to be available to all processes equally easily.)

Anything that can be userspace should be userspace

Posted Aug 4, 2026 22:13 UTC (Tue) by mb (subscriber, #50428) [Link]

>I think this does need to be in kernelspace for both performance and functionality reasons.

This sounds like the perfect job for by new invention which a abbreviate as:
BPFBPFBPF

(BPF-Blazingly-Performant-Fast-Blanket-Process-Forking)

Anything that can be userspace should be userspace

Posted Aug 4, 2026 22:19 UTC (Tue) by Cyberax (✭ supporter ✭, #52523) [Link] (4 responses)

I think it has to be a proper kernel API. Otherwise, it'll end up being unusable in generic code because of all the complexities of namespacing, labeling, privileges, etc.

It will not be able to achieve all the same semantics purely in the userspace. And an "empty shell" process is an incredibly powerful primitive in its own right. I can see tons of possible use cases.

For example, you can safely fill in the process environment (env vars, FDs, etc) in a privileged context without actually running it. Then you can pass the process descriptor to a confined runner that actually launches it with all the proper namespacing.

I can also see eventually doing it in reverse, have a running process and create its suspended "snapshot". It would make the "zygote" pattern actually useful for processes with multiple threads.

Anything that can be userspace should be userspace

Posted Aug 5, 2026 0:25 UTC (Wed) by quotemstr (subscriber, #45331) [Link] (3 responses)

> unusable in generic code because of all the complexities of namespacing, labeling, privileges, etc.

It continually disappoints me that the kernel has to do things that are really the job of userspace because in many domains in which Linux is used, userspace is so brittle and hard to modify that even linking to glibc is considered risky.

I don't think I buy a performance argument for a kernel-mode process builder: you're running the dynamic linker interpreter anyway (all your binaries are PIE, right? Right??). The interpreter has to load the actual program anyway, and I don't see why the interpreter couldn't be made to do a bit of process setup before transferring control to the main program. There are multiple ways to get correctness too.

But deployment? Yeah, I can see how that can be hard in some godforsaken Kubernetes hell. That's a good, if tragic, ecological argument for doing this kernel-side. That said, I can't wait for ten years of being unable to use this feature because gvisor won't implement it or some provider has seccomp-ed out every system call invented since 1995.

It shouldn't be this way. It doesn't have to be this way. And if I were building an OS from scratch, I'd strictly adhere to the rule that *everything* that could *plausibly* be outside the kernel *is* outside the kernel. But that's not the OS we have.

Anything that can be userspace should be userspace

Posted Aug 5, 2026 8:46 UTC (Wed) by pwfxq (subscriber, #84695) [Link]

> if I were building an OS from scratch, I'd strictly adhere to the rule that *everything* that could *plausibly* be outside the kernel *is* outside the kernel

A micro-kernel?

</sarcasm>

Anything that can be userspace should be userspace

Posted Aug 5, 2026 8:52 UTC (Wed) by chris_se (subscriber, #99706) [Link]

> It shouldn't be this way. It doesn't have to be this way. And if I were building an OS from scratch, I'd strictly adhere to the rule that *everything* that could *plausibly* be outside the kernel *is* outside the kernel. But that's not the OS we have.

Welcome to Hurd. ;-)

https://www.debian.org/ports/hurd/
https://wiki.gentoo.org/wiki/Project:Hurd

Anything that can be userspace should be userspace

Posted Aug 6, 2026 1:55 UTC (Thu) by Cyberax (✭ supporter ✭, #52523) [Link]

It's problematic to do 100% robustly without having true microkernel infrastructure.

In particular, it's impossible to tell if the wrapper process crashed or if it was the target executable. And there's no reliable way to distinguish between stuck processes and just very slow processes.

The _one_ thing I'd like to see even in a microkernel

Posted Aug 5, 2026 9:05 UTC (Wed) by chris_se (subscriber, #99706) [Link] (1 responses)

> Imagine you had a special process-launch helper (say, a souped-up /usr/bin/env) that you could spawn, give process-building instructions, and, when done, let run? And this process would do all the environment configuration you wanted, then execve the final binary, reporting over an inherited pipe any errors in the setup process?

You'd still need an additional syscall to spawn that helper program. (Otherwise you'd do the current fork/execve dance anyway, and then a helper doesn't improve your situation.)

You'd need some form of IPC in order to get that process to do what you want, and that IPC has to be powerful enough to be able to transfer file descriptors from your process to that process. (One of the main things the proposed spawning API does is give the user control over which file descriptors are in the initial set of descriptors passed to the new program.)

The operation itself would not be atomic - while the helper program is running, you do already have a subprocess, but if execve happens to fail in that program, that process ends, and error handling becomes way harder for the main process (especially if you have multiple threads and another thread has a general wait() running). Sure, that situation is the same as currently, but when designing something new you'd want to improve upon the existing.

You have to rely on that helper program existing/working in the moment you want to create a process (unlike e.g. a shared library in userspace where starting your program doesn't start up if it's not available / broken), this makes this more error-prone.

Finally, you'd still construct the helper program first, which definitely is a performance hit has compared to immediately starting the requested program.

Even from a microkernel perspective, managing processes and memory management are _the_ two key features that a microkernel should provide in the kernel directly. I believe a specific "start a new process with the following resource configuration" functionality is _the_ one functionality that would belong in a microkernel, and not a userspace helper.

Smelling blood in the water,

Posted Aug 13, 2026 9:21 UTC (Thu) by ksandstr (guest, #60862) [Link]

>Even from a microkernel perspective, managing processes and memory management are _the_ two key features that a microkernel should provide in the kernel directly. I believe a specific "start a new process with the following resource configuration" functionality is _the_ one functionality that would belong in a microkernel, and not a userspace helper.

Empirically false; on the contrary it's entirely feasible to do POSIX on top of a microkernel that knows neither process nor file. Such kernels[0] instead abstract address spaces, threads, memory mappings[1], and exception handling, with these being mediated from system level to lesser privilege by synchronous IPC that provides client atomicity. Process spawning external to the microkernel then configures the new process' pager (such as a systemwide memory server), and any filesystems implementing the files it maps, to define the new program; and this is the equivalent of calling an arbitrarily complex spawn67() over IPC. That extends to non-files such as sockets and the like.

This seems like a lot of hair splitting, but the kernel-to-system divide runs in the very definition of a microkernel. Nothing in that detail applies to monolithic kernels either: they enjoy a similar API freedom internally and so lack the gains from going usermode, but are weighed down by their POSIX externals if they went anyway.

As for solving file descriptor TOCTOU in a process with unruly threads (e.g. syscalls that started before the spawn() call) messing with open files during a running syscall, it's simple enough to retard spawn() first and then halt other threads for its duration at the point where they'd access any file. (mutexes would do in a pinch.) Lighter methods may be possible, but that one is a complete solution albeit on the petri dish scale.

[0] Where "kernel" is the supervisor component, "system" is privileged non-POSIX user mode, and "user" (or "app") is cat(1) inna terminal window, its loader, the terminal program, ..., and the horse they rode in on.
[1] And even that is more a matter of portability and security (i.e. defense in depth), where an alternative would allow privileged programs to writably map their own page tables and those of other programs. Such "exokernel" optimization is long dead.

Anything that can be userspace should be userspace

Posted Aug 5, 2026 11:45 UTC (Wed) by grmnsftphr (subscriber, #178591) [Link] (2 responses)

>ISTM the Linux kernel is already too big

There's the longtime systemd kernel that's even much bigger and will at some time encompass all known programs.

Unsubstantiated systemd bashing

Posted Aug 5, 2026 11:59 UTC (Wed) by chris_se (subscriber, #99706) [Link]

Could we please not? This feels like it's 2015 all over again.

Why?

Posted Aug 5, 2026 13:17 UTC (Wed) by corbet (editor, #1) [Link]

You seem to be determined to stir up old flame wars and be generally disruptive in the comments; I have asked you a few times now to stop. Don't do this anymore.

Would it be better to generalize existing calls?

Posted Aug 5, 2026 15:01 UTC (Wed) by epa (subscriber, #39769) [Link] (1 responses)

This is great work, but an idea I think deserves more consideration is to generalize existing Unix system calls so they can operate on another process. For example open() would get an extra argument giving the process id; the parent process could use it to open files in the child. dup2() and chdir() could also operate on another process. And so on. This seems more like the "Unix way". (Of course these new more general syscalls would need a new name, but in spirit they are the same primitives we already know.) After creating the child process and setting up its files, a final start_process() call would make it start running.

Even if the implementation were restricted, so that you can only start opening files in another process if you are its direct parent, it's running as the same user, and it hasn't yet started executing, that would still give a cleaner way to express the actions without the need for a new structure.

Would it be better to generalize existing calls?

Posted Aug 6, 2026 7:17 UTC (Thu) by donald.buczek (subscriber, #112892) [Link]

I've had the very same idea. proxy_syscall(pidfd, number, ...) or something.

Code could also be injected into the child with ptrace().

But I think the whole back-and-forth between the parent and the child somehow works against the goal, making process creation less efficient?

So the next logical step might be to batch the remote operations.

Capabilities

Posted Aug 13, 2026 2:14 UTC (Thu) by jdub (subscriber, #27) [Link]

Seems like a missed opportunity to take some design inspiration from capabilities (not Linux "capabilities", real ones), especially given much of the juggling involves file descriptors…


Copyright © 2026, Eklektix, Inc.
This article may be redistributed under the terms of the Creative Commons CC BY-SA 4.0 license
Comments and public postings are copyrighted by their creators.
Linux is a registered trademark of Linus Torvalds