File Descriptors Are Not Just Integers
Ownership semantics, lifecycle visibility, and deterministic teardown inside a modern C++ runtime.
File Descriptors Are Not Just Integers

The Runtime Was Already Working
The sockets already worked. UDP ingress worked. The UNIX control socket worked. Shutdown worked.
At that stage, EdgeNetSwitch only owned a very small number of long-lived file descriptors. Technically, the runtime did not have a descriptor bug. It was developing descriptor semantics.
That distinction ended up mattering far more than the descriptor count itself. Once a runtime starts caring about deterministic teardown, runtime observability, ownership boundaries, lifecycle correctness, and operational validation, a POSIX file descriptor stops behaving like “just an int.”
The integer is only the kernel handle. The harder problem becomes ownership.
Linux Never Treated Them As “Just Integers”
The common argument is technically correct:
int fd;
A descriptor is numerically represented as an integer. But Linux itself does not treat descriptors as primitive values.
The kernel treats them as handles into process-owned kernel resources:
- sockets
- pipes
- eventfds
- files
- device interfaces
Each descriptor participates in lifecycle semantics: allocation, ownership, transfer, release, and invalidation.
The integer itself is only the lookup token. The operational meaning exists outside the integer.
That distinction becomes increasingly important once runtime components begin depending on descriptor lifecycle behavior.
The Socket Wasn’t The Problem
The sockets themselves were stable, and the runtime remained relatively small. There were no large-scale descriptor tables, no thousands of concurrent sockets, and no hyperscale infrastructure story.
What changed was the architectural expectation surrounding ownership.
Initially, the descriptor was mostly local state:
int socket_fd = ::socket(AF_UNIX, SOCK_STREAM, 0);
Later:
::close(socket_fd);
At small scale, this feels harmless. One socket. One owner. One shutdown path.
But the runtime gradually evolved additional responsibilities:
- ingress ownership
- control-plane ownership
- runtime inspection
- shutdown coordination
- lifecycle visibility
The descriptor itself did not change. What changed was the number of runtime concerns now depending on its lifecycle semantics.
The integer was still simple. The ownership assumptions were not.
Implementation Correctness Was No Longer Enough
The runtime already behaved correctly. Packets flowed. Sockets opened. Sockets closed.
The issue was not implementation failure. The issue was that the runtime had started accumulating ownership semantics that were no longer explicitly modeled.
That distinction matters in long-running runtime systems, especially once resources begin surviving across subsystem boundaries.
Consider something as simple as this:
void ConfigureSocket(int fd); // Who owns teardown?
void RegisterControlSocket(int fd); // Who closes this?
void ExportRuntimeState(int fd); // Does visibility survive transfer?
Nothing here explains who owns teardown authority, who is allowed to close the descriptor, whether ownership transfer is legal, whether duplication is intentional, or whether runtime visibility survives transfer.
At that point, the descriptor stops being “local implementation detail.” It starts becoming runtime state.
Why Shared Ownership Was Intentionally Avoided
The obvious modern C++ reaction is usually:
“Wrap it in a smart pointer.”
That direction was intentionally rejected. Not because RAII was wrong but because descriptor ownership semantics mattered more than automatic cleanup.
A runtime-owned descriptor is usually controlled by one specific subsystem. That subsystem decides when the descriptor is valid, when it should be closed, and whether ownership may move somewhere else.
std::shared_ptr<int> represents a different ownership model. It assumes that multiple parts of the system jointly own the same resource and collectively influence its lifetime.
That was not how EdgeNetSwitch wanted descriptor ownership to behave.
Using std::shared_ptr<int> would blur ownership boundaries in ways the runtime did not want:
- multiple subsystems appearing to share teardown responsibility
- destruction timing depending on reference-count behavior
- descriptor lifetime becoming harder to reason about during shutdown
- ownership authority becoming less explicit
None of those matched the runtime model.
EdgeNetSwitch instead prioritized explicit ownership, deterministic teardown, observable lifecycle state, and predictable shutdown behavior.
The runtime needed one authoritative owner. Not collaborative ownership.
Why std::unique_ptr Still Wasn’t The Right Abstraction
At first glance, std::unique_ptr<int> looks much closer to the runtime’s ownership model.
Single owner. Move-only semantics. Deterministic destruction.
Architecturally, those properties were correct.
The problem was deeper:
a file descriptor is not a heap-owned integer.
std::unique_ptr models ownership of dynamically allocated memory:
std::unique_ptr<int>
A POSIX file descriptor is fundamentally different:
- a kernel-managed process resource
- cleanup through ::close(fd)
- semantic invalid state (-1)
- operational lifecycle semantics
Even with a custom deleter:
std::unique_ptr<int, FdCloser>
the abstraction still felt unnatural.
The runtime would first allocate heap memory, then store the descriptor inside that allocation, and finally customize destruction to call ::close(fd) instead of delete.
Technically, this is entirely valid.
But the ownership model becomes indirect:
- unique_ptr owns heap memory
- the heap memory contains an integer
- the integer refers to a kernel resource
The runtime no longer models descriptor semantics directly.
It models pointer semantics around a descriptor value.
That distinction eventually became important.
The runtime needed explicit descriptor behavior:
- relese()
- lifecycle-state transitions
- shutdown visibility
- runtime registration hooks
- descriptor-specific invariants
At that point, a dedicated type became the simpler and more accurate abstraction:
class FileDescriptor
{
private:
int fd_{-1};
};
The runtime needed descriptor behavior, not pointer behavior.
The Runtime Needed Explicit Descriptor Semantics
Eventually, the runtime evolved toward a move-only descriptor abstraction:
class FileDescriptor
{
public:
FileDescriptor() noexcept = default;
FileDescriptor(int fd) noexcept;
FileDescriptor(int fd, FdRegistry *registry, FdType fdType) noexcept;
~FileDescriptor();
// Prevent copying: multiple objects must not own the same FD.
FileDescriptor(const FileDescriptor &) = delete;
// Prevent assignment copying: avoids FD leaks and double-close bugs.
FileDescriptor &operator=(const FileDescriptor &) = delete;
FileDescriptor(FileDescriptor &&other) noexcept;
FileDescriptor &operator=(FileDescriptor &&other) noexcept;
[[nodiscard]] int get() const noexcept;
[[nodiscard]] bool valid() const noexcept;
int release() noexcept;
void reset(int fd = -1, FdType type = FdType::Unknown) noexcept;
private:
int fd_{-1};
FdRegistry *registry_{nullptr};
FdType fd_type_{FdType::Unknown};
};
The deleted copy semantics were not stylistic. They encoded ownership law.
A runtime-owned descriptor could not accidentally become multi-owned through copy propagation.
“Move-only” means ownership can be transferred, but never duplicated.
Move semantics, however, represented legitimate ownership transfer. Ownership transfer is not the same thing as ownership duplication.
The runtime intentionally allowed one while forbidding the other.
Tracking Resources Is Not The Same Thing As Owning Them
This eventually led to one of the most important architectural boundaries in the runtime:
tracking resources is not the same thing as owning resources.
EdgeNetSwitch introduced an observational FdRegistry. Critically, the registry does not own descriptors. It tracks them.
That distinction is foundational.
The registry exists for runtime inspection, lifecycle visibility, shutdown validation, and operational diagnostics. Not ownership authority.
The move-only FileDescriptor retains teardown authority over the descriptor lifecycle inside the runtime.
This separation turned out to be important architecturally. Once observability infrastructure starts owning runtime resources, lifecycle boundaries become blurry very quickly.
The registry cannot:
- extend descriptor lifetime
- prevent destruction
- authorize closure
- transfer ownership
It only records lifecycle state transitions. The ownership authority remains elsewhere.
Runtime Visibility Changed The Meaning Of Shutdown
Once descriptor lifecycle state became runtime-visible, shutdown semantics became easier to reason about operationally.
Descriptors eventually gained observable states:
- active
- released
- closed
Those states were not added for cosmetic observability. They existed because the runtime needed explicit lifecycle visibility.
An active descriptor means runtime ownership still exists.
A released descriptor means ownership intentionally left runtime control boundaries.
A closed descriptor means teardown completed successfully.
This made shutdown validation much more explicit.
The runtime could now answer questions that raw integers could not:
- Which descriptors are still active?
- Which descriptors were intentionally released?
- Which descriptors reached terminal closure state?
- Did shutdown complete with unresolved runtime-owned descriptors?
The runtime could also expose descriptor state directly:
{
"fd": 4,
"type": "unix-control",
"state": "active"
}
The descriptor itself was never the hard part. The lifecycle visibility was.
The Descriptor Was Never “Just an Int”
As EdgeNetSwitch evolved, file descriptors gradually stopped behaving like disposable implementation details.
The runtime was no longer only opening sockets and calling ::close(fd).
It had started modeling ownership, lifecycle transitions, teardown responsibility, and runtime visibility explicitly.
The runtime itself remained relatively small.
But the architectural pressure was already real.
And that pressure had very little to do with the integer itself.
The descriptor was never “just an int.”
The integer was only the visible handle to a much larger lifecycle model.
메타데이터
- post_id
- ee08fb57d1e2
- slug
- file-descriptors-are-not-just-integers-ee08fb57d1e2
- url
- https://medium.com/@togunchan/file-descriptors-are-not-just-integers-ee08fb57d1e2
- canonical_url
- https://medium.com/@togunchan/file-descriptors-are-not-just-integers-ee08fb57d1e2
- author_url
- https://medium.com/@togunchan
- status
- ok
- fetched_at
- 2026-06-09 15:37:30