Architecture — how tethered works¶
tethered uses 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 allowedgethostbyname(the eventgethostbyname_exraises 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.getnameinfois 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(includingconnect_ex, which raises thesocket.connectaudit 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 withNonein 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 (everywritelines(), and whatever part of awrite()onesend()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 asyncioProactorEventLoopis 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).import— while a policy that is not log-only is active, an import of uvloop or pycares is refused withImportError(see 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 lists which of some seventy client-library arrangements do which. What a check costs per event is measured below.
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
socketmodule. 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 behindasyncio.create_subprocess_exec/create_subprocess_shell. An audit trace taken without tethered showssocket.getaddrinfoandsocket.connectforasyncio.open_connectionon 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_IMPORTSmaps the names of theimportaudit events at which a library is refused to that library:uvloopanduvloop.loop,pycaresandpycares._cares. Two names each, becauseimport uvloopraises an event for the package whileimportlib.import_module("uvloop")raises none for it, only for what the package's__init__imports, of whichuvloop.loopis the extension that holds the event loop. When such an event arrives while a policy that is not log-only is active,_refuse_importraisesImportErrorand 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_specof 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 whenuvloopis insys.modules: it logs_UVLOOP_LOADED_MSGat error level and raises it asRuntimeError, 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 ascope()with no policy under it,_warn_if_uvlooplogs one warning whenuvloopis insys.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 asocket.__new__event while a policy or a scope is active, until the warning is out. The module-level flag_uvloop_warning_pendingstarts 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_overlappedcalls that raise nosocket.connect/socket.sendtoevent, 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), andasyncio.SelectorEventLoopis 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*andos.startfile;- the two events raised by launches that bypass
subprocess.Popen, such asmultiprocessingworkers:_winapi.CreateProcesson Windows and, from Python 3.14,_posixsubprocess.fork_execon 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).
A launch that raises none of these events is not handled at all:
multiprocessingon 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_shellon a uvloop event loop, which starts the process in libuv (on the selector loop they go throughsubprocess.Popen).
See the subprocess page 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
_ConfigandAllowPolicy.
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.
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 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:
- Its own probe event: answer it.
- An
importofsitecustomize: close the calling interpreter's window. - No policy is locked, or the event is not one tethered guards: let it pass.
- 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.
- 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.unsetenvof the policy variable is refused outright. - 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). Their activate() and deactivate() calls take turns through the guardian's activation claim (see 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.