# Exercise 08 — The app that vanishes when the helper crashes

**Failure mode:** unhandled `SIGPIPE` — a peer's death terminating your process
**Language:** C · **Runtime:** about 1 second · **Difficulty:** the one with no error message

---

## The prompt

> An export feature streams a document to a helper process over a pipe. The helper does the
> conversion and is expected to outlive the transfer.
>
> The field reports: "if the helper crashes mid-export, our app vanishes too. No alert, no
> crash report we can read, nothing in our logs — the process is simply gone. It only
> happens when the helper dies first."
>
> Reproduce it, explain why there is nothing in the logs, and fix it so the failure becomes
> something you can report. Then tell me why the obvious one-line fix is the wrong one for
> a framework.

Answer before you run anything: **what is the default disposition of `SIGPIPE`?**

---

## What you are given

| Path | What it is |
| --- | --- |
| `broken/helperlink.c` | The starting point. Writes to a pipe whose reader dies. |
| `fixed/helperlink.c` | One correct repair. |
| `solution.patch` | The diff between the two, applies with `patch -p1`. |
| `check.sh` | Asserts the signal death and the clean failure, twice each. |

---

## Reproduce the measurement

```bash
cd exercises/08-epipe
clang -O2 -g -Wall -Wextra broken/helperlink.c -o /tmp/hl_broken
clang -O2 -g -Wall -Wextra fixed/helperlink.c  -o /tmp/hl_fixed
/tmp/hl_broken ; echo "exit status: $?"
/tmp/hl_fixed  ; echo "exit status: $?"
```

Observed on an Apple M4 Pro, macOS 26.3 (25D125):

```
broken                                fixed
phase=streaming                       phase=streaming
exit status: 141                      phase=peer-gone errno=32 message=Broken pipe
                                      phase=finished bytesWritten=393216
                                      helperLost=1
                                      exit status: 0
```

**141 = 128 + 13, and 13 is `SIGPIPE`.** That arithmetic is worth memorising: any shell exit
status above 128 is a signal death, and subtracting 128 names the signal.

---

## The evidence to collect

1. **The broken build prints `phase=streaming` and then nothing.** Not an error, not a
   partial result, not a log line. The process does not survive its own `write(2)` call, so
   no code after that call ever runs — including the error handling that *is already there*
   in the source. Read that again: the broken build **has** an `if (w < 0)` branch, and it
   is unreachable.

2. **Exit status 141.** The single piece of evidence that identifies the mechanism. In a
   crash reporter this appears as a termination by signal rather than an exception.

3. **`errno=32` in the fixed build.** `EPIPE`, "Broken pipe" — the error the `write` was
   always going to return, if the process had been allowed to see it.

4. **`bytesWritten = 393216`.** 48 chunks of 8 KiB made it into the pipe buffer before the
   reader's death was noticed. The transfer genuinely started and genuinely did not finish,
   which is exactly the state your error handling has to describe to the user.

---

## Success criteria

- [ ] The broken build is still killed by a signal — you have not edited it.
- [ ] Your fixed build exits **0** and reports `helperLost=1`.
- [ ] Your fix is scoped to **one descriptor**, not to the whole process, and you can say
      why that matters for library code.
- [ ] You handle `EPIPE` distinctly from other `errno` values, and you can say what your
      caller should do about it.
- [ ] Both results hold on two consecutive runs.

Run `./check.sh` to have all of that checked for you.

---

## Hints

<details>
<summary>Hint 1 — the write is not returning an error</summary>

Add a `printf` immediately after the `write` call in the broken build and run it again. It
never prints. The problem is not that you are mishandling an error; it is that you are never
given one.
</details>

<details>
<summary>Hint 2 — signals have dispositions, and this one's default is fatal</summary>

Writing to a pipe or socket with no reader raises `SIGPIPE`. Its default disposition
terminates the process. Turn that into an error return and the `if (w < 0)` branch that is
already in the code starts working.
</details>

<details>
<summary>Hint 3 — there are two ways, and they are not equivalent</summary>

One is process-wide and one is per-descriptor. If you were writing a framework that gets
linked into somebody else's application, which of those are you entitled to change?
`fcntl(2)` has the answer; sockets have an equivalent socket option.
</details>

---

## Solution

<details>
<summary>Reveal the solution</summary>

### The repair

```c
if (fcntl(p[1], F_SETNOSIGPIPE, 1) != 0) { perror("F_SETNOSIGPIPE"); return 1; }
...
ssize_t w = write(p[1], doc, CHUNK);
if (w < 0 && errno == EPIPE) {
    helper_lost = 1;
    printf("phase=peer-gone errno=%d message=%s\n", errno, strerror(errno));
    break;
}
```

`fcntl(2)` documents `F_SETNOSIGPIPE` as determining "whether a `SIGPIPE` signal will be
generated when a write fails on a pipe or socket for which there is no reader". With it set,
`write` returns `-1` with `errno == EPIPE` and your process stays alive to handle it.

### Why not `signal(SIGPIPE, SIG_IGN)`

It works, and it is **process-wide**. An application may legitimately want `SIGPIPE` to
terminate it — that is the behaviour that makes `producer | head -5` exit cleanly instead of
running forever. A library that silently changes its host application's signal disposition
has reached outside its own boundary to make a decision that was not its to make. The
per-descriptor control exists precisely so you do not have to.

For sockets the equivalent is the `SO_NOSIGPIPE` socket option, set with `setsockopt`. For a
single send you can also pass `MSG_NOSIGNAL` to `send(2)`.

### The half of the repair that is not a flag

Suppressing the signal makes the failure *reportable*. It does not make it *handled*. A peer
in another process can die at any moment — that is the entire reason you put it in another
process — so "the helper is gone" is a normal, expected outcome of this API and belongs in
its contract:

- **Say so to the caller.** A distinct error, not a generic I/O failure. The caller's
  recovery for "the disk is full" and "the converter crashed" are different.
- **Decide about restarting.** Relaunch and retry once? Fail the operation? If you retry,
  bound it — a helper that crashes on this particular document will crash on it again.
- **Clean up the partial output.** `bytesWritten = 393216` of a document went somewhere.
- **Reap the child**, or accumulate zombies. The fixture's `waitpid` does this.

### The same failure in other clothes

- **`read` from a dead peer** returns 0 (end of stream), not a signal. Asymmetric, and a
  common source of confusion: the writer dies loudly, the reader ends quietly.
- **An XPC connection** delivers `XPC_ERROR_CONNECTION_INTERRUPTED` when the peer crashes
  and `XPC_ERROR_CONNECTION_INVALID` when it is gone for good. Same event, a designed API
  around it, and no signal — which is a concrete argument for XPC over raw pipes between
  processes on macOS.
- **A Mach send** to a port whose receive right is gone fails with
  `MACH_SEND_INVALID_DEST` rather than killing anything, and a *dead-name notification*
  tells you asynchronously.
</details>

---

## Going further

- Swap the pipe for a `socketpair` and use `SO_NOSIGPIPE` instead. Confirm the same
  behaviour and note that the option is set on the socket, not on a descriptor flag.
- Make the helper exit **after** consuming everything rather than early. The broken build
  now passes. Write down what that tells you about tests that only exercise the happy path
  of an inter-process protocol.
