Close-on-Exec in Device Services: Stop Helper Processes from Keeping Ports Open

Set close-on-exec when a descriptor is created, duplicated or received, unless it belongs to an explicit helper-inheritance contract. Adding FD_CLOEXEC later with fcntl leaves a multithreaded fork/exec window. A helper can retain a device port, socket or pipe after the parent closes its copy, preventing the release the service expected.

This guide follows Linux man-pages 6.12 and modern Linux atomic descriptor APIs. It covers descriptor inheritance across successful exec, not a universal child-process cleanup framework. An executed C fixture used two owned pipe/fork/exec cases on x86-64 Linux 6.18.44. No device port or production helper was opened.

Identify the reference that keeps the resource alive

Separate a descriptor number in one process from the shared open file description. The open contract describes the latter as the kernel object carrying file offset and status state. Forked or duplicated references can keep it alive after the original descriptor closes.

For a serial service, this can explain why “close the port” does not trigger the expected final release. For a pipe, a helper retaining an unintended write end can delay EOF at the reader. The exact device release effects belong to its driver, so diagnose the surviving references before attributing behavior to a broken close implementation.

Create a ledger of every descriptor-producing path: normal startup, error logging, reconnect, accepted clients, library helpers, duplication and received SCM_RIGHTS handles. Record intended owner, final close, and whether any executed helper should inherit it. A policy applied only to the obvious device-open call misses less visible references.

Side-by-side interleavings show a descriptor leaking when fork happens between open and fcntl, versus atomic close-on-exec creation, with a reminder that fork-only children still inherit references.
Original race diagram. Changing the parent flag after fork cannot repair the child copy already made.

Remove the open-then-fcntl window

The open documentation explicitly describes this race: thread A opens a descriptor; before A sets FD_CLOEXEC, thread B forks and the child executes another program. The child’s copy was made while the flag was clear. A setting the parent’s flag afterward does not repair that already inherited copy.

Use the atomic flag supported by the creation operation. The following audit rows identify Linux interfaces, not a complete portable implementation. Verify deployed kernel/libc support and treat unexpected failures as errors rather than silently retrying without the flag.

Descriptor path Creation-time inheritance control
Open a file or device open/openat with O_CLOEXEC.
Create a pipe pipe2 with O_CLOEXEC on both returned descriptors.
Accept a connection accept4 with SOCK_CLOEXEC for the newly accepted socket.
Duplicate into a selected descriptor dup3 with O_CLOEXEC when its distinct-descriptor contract applies.
Receive SCM_RIGHTS recvmsg with MSG_CMSG_CLOEXEC.

The pipe2 and accept4 references specify their respective flags. Do not infer the accepted descriptor’s inheritance policy from the listening socket’s settings. Likewise, wrappers that internally allocate descriptors need their own documented guarantees.

Audit duplication separately from creation

The dup documentation distinguishes shared file-status flags from descriptor flags. Ordinary dup creates a duplicate with close-on-exec clear. A source descriptor being protected does not automatically protect the new one. Review every duplication path, including logging redirection and helper setup.

dup3 can atomically select O_CLOEXEC, but unlike dup2 it rejects identical old and new descriptor arguments. Preserve that boundary in wrappers rather than swapping names mechanically. Also avoid close-then-dup sequences when atomic replacement semantics are required; descriptor allocation by another thread or signal handler can intervene.

For received handles, MSG_CMSG_CLOEXEC applies the flag during receipt. A sender’s flag is not the receiving process’s inheritance policy. Keep rejection cleanup separate: a close-on-exec descriptor is still live until it is closed or a successful exec occurs.

Make intended inheritance an allowlist

Some helpers legitimately need standard input/output pipes or a selected device handle. Declare those descriptors explicitly in a reviewed spawn plan. Keep everything else close-on-exec from creation. The helper’s documented descriptor contract should state access mode, ownership, expected lifetime and which side closes each reference.

Changing a descriptor’s inheritance flag in the parent for one helper can race with another concurrent spawn. Prefer a controlled child/spawn file-action path that establishes only the intended references, and check the exact process-launch API’s behavior. A global “temporarily clear all flags, spawn, restore” sequence is not a safe multithreaded policy.

Close-on-exec takes effect on successful exec, not on fork itself. A fork-only child can retain the reference indefinitely, and an exec failure leaves the child responsible for its error-path cleanup or exit. Do not leave a failed-launch child waiting in a diagnostic loop while it still holds the device’s last reference.

Use reference-based tests instead of descriptor numbers alone

An executed GCC 14.2.0 fixture created a pipe without close-on-exec, forked and executed the same small checker program. The checker verified that the selected inherited descriptor remained open. A second case used pipe2(O_CLOEXEC); after exec, fcntl on that descriptor failed with EBADF as expected. The parent closed both pipe ends and reaped each child.

This fixture checks the two inheritance outcomes under controlled conditions. It does not reproduce the concurrent open/fcntl race or establish a driver release callback. In a larger helper, descriptor numbers can be reused during startup, so observing that number open is not enough; compare the intended object’s identity and role using safe supported inspection.

Proposed service tests repeatedly launch a harmless helper while opening and closing owned test descriptors. Add accepted sockets, duplicated references and received handles. Verify the helper’s complete inherited-descriptor allowlist, not only the absence of one known number. Exercise fork without exec and intentional exec failure separately.

Close the lifecycle with an observable outcome

For an isolated target test, correlate parent close, child cleanup and the device-specific final release observation. Keep the resource safe and recoverable; do not use a production actuator or sole management port merely to demonstrate a leak. A reference surviving unexpectedly should produce a precise ledger entry showing its creation and owner.

The engineering result is an atomic descriptor-policy audit plus a helper inheritance test. That turns “the port sometimes stays open” into a bounded lifetime explanation and prevents unrelated executed programs from silently extending the service’s resource ownership.

Similar Posts