danos/docs/dynamic-libraries.md

125 lines
7.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Dynamic libraries on danos
A design note and milestone plan for shared objects: building them, loading them
with `dlopen`, and — the part that needs kernel work — actually *sharing* them
between processes. Directional, post-P5 of
[python-on-danos-milestones.md](python-on-danos-milestones.md); nothing on the
CPython bring-up path depends on it.
## Reconciling the earlier "rejected"
Dynamic libraries were evaluated once before and rejected — but as an answer to a
*different question*: whether they could claw back ReleaseSafe's measured ~2×
code size. They cannot (the safety checks inline at every call site; no library
scheme dedups them), and that verdict stands for that question. The reasons to
build them now are the ones that investigation never weighed:
- **`ctypes` and runtime FFI** — Python calling into a danos library without
rebuilding the interpreter. This is the piece that makes Python prototyping
self-serve: drop a `.so` on the image, `ctypes.CDLL` it, iterate.
- **Loadable CPython extension modules** — today every C extension means
relinking the interpreter (`Modules/Setup`); with `dlopen`, an extension is a
file.
- **One interpreter image, many Python services** — a statically-linked CPython
is tens of megabytes *per process*. A shared `libpython` mapped read-only once
(milestone D3 below) makes Python services cheap enough to be the default way
to prototype one.
- **Plugin-shaped applications** — the UI toolkit and the terminal will want
them eventually.
The scoping that dissolves the apparent contradiction is the **size doctrine**:
leanness is an *operating-system* property — the kernel and system services stay
small and statically linked, and none of them ever link the loader — while
*applications* have their own budget and may be big. Dynamic libraries are an
**application-layer facility**, full stop.
What also does **not** change: the public ABI stays the vDSO + the IPC
protocols. Shared objects are artifacts *within* one system image, versioned by
the build — not a new stable ABI surface for the OS.
## Design
- **Format and codegen are free.** ELF shared objects with position-independent
code; `zig cc -fPIC -shared` against the [libdanos-c](c-library-compatibility.md)
sysroot already emits them. The work is entirely on the loading side.
- **The loader lives in userspace, inside the libc.** `dlopen` reads the `.so`
through the VFS, maps its segments, applies relocations, resolves symbols
against the process and the `DT_NEEDED` dependency graph, runs constructors,
returns a handle. No kernel loader changes in v1 — segments land in anonymous
`mmap` as private copies.
- **Bind-now, always.** All relocations resolved at `dlopen` time
(`RTLD_NOW` semantics only). Lazy PLT binding buys startup latency danos does
not care about, at the price of a writable GOT dance and a much subtler
loader. Not worth it; keep it out permanently.
- **W^X from day one.** Map, relocate, then flip text pages read-execute —
which requires memory-protection change (`mprotect`-shaped) in the danos
`mmap` surface if it is not already there. No page is ever writable and
executable at once.
- **TLS in shared objects is deferred.** Thread-local storage models
(initial-exec vs. general-dynamic) are the deep end of every dynamic linker.
v1 refuses a `.so` with a TLS segment; revisit alongside the post-P5 pthread
subset, which is when it could matter.
- **Executables stay static until D4.** v1 is "a static binary that can
`dlopen`" — no `PT_INTERP`, no program interpreter, no dynamically-linked
`main` binaries. That keeps process startup untouched.
## Milestones
1. **D1 — dlopen in-process.** The `.so` build target; the loader in libdanos-c:
map, relocate (`RELATIVE`/`GLOB_DAT`/`JUMP_SLOT`), resolve, constructors;
`dlopen`/`dlsym`/`dlerror`/`dlclose`; private anonymous mappings; no TLS.
*Test:* QEMU `dlopen-hello` — load a `.so`, call a symbol, unload, reload.
2. **D2 — the FFI payoff.** `DT_NEEDED` dependency graphs; a **libffi port**
(x86-64 SysV assembly is upstream; the port is its closure-allocation paths,
which must respect W^X); CPython's `ctypes` enabled; extension modules
loadable from file. *Test:* QEMU — a Python script `ctypes.CDLL`s a danos
`.so` and round-trips a call; a `.so` extension module imports.
3. **D3 — actual sharing (the kernel milestone).** Shared read-only file-backed
mappings — a page-cache-shaped facility so N processes mapping `libpython`
hold one physical copy. This is the memory-win milestone and the only one
touching the kernel; design it with the existing shm machinery in view
(the shared-fate walks already locked the relevant paths). *Test:* N Python
services up; measure physical pages against N× the static baseline.
4. **D4 — dynamically-linked executables** (optional, evaluate after D3):
`PT_INTERP`, a danos program interpreter, and the spawn path teaching the
loader about it. Only worth it if the image-size or update story demands it.
## Risks and gotchas
- **Scope creep is the failure mode.** Every dynamic linker grows toward glibc.
The fences: bind-now only, no lazy binding ever, no TLS until pthreads demand
it, no dlopen-from-memory, no versioned symbols. Each fence removed is a
design discussion, not a patch.
- **Code loading is a security event.** `dlopen` turns file bytes into executable
code, so W^X discipline is table stakes and *what may be dlopened* is a
capability question — the natural danos answer is that loadability follows VFS
readability of the `.so`, and services' images are supervised like any other
artifact. Revisit explicitly at D3 when mappings become shared.
- **`dlclose` is where loaders go to die.** Constructors/destructors,
dangling function pointers, re-open identity. Keep v1 semantics honest and
simple: `dlclose` runs destructors and unmaps; holding pointers past it is
undefined; no reference-counted deferral cleverness.
- **The ReleaseSafe fact still applies to `.so`s** — a ReleaseSafe shared object
carries its inlined checks like any static code; D3's sharing saves *copies*,
not check overhead. Size expectations should be set accordingly.
## Decisions needing sign-off
- Dynamic libraries join the roadmap at all (this note exists because the
earlier size-motivated rejection was re-opened for ABI/sharing reasons).
- **Bind-now only; no lazy binding, permanently.**
- **Loader in userspace libc; kernel involvement only at D3** (shared read-only
mappings).
- **Static executables until D4**, and D4 only on demonstrated need.
## Related
- [c-library-compatibility.md](c-library-compatibility.md) — the sysroot the
loader ships in; its absence table gains `dlfcn.h` at D1.
- [python-on-danos.md](python-on-danos.md) — the `ctypes` story this unlocks.
- [python-on-danos-milestones.md](python-on-danos-milestones.md) — sequencing;
this work is post-P5.
- [os-development/memory-map.md](os-development/memory-map.md) /
[os-development/paging.md](os-development/paging.md) — where W^X and shared
mappings land.