In the past, people have popped up to say “but that’s only in the bind() implementation side” (when talking about `sockaddr`, for instance) but I find this argument amusing. At the end of the day, if something breaks because of this, that nuance is lost amidst the mass of frustration.
People often point to the function calling conventions of the API, such as the "sockaddr" structures and the "errno" mechanism (that has historically been tricky for non-Unix operating systems and for systems where there are multiple C compilers with multiple C runtime libraries). But there are actual architectural decisions that had fairly reasonable alternative paths. I wouldn't say that it's a "mess" because of these alternatives, but it is definitely not the sole and unequivocal way of approaching things; and there were and are tradeoffs to be had.
Consider an alternative design (Plan 9 is something like this, it's been a while so I'm short on details):
1. Open /net/dns and write the desired hostname, then read back the resolved IP address
2. Open /net/tcp/clone and write the address and port, then read back a connection number
3. Open /net/tcp/$conn_id and the file is now a full duplex TCP stream
Compare this to BSD sockets: use getaddrinfo for the DNS lookup (gross!), create a socket with a syscall, then connect the socket with another syscall using sockaddr (gross!). Much worse and much less Unixy.
[1] that's the ideal of course, reality is different.
All the “it’s all a file” idea was added as an ideal along the way because the interface was so dang convenient. But I’ve never read it in any documentation.
Even pipes were added as a “that’s a cool idea.” There was a conversation, a late night coding session and pipes came into existence.
Plan 9 was a response to the evolved (vs designed) Unix. Took “the best” from it and made those design principles. It was an “if we could do it all over again, knowing what we know now” project.
What’s interesting to me is that evolved systems seem to dominate design-first systems in adoption. Maybe it’s that they’re pragmatic. I don’t know. Or maybe my observation is just wrong.
If you want to establish bi-directional communication with some process on the same host, that process should create two rendezvous points with mkfifo(2). Your process opens the read FIFO for reading, and the write FIFO for writing, and you're done. If you finish writing and want to just read, you close(2) the write file descriptor, and keep reading the read file-descriptor until you hit EOF.
If you want to establish bi-directional communication with some process on a remote host over TCP/IP, that process needs to listen(2). Your process connects to it with connect(2), and gets back a single-file descriptor that (unlike any other file-descriptor) is both writable and readable. If you finish writing and want to just read, you have to use the special shutdown(2) function, a quirk that only exist to work around the quirk of TCP sockets being both readable and writable.
Some might argue that this is a pretty minor wart, all things considered, and sure, it's nowhere near as confusing as the mess of terminal job control. But it also seems like it they could have implemented it in a non-quirky way if they'd just spent thirty seconds thinking about it beforehand.
Processor affinity, sequential execution on available/shared resources, hold queues for non-fatal errors, and policy enforcement are missing from job control.
This is how they optimised it in the days before COW, by fully preserving the intended behavioural semantics of fork/exec. You shouldn't modify memory in the child and the OS therefore shouldn't copy the memory space.
Essentially you run into the same problem - now the complexity of the spawn(...) interface is shifted to that binary.
You could simply use ordinary IPC for these things, though. They need not be implemented as part of the OS.
One example among many warts: in traditional Unix, user programs manipulate process IDs in a shared PID address space, not process handles private to one process (as in file descriptors). Therefore, nearly any operation done on a process through a PID is inherently racy. That's because between acquiring the PID of a process you want to interact with and performing an operation by PID, the PID could no longer refer to the process you were interested in (for example, the process could die and the kernel could recycle the PID for a new, unrelated process). There are ways to fix that (pidfd_open on Linux for example), but the sheer amount of legacy code out there means that these warts will stay around in Unix-like systems for a very, very long time.
Even if you somehow fix every single design issue with Unix and remove all the warts, the end result would not really look like Unix anymore. One example of a legacy-free, capability-based operating system with no implicit ambient authority is Fuchsia, whose kernel interfaces do not resemble traditional Unix syscalls at all.