# Architecture — how tethered works

tethered uses [`sys.addaudithook`](<https://docs.python.org/3/library/sys.html#sys.addaudithook>) (PEP 578) to intercept socket operations at the interpreter level:

- **`socket.getaddrinfo`** — blocks DNS resolution for disallowed hostnames and records IP-to-hostname mappings for allowed hosts.
- **`socket.gethostbyname` / `socket.gethostbyaddr`** — intercept alternative DNS resolution paths, including reverse-DNS lookups of raw IPs. An allowed `gethostbyname` (the event `gethostbyname_ex` raises too) records the name as a fallback candidate, so that a later connect to the address it returned can be matched back to the name. `socket.getnameinfo` is not checked: its audit event carries the address but not the flags, so a reverse lookup cannot be told apart from numeric formatting.
- **`socket.connect`** (including `connect_ex`, which raises the `socket.connect` audit event in CPython) — enforces the allow list on TCP connections.
- **`socket.sendto` / `socket.sendmsg`** — enforces the allow list on UDP datagrams. `sendmsg()` without an address raises the event with `None` in its place: that is a write on a connected socket, which names no destination, and it passes. From Python 3.12 asyncio's selector transports write this way (every `writelines()`, and whatever part of a `write()` one `send()` did not take).
- **`socket.__new__`** — no enforcement. It is used for two one-time warnings about event loops that tethered cannot see into: on Windows when a socket is created while an asyncio `ProactorEventLoop` is running, and elsewhere when one is created while uvloop is loaded, which can only be under a log-only policy or a scope without a policy (see [Event loops](<#event-loops>)).
- **`import`** — while a policy that is not log-only is active, an import of uvloop or pycares is refused with `ImportError` (see [Event loops](<#event-loops>)). Nothing else is done for import events.

This works with libraries built on CPython sockets (requests, httpx, urllib3, aiohttp) and frameworks like Django, Flask, and FastAPI — they all call `socket.getaddrinfo` and `socket.connect` under the hood. A library that resolves and connects inside a C library of its own raises neither event and is not seen; [the compatibility page](<https://tethered.shcherbak.ai/COMPATIBILITY/#client-libraries>) lists which of some seventy client-library arrangements do which. What a check costs per event is measured [below](<#performance>).

When `getaddrinfo` resolves a hostname, tethered records the IP-to-hostname mapping in a bounded LRU cache: up to 4096 addresses, keyed by the canonical text of the address (IPv6 compressed and without scope ID, IPv4-mapped IPv6 as the IPv4 address), each with up to 8 hostnames, most recent first. When a subsequent `connect()` targets that IP, tethered looks up the hostnames and checks them, and the raw IP, against the allow list; the connection is allowed if any of them is. If denied, `EgressBlocked` is raised before the connection is attempted.

One caveat: CPython resolves a hostname passed directly to `connect()` / `sendto()` before it raises the audit event, so that lookup has already happened when tethered refuses the call.

The map belongs to a policy: `deactivate()` drops it, and so does an `activate()` that changes the policy. An `activate()` that puts the same policy in place again (an identical serialized payload) keeps it, because a thread that has just looked a name up and is about to connect would otherwise find no name for the address and be refused a host the policy allows.

Addresses are read in every form CPython accepts: `str`, `bytes` or `bytearray` hosts, and any port object with `__index__`. An address on an IP-family socket that tethered cannot read is refused. `connect()` and `sendto()` always name an address; only `sendmsg()` can come without one, and that case is the write on a connected socket described above.

When a connection to an IP address is refused and no hostname is known for that address, the fallback search below included, the hook logs one hint per process (`_hint_at_unnamed_address`), provided the policy has hostname rules. Usually such an address is simply one that nothing allows. But a library that resolves names without `socket.getaddrinfo` (c-ares, dnspython, eventlet's greendns, gevent's ares resolver) leaves tethered with the address alone, and then every host that is allowed by name is refused with a message that names an address the user has never seen. The hint says where the name went and what to change.

## DNS-divergence fallback

The audit-hook architecture has an inherent limitation: tethered's resolution (called from inside the `socket.getaddrinfo` audit hook) and CPython's own resolution (whose result is returned to the application) are **two independent DNS queries**. For load-balanced services with short TTLs, and especially under gevent's threadpool resolver, the two queries can return different IP sets — leaving the connect-time IP unmapped. Eager population can never be perfect within the audit API.

When `_handle_connect` sees an unmapped IP, `_fallback_resolve` re-resolves up to 30 most-recently-allowed hostnames (LRU order, newest first) and checks if any maps to the connecting IP. On a hit it returns the hostname and enriches the IP map so future connects to that IP take the fast path. On a miss it returns `None` and the connect-time policy check then sees the bare IP, which doesn't match any hostname allow rule, and blocks. Hot path (mapped IP) is unaffected — the fallback fires only on misses, and it is skipped when every layer already allows the raw IP (loopback, an IP or CIDR rule), because no hostname could change that outcome.

Candidates are recorded after the scope checks, so only names whose lookup the global policy and every enforcing scope let through are ever re-resolved. `gethostbyname` lookups record a candidate too, but no mapping; the fallback is how an address obtained that way is matched to its name.

In locked mode, `_fallback_resolve` is the second authorized caller of `_c_guardian.resolve` (slot 1 in the C guardian's `expected_caller_codes` array; slot 0 is `_handle_getaddrinfo`). Like every function in the module it is covered by the C guardian's integrity snapshot, so replacing it with a no-op that defeats the divergence repair is detected. The resolver that `_c_guardian.resolve` calls is the `_socket.getaddrinfo` captured when the policy was locked or, in an interpreter that joined the lock, its own `_socket.getaddrinfo` as it was when it joined. Inside the resolution only the resolver's own `socket.getaddrinfo` event is exempt from the guardian's checks; a `connect`, `sendto` or `sendmsg` event raised from inside it is refused.

## Event loops

asyncio is supported on its own selector event loop: the socket calls that loop makes raise the same audit events, so asyncio streams, `aiohttp`, and `httpx` async go through the same connect-time checks as synchronous code. One thing differs there. `loop.getaddrinfo` runs `socket.getaddrinfo` in an executor thread, where the global policy applies and the caller's scopes are not visible; so that the connect can still be matched to the name, a lookup made in a context with no policy and no scope is recorded while any scope is active in the process.

Two event loops do their I/O through calls that raise no audit event, and an audit hook cannot add one. tethered enforces nothing it cannot see, so for the first of them it refuses to run at all, and for the second it logs a one-time warning.

- **uvloop** implements the event loop in libuv, below Python's `socket` module. Nothing the loop does itself raises an event: `loop.getaddrinfo`, `loop.create_connection(host, port)` and the streams built on it, both kinds of datagram endpoint, and the process launches behind `asyncio.create_subprocess_exec` / `create_subprocess_shell`. An audit trace taken without tethered shows `socket.getaddrinfo` and `socket.connect` for `asyncio.open_connection` on the selector loop and no event at all on uvloop. What still raises events under uvloop is a Python socket object: `loop.sock_connect(sock, ...)`, and blocking code wherever it runs.

  The hook cannot see into the loop, but it does see the import. `_REFUSED_IMPORTS` maps the names of the `import` audit events at which a library is refused to that library: `uvloop` and `uvloop.loop`, `pycares` and `pycares._cares`. Two names each, because `import uvloop` raises an event for the package while `importlib.import_module("uvloop")` raises none for it, only for what the package's `__init__` imports, of which `uvloop.loop` is the extension that holds the event loop. When such an event arrives while a policy that is not log-only is active, `_refuse_import` raises `ImportError` and logs the refusal once per library, because what prefers these libraries catches the error and falls back without a word: uvicorn then runs on the standard loop. Before it refuses the package itself it asks the import system whether the library exists (`importlib.util.find_spec` of a top-level name imports nothing), so that a server which merely probes for a loop it prefers gets Python's own "No module named" where there is none. Neither event is raised again once a library is loaded, so one that was imported before the policy is not touched by this.

  A uvloop that is already loaded cannot be taken away. `activate()` therefore refuses every policy that is not log-only when `uvloop` is in `sys.modules`: it logs `_UVLOOP_LOADED_MSG` at error level and raises it as `RuntimeError`, before anything is changed. It is logged as well as raised because the exception does not always reach anyone. A log-only policy refuses nothing, this included; for it, and for a `scope()` with no policy under it, `_warn_if_uvloop` logs one warning when `uvloop` is in `sys.modules`. tethered never imports uvloop, and that it is loaded is taken as the sign that it is in use. That check is made when a log-only policy is activated, when a scope is entered, and for a `socket.__new__` event while a policy or a scope is active, until the warning is out. The module-level flag `_uvloop_warning_pending` starts clear on Windows, where uvloop does not exist.

  All of this is per interpreter, like the rest of `_core`: an interpreter with no policy of its own is not kept from importing uvloop by a policy, or a lock, held in another one.
- **pycares** is not an event loop, but it is refused by the same mechanism and for the same reason. c-ares, which aiohttp resolves with whenever aiodns is installed, looks names up in C: tethered then sees a connection to an address it has no name for, no hostname rule can match it, and the lookup of a forbidden name still leaves the process. With the import refused, aiodns fails to import and aiohttp takes its threaded resolver, which calls `socket.getaddrinfo`. The refusal is logged at info level, since this is what a correctly working default installation does. A pycares that was loaded before the policy is left alone, unlike uvloop: what it resolves is refused at the connection.
- **The Windows `ProactorEventLoop`**, the default there, connects and sends through `_overlapped` calls that raise no `socket.connect` / `socket.sendto` event, so there is no connect-time enforcement on that loop. tethered logs its warning when a socket is created while such a loop is running (`_warn_if_proactor_loop`), and `asyncio.SelectorEventLoop` is the supported loop.

The import refusal is a guard against a deployment that would silently not be enforced. It is not a barrier to code that is out to get around tethered: such code can load any other extension that does its own networking.

## Scopes

`scope()` uses `contextvars.ContextVar` to push a per-context policy onto a stack. The audit hook checks the context-local scope stack in addition to the global policy. When a scope is active, a connection must pass both the global policy and every scope on the stack. When the context manager exits (or the decorated function returns), the scope's own entry is removed from the stack, by identity, so exits that happen out of order do not disturb other scopes. A generator suspended inside a `with scope():` block leaves that scope on the consumer's stack until it resumes. Because `ContextVar` is async-safe, scopes propagate correctly through `await` chains and `asyncio.create_task()`. Scopes do **not** automatically propagate to child threads — use `scope()` at the I/O point inside the thread, or use `activate()` for a process-wide ceiling.

## Subprocess audit handling

In addition to socket events, the audit hook intercepts process launches:

- `subprocess.Popen`, `os.system`, `os.exec*`, `os.posix_spawn`, `os.spawn*` and `os.startfile`;
- the two events raised by launches that bypass `subprocess.Popen`, such as `multiprocessing` workers: `_winapi.CreateProcess` on Windows and, from Python 3.14, `_posixsubprocess.fork_exec` on POSIX.

At these events it enforces `external_subprocess_policy`, establishes the policy payload in the child's launch environment, makes the locked-mode payload-integrity check, and hands an active scope on to a spawn-mode child (through the `_execute_child` frame of `subprocess.Popen`, and for an `os.exec*` of the same interpreter by carrying the exec out with a completed environment).

It also handles `os.putenv` / `os.unsetenv` for the policy variable, because `os.environ` does not show changes made through them. A change made from another interpreter or below Python shows up neither in `os.environ` nor in these events, so in locked mode a launch that inherits the environment also reads the variable from the process environment itself (see [The policy variable](<#the-policy-variable>)).

A launch that raises none of these events is not handled at all:

- `multiprocessing` on POSIX before Python 3.14;
- a worker that multiprocessing's fork server forks on request (the request goes over a socket; `activate()` warns once when a fork server is already running, `_warn_if_fork_server_runs`);
- `asyncio.create_subprocess_exec` / `create_subprocess_shell` on a uvloop event loop, which starts the process in libuv (on the selector loop they go through `subprocess.Popen`).

See [the subprocess page](<https://tethered.shcherbak.ai/SUBPROCESS/index.md>) for the full mechanics.

An audit hook cannot change the arguments of the call it is asked about, so the hook gives a child its policy in two indirect ways. For `subprocess.Popen` it rewrites the `env` local of the `_execute_child` frame, which the launch reads afterwards. For `os.exec*` there is no such frame, and the environment has been converted by the time the event is raised; but an exec does not return, so for an exec of the same interpreter whose explicit environment lacks the payload the hook makes the call itself (`_exec_with_policy`): `os.execve` with the same program and arguments and a copy of the environment that carries the payload. If it succeeds the process is replaced, as the caller asked; if it fails, the error leaves the hook as the exception of the caller's own call. Under a lock both ways are also how an environment that merely lacks the variable is completed instead of refused, for an external program started through `subprocess` as well. `os.posix_spawn` and `os.spawn*` offer neither way.

In locked mode, the audit hook also intercepts filesystem events (`os.remove`, `os.rename`, `open` write-mode, `os.chmod`, `os.truncate`, and `_winapi.CopyFile2`, which `shutil.copy2` writes through on Windows) on the `tethered.pth` path to refuse Python-level tampering with the auto-propagation hook. That path is the hook found in a site directory this interpreter processes; it is empty when there is none, and then there is nothing to guard. An event's path is matched against it as text first, and then by what it names: a path whose last component is `tethered.pth` is the hook when its directory is the hook's directory, compared by identity (`os.stat`) and resolved against the event's `dir_fd` where it has one. So a symlink or junction to the directory, a Windows short name and a descriptor-relative `os.remove` (which is what `shutil.rmtree` issues on POSIX) all name the hook, and a file called `tethered.pth` in another directory does not. An event whose paths do not mention `tethered.` ends at one substring test, which is what the `open` of any other file costs under a lock, and only a path that ends in the file's name is looked up in the file system.

One file can have several names, and the hook often has: uv installs by hard-linking out of its cache, so the `tethered.pth` of an environment it made is also a `tethered.pth` in the cache directory. For the events that change a file in place (a write-mode `open`, `os.chmod`, `os.truncate`, `_winapi.CopyFile2`), a `tethered.pth` in another directory is therefore compared with the hook as a file as well, by one more `os.stat` of each, and refused when it is the same file. Removing that other name is not refused, because it leaves the hook as it is. Giving the file a further name is: `os.link` and `os.symlink` with the file as their source (`_gives_pth_another_name`), and a rename of that other `tethered.pth`. A name that does not end in `tethered.pth` is never looked at afterwards, so it has to be stopped where it is made. Some names need no making and stay open: on Linux `/proc/self/fd/N` names any open file.

What the file guard cannot refuse is met from the other side. A `tethered.pth` counts as the hook only while it holds the one line tethered installs (`_is_bootstrap_file` compares the file with `_PTH_BOOTSTRAP`): `activate()` looks for a file with that content, and warns when a file of that name is there with another, which is what a second distribution that ships a `tethered.pth` leaves behind. The launch classifier reads the file again for every launch of a Python child that would inherit, and a hook that was emptied, removed or replaced since makes that launch an external one; under a locked policy `_handle_subprocess` refuses it outright, as it refuses a launch whose process environment has lost the policy variable. The read is an `open` event like any other; under a locked policy that has failed its integrity check the guardian refuses it too, and the file then counts as not confirmed.

The same hook refuses creating a `tethered.start` file beside `tethered.pth` (a write-mode `open`, the destination of `os.rename`, `os.link`, `os.symlink` or `_winapi.CopyFile2`). The bootstrap is an `import` line in a `.pth` file, and PEP 829 changes how Python treats those: from Python 3.15, `site.py` ignores the `import` lines of `<name>.pth` when a `<name>.start` file sits beside it, whatever that file contains, and Python 3.18 is to ignore such lines altogether in favour of `.start` entry points. tethered ships no `tethered.start`; on Python 3.15–3.17 the `.pth` line runs as before. A `tethered.start` that is there all the same (made with `os.mknod`, which raises no audit event, by another process, or before the policy) switches the hook off, so it is looked for where the hook's content is checked: at `activate()`, which warns, and at each launch of a Python child (`_start_file_beside`).

## C guardian — tamper-resistant locked mode

When `activate(locked=True)` is called, a C extension (`_guardian.c`) records a list of facts that `_core` generates from the namespaces of `tethered._core` and `tethered._policy`:

- what every module-level name is bound to (except `_config`, which the guardian tracks itself, and lower-case scalar runtime state);
- every class attribute of the classes defined there;
- the `__code__` of every Python function found;
- that no builtin those modules use is shadowed in their namespaces;
- that no `__getattribute__` / `__getattr__` has been added to those classes;
- the class and every slot of the active `_Config` and `AllowPolicy`.

The guardian re-checks the list on every socket audit event, every process-launch event, and the filesystem and environment events above when they concern `tethered.pth`, `tethered.start` or the policy variable (those are pre-filtered in C, so ordinary `open` calls stay cheap). Any mismatch (object replaced, method monkey-patched, frozen field mutated via `object.__setattr__`, bytecode swapped) refuses the operation — `EgressBlocked` for socket events, `SubprocessBlocked` for the others — and a tamper alert is written to fd 2. What the snapshot does not cover is listed in [the security model](<https://tethered.shcherbak.ai/security/#locked-mode--what-it-does-and-does-not-do>).

The C guardian also owns the lock state. It alone holds the token, `deactivate()` and `activate()` go through C for token verification, and it rebinds `_core._config` itself as part of its own commit, so no audit event sees a config and a snapshot that belong to different policies and swapping `_config` to an unlocked configuration doesn't help. A policy that fails the integrity check can be neither replaced nor deactivated. The token is not reported to the garbage collector, so `gc.get_referents()` does not hand it out.

`holds_lock()` tells the Python side whether the calling interpreter is the one that took the lock, so that a refused `activate()` / `deactivate()` can be worded for the caller: the holder without its token and an interpreter that joined are told about the token, and an interpreter that is neither the holder nor has a locked config of its own is told that it is outside the lock and that no token will help (`_locked_refusal`).

When the guardian adds its hook it confirms the hook is live with a private probe event (`tethered._guardian.probe`), and activation fails with `RuntimeError` if another audit hook refused it. `locked=True` requires the C extension; the extension is always built during installation, and a C compiler is required for source installs.

### Interpreters

A process can hold several interpreters, and the two hooks see them differently. tethered's Python hook is added with `sys.addaudithook` and belongs to one interpreter: each interpreter has its own `tethered._core`, its own `_config` and its own hook. The guardian's hook is added with `PySys_AddAuditHook` and is called for the events of every interpreter in the process. So there is one lock per process, taken by one interpreter (the lock holder; the source calls it the owner), while everything the guardian verifies is a Python object that belongs to exactly one interpreter. The guardian's state is split along that line.

**Per-interpreter state.** One struct for each interpreter that has imported the extension: its role (none, holder or joined), its snapshot, config and resolver, the exception classes raised on its behalf, the `_in_hook` ContextVar, the code objects allowed to call `resolve()`, and for the holder the token. A struct is found through the interpreter's ID, at the cost of a thread-local read and one atomic load when a thread stays in one interpreter, and it is touched only by threads of that interpreter with its GIL held. Structs are pooled and never freed, so a pointer a thread has cached is always safe to check. The extension uses multi-phase initialisation and, from Python 3.12, declares support for interpreters that have their own GIL. Importing it again in the same interpreter (after removing it from `sys.modules`) gives another module object that works on the same struct.

**Process-wide state.** Threads of different interpreters, which may hold different GILs, read and write this at the same time. What the hook reads on every event is atomic: whether a policy is locked, the holder's interpreter ID, the policy's `log_only` flag copied at commit, and a generation counter. The rest sits behind one spinlock, inside which no Python API is called and nothing is allocated: a C-owned copy of the locked policy's serialized payload, the record of which interpreters have finished starting, and the list of structs. On POSIX the forking thread takes the spinlock before `fork()` and both processes release it afterwards, so a child process is never left with it held by a thread it does not have. One piece of process-wide state is held for much longer than a critical section, and is atomics of its own: the activation claim, by which the interpreters take turns at `activate()` and `deactivate()` (see [The policy variable](<#the-policy-variable>)). The holder is known by its interpreter ID, which CPython never gives out twice, and not by the address of its state, which the next interpreter created would be given: when the holder is destroyed the process stays locked and nobody is the holder.

**The startup window.** The guardian cannot authenticate Python objects: code already running in an interpreter could build stand-ins for `tethered._core` and offer those for pinning. An interpreter may therefore join only while it starts, when nothing has run in it but `site.py`, the `.pth` files and what they import. When `site.py` has processed the `.pth` files it executes `import sitecustomize`, which raises the `import` audit event with that name whether or not the module exists, and all of this happens before the call that creates the interpreter returns. The hook watches for that event in every interpreter, whether or not a policy is locked, and closes the window of the interpreter that raised it. An interpreter that already existed when the hook was first installed, which the first `activate(locked=True)` of the process does, can never join: its startup was not observed. In a process that runs without `site` (`-S`) the event never comes, so nobody joins. The record is C memory and nothing reopens a window.

**Joining.** The `tethered.pth` bootstrap of a new interpreter asks the guardian for the payload of the locked policy (`locked_payload()`). If there is one, `_core._join_locked()` rebuilds the policy and a locked `_Config` from it with the functions `activate()` uses, builds a snapshot of that interpreter's own modules, and calls `join()`. The guardian accepts when a policy is locked, the caller is not the holder, its window is open, and the offered config carries the locked policy's payload byte for byte. In one step it then binds that interpreter's `_core._config`, installs the snapshot, records the current generation and closes the window. No token is involved, and nothing is written to the policy variable. The policy comes from the guardian's copy and not from `_TETHERED_CHILD_POLICY`, so nothing done to the variable changes what is joined; inherited scopes are still read from the variable.

**Generations.** The generation counter moves when the holder commits a policy whose payload differs from the one before, and when the lock is released. A joined interpreter counts as joined only while the generation it recorded is the current one. This is how a replaced policy puts its earlier joiners outside the lock without the guardian touching their state, which belongs to other interpreters, and why re-activating an identical policy keeps them joined.

**Dispatch.** For every audit event the hook goes through these steps and stops at the first that applies:

1. Its own probe event: answer it.
2. An `import` of `sitecustomize`: close the calling interpreter's window.
3. No policy is locked, or the event is not one tethered guards: let it pass.
4. The caller is the holder: verify its snapshot (filesystem and environment events are pre-filtered first). If the snapshot holds, the holder's Python hook decides by the allow list. If the holder no longer has any state, because every instance of the extension in it has been freed, the event is refused.
5. The caller has joined, under the current generation: the same verification against its own snapshot, with its own exception classes, resolver and tamper alert. One thing differs: `os.putenv` / `os.unsetenv` of the policy variable is refused outright.
6. Anyone else: refuse the guarded operations with an exception created in the calling interpreter, reading only the atomics and the event's own arguments, never another interpreter's objects. Under a log-only lock, socket events pass.

### The policy variable

`_TETHERED_CHILD_POLICY` is one variable for the whole process, but each interpreter looks at it through its own `os.environ`, a copy made when that interpreter starts, and an `os.putenv` / `os.unsetenv` audit event reaches only the Python hook of the interpreter that raised it. With several interpreters launching children, a change made by one is invisible to the others, and so is a change made below Python. Under a lock, a launch that inherits the environment therefore also asks the guardian for the variable as the process holds it (`process_policy_env()`, which calls `getenv` or `GetEnvironmentVariableW`) and is refused when that is not the locked policy. An interpreter that has joined is refused changing the variable at all.

Without a lock there is no such check. Interpreters with different unlocked policies each enforce their own on their own events, but the variable holds whatever the last `activate()` in any of them wrote. A Python child launched from any of them inherits that policy, because a child that is given no environment of its own gets the environment of the process, and an interpreter created afterwards runs its bootstrap on it.

**The activation claim.** The interpreters of a process also take turns at changing the variable. `activate()` and `deactivate()` write it before they commit, and a write is not one step: `os.putenv` raises its audit event, every audit hook of the interpreter runs, and only then does the environment change. An interpreter could be anywhere in there while another one publishes its own policy and locks it, and its value would then land in the environment of a process whose lock it does not hold.

So the guardian holds one more piece of process-wide state, the activation claim (`_guardian.activation_claim`, a context manager): whoever is about to write the variable on tethered's behalf takes it first and gives it up when its transition is committed, or has failed and been undone. That is `activate()` and `deactivate()`, locked or not, and the launch hook for the moment in which it puts the payload of an unlocked policy back into a cleaned environment (it writes only if that policy is still the active one). All of them take their interpreter's lifecycle lock first and the claim second.

A thread that finds the claim taken waits for it with its GIL released, since the holder may be a thread of an interpreter that shares that GIL, and looks again once it has it: an interpreter that waited while another one locked a policy gets `TetheredLocked` before it has written anything. When several interpreters race for the first lock of a process, the winner's payload is therefore the only one that is written, and the losers are told at the door. The thread that holds the claim may take it again. The wait has no time limit, like a wait for the lifecycle lock; in the main thread a signal handler that raises ends it. Four cases are handled apart:

- A call made from inside a transition that the same thread is running in another interpreter raises `RuntimeError`, because that transition could not finish while the call waited for it.
- A call that finds the claim taken while Python is shutting down does not wait either and raises `RuntimeError`.
- The claim of an interpreter that is destroyed while it holds it is given up.
- A child created by `fork()` does not inherit a claim held by a thread it does not have.

A claim that was never given up would stop every later transition in the process, so the `with` statement is not trusted alone to give it up: Python 3.14 raises a pending exception after `__enter__` has returned and before the block is entered, and then never calls `__exit__`. Each use therefore notes how often the thread holds the claim beforehand (`held()`) and gives up anything beyond that in a `finally` (`restore()`).

The claim covers the writes tethered makes. A value that application code in another interpreter assigns to `os.environ`, or that is set below Python, is met by the launch check above.

The bootstrap of a new interpreter takes its turn as well, before it decides anything: it waits for a transition that is under way in another interpreter and then goes by the variable as the process holds it and by the lock as it stands, so it never acts on a policy that was published and not committed. Where it cannot wait, in the two cases in which `activate()` raises `RuntimeError` instead of waiting, it goes by the variable as its own `os.environ` showed it at startup. An interpreter created while a locked policy is being replaced therefore joins the new policy. One created while the first lock of a process is being taken still stays outside it: the guardian starts to watch interpreters at that lock, so this one counts as having existed before it.

## Thread safety

`AllowPolicy` is deeply immutable after construction: rules are tuples of strings and integers, networks are stored as integers, and matching reads plain tuples, so no rule field can be reassigned in place. The `_Config` bundle is a frozen `dataclass` swapped by a single reference assignment; a hook reads `_config` once and works from that object. `activate()` and `deactivate()` are serialized from start to finish by a re-entrant lifecycle lock, so the config, the guardian state and the child-policy variable change together; `_state_lock` only guards the one-time installation of the audit hook. Installation is confirmed with the private audit event `tethered.hook_probe`, raised before adding (a hook that is already live is never added twice) and after (unanswered means another audit hook refused tethered's, and `activate()` / `scope()` raise `RuntimeError`). The IP-to-hostname map is an `OrderedDict` guarded by `_ip_map_lock` with LRU eviction. The process-wide count of active scope entries is guarded by its own lock. Reentrancy guard uses `contextvars.ContextVar` (async-safe, faster than `threading.local()`).

All of the above is per interpreter: each interpreter has its own `tethered._core` with its own locks, config and maps. Interpreters can run in parallel from Python 3.12, each under its own GIL. What they share lives in the C guardian, behind atomics and one spinlock (see [Interpreters](<#interpreters>)). Their `activate()` and `deactivate()` calls take turns through the guardian's activation claim (see [The policy variable](<#the-policy-variable>)): when several of them call `activate(locked=True)` at the same moment, one takes the lock and each of the others gets `TetheredLocked` when its turn comes.

A forked child gets these locks back unlocked. `os.fork()` copies the forking thread only, so a lock that another thread held at that moment would stay locked in the child for ever. An `os.register_at_fork` handler resets tethered's locks in the child, in place (`_at_fork_reinit`, as `logging` and `threading` do with theirs), so the lock objects stay the ones a locked policy has pinned; a re-entrant lock that the forking thread itself holds is left to it. The C guardian gives up its activation claim in a forked child for the same reason.

None of this has been validated on free-threaded Python (PEP 703). The C extension does not declare free-threaded support, so importing it on a free-threaded build re-enables the GIL, and no free-threaded wheels are published.

## Performance

The audit hook runs on every relevant socket event, but the cost per event is small: the TCP handshake, TLS and application logic dominate the time a real connection takes. The overheads below apply whether tethered was engaged through `activate()` or `scope()`. Measured on CPython 3.13 against localhost with the benchmark's default settings (3,000 TCP-connect iterations, median of 3; 5,000 iterations for the other events):

| Operation | Overhead added |
| --- | --- |
| `socket.connect` | \~25 µs |
| `socket.getaddrinfo` | \~120 µs (\~2×): tethered resolves the name itself to record which hostname each address belongs to |
| Any audit event that is not a network event | \~150 ns |
| `with tethered.scope(...)` enter + exit | \~1 µs (the cost of the context manager, with or without `activate()`) |

Numbers vary with hardware, operating system and Python version. Reproduce them with [`uv run python benchmarks/overhead.py`](<https://github.com/shcherbak-ai/tethered/blob/main/benchmarks/overhead.py>).
