11.1 Wiring
peinit is not a logging system — eventd stores, indexes and queries logs. But peinit holds the pipes at birth. It decides where a service's output goes, and it has to cover the window before eventd exists.
11.1.1 The pipes #
Before exec, peinit creates a pipe for stdout and one for stderr with
pipe2(O_CLOEXEC). The child's streams are redirected onto the write
ends; peinit keeps the read ends and watches them with epoll.
The blocking discipline is asymmetric, deliberately :
- The parent's read ends are non-blocking. PID 1 cannot afford to block on a read.
- The child's write ends keep ordinary blocking semantics.
That second half is what makes backpressure work. When a service
produces faster than peinit consumes, the kernel pipe buffer fills and
the service's own write() blocks, so the service slows down. Making
the write ends non-blocking would turn that into EAGAIN failures in
the service, converting a flow-control mechanism into an error the
service has to handle.
The child's stdin is redirected to /dev/null.
peinit provides no
interactive input channel; a service needing input obtains it explicitly
— through a socket, or a stored descriptor — never through inherited
stdin.
The epoll instance itself is close-on-exec and is never inherited.
11.1.2 Tagging #
peinit reads output line by line and tags each line with:
- the origin,
- the stream — stdout or stderr,
- a
CLOCK_REALTIMEtimestamp, - the job's identifier.
The origin is the service name for a main process. For a hook it is the
service name, the hook kind and its index — jellyfin/ExecStartPre[0].
Reload commands are <service>/ExecReload and health checks
<service>/HealthCheck. A submitted job, which has no service, is
jobs/<guid>.
Health check output is captured, which is usually the only way to find out why a health check failed.
11.1.3 The output sink #
A submitted job's output is recorded like any other's; that is
unconditional, and eventd is always the primary destination. A
submitter may additionally ask for a copy by attaching a descriptor
with output: true (§8.5). When the job's pipes are registered, the
runtime adopts that descriptor as the job's sink: it is put in
non-blocking mode, and every line read from either pipe is written to
it as the line followed by a newline, untagged, in the order the lines
were read. The two
streams interleave; the sink gets what the job wrote and nothing else.
The copy is best-effort and the record is not. A write that would
block drops the line for the sink only, counts it, and on the first
drop for that job emits one peinit.job.output.dropped event — one per job,
however many lines follow — so a submitter that sees a gap can learn
it caused one.
Any other write failure closes the sink at once. The
sink is otherwise closed when the job's last pipe closes, after the
last line has gone to it, and a sink attached to a job that never got
pipes is closed without a write.
11.1.4 Terminal-attached services #
A service with a TTYPath has all three streams on its terminal, and
both pipe pairs are closed in the child (§5.4). Its output is not
captured at all — it goes to the terminal, which is what asking for one
means.
peinit opens the terminal with read data, write data and read
attributes. The
last matters because the descriptor is inherited by
every process the service starts, and a KACS handle answers fstat
only if that right is on it: without it a session can read and write
its terminal but not name it — ttyname() fails, tty reports "not a
tty", and a program choosing TERM cannot tell a serial line from a
virtual console. isatty() is an ioctl and works either way, which is
why the gap is easy to miss.