11937 Commits
Author SHA1 Message Date
zandGitHub 1b374b7329 Delete upstream workflows
This fork does not ship upstream's releases, and its hosted-runner jobs
never start here.

Claude-Session: https://claude.ai/code/session_01HuMDfgFssPUptYUzgZ6Cp8
2026-09-24 10:04:09 -07:00
zeekayandHanzo Dev 02d6346192 Pin zap-proto/zip to a published release
The ZAP wire was pinned to a pseudo-version of a commit that exists only
in one checkout, so a clean fetch could not resolve it. v1.36.49 carries
the same Layouts generator and SCM_RIGHTS file passing.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-22 23:12:21 -07:00
zeekayandHanzo Dev 1fda956476 Set a call's files after its result, and check the magic
Reading a ZAP result writes every field its layout states, the file payload
included, so files attached before decoding were overwritten with none. A
frame whose header is not ZAP is refused at the magic rather than read as a
length.


Co-authored-by: Hanzo Dev <[email protected]>
2026-09-22 23:10:33 -07:00
zeekayandHanzo Dev f42ae7beb2 Carry urpc payloads as ZAP, not JSON
encoding/json walked every payload on every call. ZAP reads a field at a
constant offset out of the buffer that arrived, and //tools/zap has
already stated those offsets for every payload, so the transport now asks
the payload for its own bytes and the parse step is gone.

The envelope collapses with it. clientCall and serverCall existed only
because JSON needed one type to hold an any and another to hold a
RawMessage; bytes are bytes, so one Call serves both ends. Framing comes
from the ZAP header, which declares its own length.

A payload that is nothing -- the *struct{} a method takes when it has no
argument or no result -- carries nothing. Anything else without a layout
fails the call naming the type, so a payload that would have arrived
silently empty does not arrive at all.

The methods that took a bare string or returned a bare int now take and
return named payloads, because a scalar has no layout to state and the
rest of the API was already shaped this way.


Co-authored-by: Hanzo Dev <[email protected]>
2026-09-22 23:10:33 -07:00
zeekayandHanzo Dev e1280967b6 Give the last two payloads a wire to state
A prometheus Data held its labels in a map, and control.ExecArgs held the
live kernel objects an exec runs against -- a mount namespace, an fd
table, a limit set. Neither has offsets, so neither payload could state
a layout.

Labels become a list, which also retires the O(n^2) walk that existed to
read a map in sorted order. The kernel objects move to control.Env, which
is what the sentry supplies and what a call arriving on the wire cannot:
a namespace is a reference, not a value.

Every urpc payload now states its own wire.


Co-authored-by: Hanzo Dev <[email protected]>
2026-09-22 23:10:33 -07:00
zeekayandHanzo Dev 3da05638df State the ZAP wire of every urpc payload
urpc encodes with encoding/json, which walks the payload on every call: a
realistic control.ExecArgs costs 7084ns and 10 allocations to cross. ZAP
reads a field at a constant offset out of the buffer that arrived, so the
same payload is 740ns and one allocation -- but only if the offsets are
stated, and a stated offset that is wrong is silent corruption rather than
a compile error. So state them from the types themselves.

//tools/zap reflects over the payloads at build time and writes the wire
as Go source with the offsets as constants. It lives inside Bazel because
pkg/sentry/control cannot be imported from outside it: the *_go_proto
packages it depends on have no source in the tree.

Six payloads had no layout to state. A map, an interface and a type from
another module have no offsets, so each is answered in the type:

  SaveOpts.Metadata and StartContainerArgs.InitialCgroups/Limits cross as
  lists of pairs; boot.Route.Destination restates net.IPNet as Subnet;
  OpenMountArgs restates the OCI mount fields the call actually uses;
  StartArgs.Spec and SinkConfig.Config carry their documents as the bytes
  they arrived as; MetricsRegistrationResponse carries the protocol
  buffer the same way; and seccheck's session options become a type
  instead of a map of any.

ExecArgs and MetricsExportData still have none: ExecArgs holds live
kernel objects behind an interface, and MetricsExportData reaches a
prometheus snapshot whose labels are a map.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-22 23:10:33 -07:00
zeekayandHanzo Dev c814594092 The version gate asked the runtime for a string only its guest can say
`runsc --version` prints two lines, and -visor is in neither:

    runsc version <STABLE_VERSION>
    spec: <OCI spec version>

STABLE_VERSION is `git describe --always --tags` — tools/workspace_status.sh,
wired by .bazelrc, substituted through runsc/BUILD's x_defs. The spec line is
opencontainers/runtime-spec's. The -visor suffix lives in
pkg/sentry/kernel/version/version.go: it is the release the sandbox advertises
through uname(2), which only a process inside a running sandbox can read. Its
own comment says as much — the two builds "are told apart by asking the guest".

So the gate fails for every build that can exist. visor-20260919.0 is queued and
has never run; it would have compiled all of gVisor and died on the last line
before creating the release, reading as a broken fork rather than a bad grep.

Assert what the binary does report, and what a release needs anyway: that the
bytes carry the tag being published. At a tag, describe returns the tag name, so
the line is exactly `runsc version $TAG`. Everything that should be rejected
still is — VERSION_MISSING with no stamp, 0.0.0 or a bare commit with no tag in
reach, -dirty from a modified tree, and upstream, whose tags are release-*.

The bazelisk asset now names the pool it runs on. bazelisk publishes <os>-<arch>
with exactly the labels our runners carry, so the arm64 row cannot come back
downloading an amd64 bazel.

Co-authored-by: Hanzo Dev <[email protected]>
visor-20260919.1
2026-09-19 10:19:16 -07:00
zeekayandHanzo Dev afb70653f9 The publish lane moves to where our CI looks
The lane that builds visor and attaches verified binaries to its release has
existed since e3027ddbc, at .github/workflows/publish.yml. Tag visor-20260911.0
was pushed at a commit that already carried it, and no release was ever created:
this estate runs CI on Hanzo Git Actions from .hanzo/workflows, and has no
runners on GitHub Actions at all. A workflow in the wrong directory does not
fail, it waits — which reads exactly like a lane nobody triggered, and is why
every node still runs a visor built by hand from a working tree.

Also takes the canonical runner label. RUNNERS.md: "A runs-on is one of exactly
these" — linux-amd64. hanzo-linux-amd64 still routes, as an alias kept so
existing workflows keep running, and none of it should be written into a new one.

Nothing else changes: same bazelisk bootstrap, same single bazel invocation for
the runtime and the shim it execs, same gate refusing a binary that does not
report -visor, same sha512 beside every artifact.


Co-authored-by: Hanzo Dev <[email protected]>
visor-20260919.0
2026-09-19 09:56:31 -07:00
zeekayandHanzo Dev 0cc5982824 fix(execd): keep the host socket from children, and git where dir says
CI / build (push) Waiting to run
Go / generate (push) Waiting to run
FD 3 arrives without FD_CLOEXEC, which is how it crosses the exec into
execd, and execd never set it again, so every command, pty and git it
ran held the host's channel: it could read requests meant for the
daemon and answer in its name. newDaemon now sets FD_CLOEXEC. The test
harness made its pair with SOCK_CLOEXEC, which hid this; the daemon's
end now arrives inheritable, as under runsc. The workspace descriptor
dup'd for a top-level name closes on exec too.

The git ceiling governs only discovery, so -C, --git-dir, --work-tree
and a GIT_DIR or GIT_CONFIG_GLOBAL in env reached a repository or a
config file outside the workspace. Before its subcommand git now takes
-c and options that move nothing, and refuses the rest, since an
option execd does not know could take a value that hides the next.
GIT_* in env is refused and the global config is /dev/null, so HOME
picks no file. The ceiling test now covers each of these.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:05:18 -07:00
zeekayandHanzo Dev 4855f06fd1 fix(execd): confine git, make patch all-or-nothing, answer only what happened
CI / build (push) Failing after 33s
Go / generate (push) Failing after 46s
The git ceiling named the workspace, which git only applies to a proper
ancestor of the directory it starts from, so a request with no dir found,
read and wrote a repository above the workspace. The ceiling is now the
workspace's parent, which refuses from the root and from any directory
under it.

patch staged by action id, so two changes naming one file collided and
the second failed with the first's bytes already in place, and a delete
that could not happen left earlier renames behind. Every change now
stages under a random name, the file that was there is kept on a second
link until every move has landed, and a move that fails is put back. A
diff that names one file twice, gives two names for one file, would
delete a directory, or renames is refused.

A zero-length packet is a packet on SOCK_SEQPACKET, and reading it as end
of file ended the daemon for good. POLLRDHUP tells the two apart.

Also: a listing fills a page to what a frame holds instead of promising
4096 entries no frame can carry; write and patch resolve the parent with
openat2 on the path as written, so one string cannot mean two places; a
refused request is not recorded, so its id is free, while an action that
ran is recorded whatever came of it; a staged name is random, so a
workspace file is never unlinked to make room; children start from a
declared environment instead of the daemon's; write reports the mode on
disk and keeps the mode a file had; mode means permission bits in stat as
well as read and list; a read past the end is refused; an oversize
refusal names the op it refused; the ledger is bounded by bytes as well
as count; the diff path is the reading both header lines agree on; the
build constraint sits above the package doc; and BUILD embeds a library,
which is how the repo builds a main package with tests.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-18 17:49:38 -07:00
zeekayandHanzo Dev 6208c7df95 Add execd, the execution daemon inside the sandbox
CI / build (push) Canceled after 0s
Go / generate (push) Canceled after 0s
A coding agent is two programs: one reasons, one runs cargo test. execd is the
second half, and it knows nothing about the first — no model, no session, no
plan, no credential. Actions arrive as ZAP frames on FD 3, one end of a
SOCK_SEQPACKET pair the host connected before exec, so there is no listen, no
connect and no path in a filesystem.

Every request carries an action id and every reply names it. A repeated id for
a completed action is answered from the record instead of run again, so losing
an answer to git commit does not commit twice; the table is bounded by count
and an id whose result was evicted is refused rather than replayed. Paths
resolve with openat2 RESOLVE_BENEATH, so the kernel refuses "..", an absolute
path and a symlink that leaves the workspace. write and patch stage a
temporary file and rename over the target, and patch verifies every file in a
diff before renaming any of them.

Nothing fsyncs: the host serves the workspace and owns durability, and an
fsync inside the cell can cost seconds for nothing. Tests need no visor — a
socketpair in-process is the same code path as FD 3 under runsc.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-18 13:48:08 -07:00
zeekayandHanzo Dev a422ca68e5 Install bazel without root
CI / build (push) Failing after 1m5s
Go / generate (push) Failing after 4m37s
Runners execute inside the visor sandbox, where setuid is not honoured: sudo
fails with "nosuid" before the step does anything. Bazel goes into a directory
the job already owns, named on GITHUB_PATH.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 23:23:54 -07:00
zeekayandHanzo Dev b3371ed075 Build with bazel directly, and only where a runner can start
make copy wraps bazel in a container and pushes to an upstream registry, so it
needs a docker socket these runners do not have: it failed on
dial unix /var/run/docker.sock before building anything.

aarch64 is dropped for now rather than left to queue. git-runner:5 is a single
manifest, so an arm64 node cannot start it — the pods StartError and the job
then waits forever, which reads as nothing happening. It comes back in the same
commit that publishes a multi-arch runner image.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 23:21:11 -07:00
zeekayandHanzo Dev e3027ddbc5 Build visor on our own runners and publish verified binaries
publish / binaries (aarch64, hanzo-linux-arm64) (push) Waiting to run
publish / binaries (x86_64, hanzo-linux-amd64) (push) Failing after 22s
A node installed a hand-built runtime nobody could reproduce or attribute to a
commit. Both binaries now come from one bazel invocation, ship beside their
sha512, and refuse to publish unless the runtime reports -visor.

This path, not .hanzo/workflows: the forge has no online runner, and an
unmatched runs-on queues silently instead of failing.

Co-authored-by: Hanzo Dev <[email protected]>
visor-20260911.0
2026-09-10 23:11:57 -07:00
zeekay 2961b80df6 One publish lane, where the runners are
The .hanzo copy could never run: the forge has no online runner, and an
unmatched runs-on queues silently rather than failing.
2026-09-10 23:11:19 -07:00
zeekay ca9e507131 Publish with the token the runner already has
The lane depended on a GitHub PAT nobody had set on the forge, so it could
never have run. It now publishes to the forge release with the token every
runner is given, which is one less credential that can rot.
2026-09-10 23:08:14 -07:00
zeekay 844e091701 Publish verified visor binaries per architecture
Each node installed a hand-built runtime nobody could reproduce or attribute
to a commit. Both binaries now come from one bazel invocation, ship beside
their sha512, and refuse to publish unless the runtime reports -visor.
2026-09-10 19:37:54 -07:00
zeekayandHanzo Dev 04e0b3d153 Delete the packetimpact harness
It was the only thing in the tree importing google.golang.org/grpc, and it
reaches no runsc build path — 71 files of network conformance tests against a
posix_server that is not part of the sandbox. The control plane is urpc, and
now nothing here speaks gRPC at all.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:27:54 -07:00
zeekayandHanzo Dev f4a6d50921 Report -visor so the guest names its own build
A sandbox that answers "-gvisor" is upstream's binary. Telling the two apart
by config is a guess — the RuntimeClass, the containerd entry and the shim
options can all name our binary while the pod runs something else, which is
exactly what happened when an inline BinaryName was accepted by containerd and
never read by the shim. The guest's own uname is the one answer that cannot be
wrong about which kernel is running it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-09 01:48:04 -07:00
gVisor bot ea30172b22 Merge pull request #14448 from tianyuzhou95:albert/fdbased-foreign-frame
PiperOrigin-RevId: 978250424
2026-09-08 19:51:53 -07:00
gVisor bot 5128bf5f56 Merge pull request #14519 from dany74q:veth-peer-short-ifinfomsg
PiperOrigin-RevId: 978249831
2026-09-08 19:46:17 -07:00
gVisor bot 8906ea32aa Merge pull request #14628 from 014-code:docs/seccomp-partial-support
PiperOrigin-RevId: 978243088
2026-09-08 19:29:00 -07:00
gVisor bot 3668534c55 Merge pull request #14646 from konstantin-s-bogom:shim-stopped-latch-fix
PiperOrigin-RevId: 978234612
2026-09-08 19:07:30 -07:00
gVisor bot b812887326 Merge pull request #14455 from aishukamal:tpu-checkpoint-tool
PiperOrigin-RevId: 978231673
2026-09-08 19:00:29 -07:00
Etienne PerotandgVisor bot bc480ed7f3 Use //:release BUILD target over //runsc.
PiperOrigin-RevId: 978229308
2026-09-08 18:53:18 -07:00
Aishu KamalandHanzo Dev 4cbdcc589b tools/tpucheckpoint: standalone host-side client for tpu_control protocol
Add a CLI tool and Go library that speak the libtpu checkpoint/restore
control protocol (pkg/sentry/control/tpu_control.proto) against
unsandboxed processes.

gVisor coordinates libtpu C/R during sandbox save/restore
(pkg/sentry/control/tpu_control.go), but there is no way to exercise
or debug that protocol path without a full runsc save cycle. This tool
fills the gap: it discovers libtpu control threads by scanning
/proc/<pid>/task/*/comm, opens the encoded pipe FDs via /proc/<pid>/fd/,
and sends ControlRequest messages.

Accepts a comma-separated PID list and runs actions concurrently
across PIDs with per-PID results, for multi-process jobs that need all
processes checkpointed together.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 16:19:05 -07:00
Konstantin Bogomolov f152fe247e Do not transition shim to stopped state from a state query
createdState.State and runningState.State transitioned the shim's init
process to stoppedState whenever a single `runsc state` call answered
"stopped". That transition bypasses SetExited: waitBlock is never
closed, no TaskExit event is published, and handleStoppedKill silently
swallows all subsequent kills, so containerd waits forever and the
container can never be torn down.

This behaviour comes from commit 0b39816a3, which tried to fix the
opposite problem of containerd never seeing that a container died due
to query errors. However, this part was never essential, because the
real fixes had to do with state queries on dead or deleted containers
erroring out. In light of https://github.com/google/gvisor/pull/14645,
there is now a concrete example of a buggy runsc not being an
authoritative and irreversible source for the stopped state, so kills
can be accepted but dropped, and the pod was stuck terminating forever.
2026-09-08 16:12:35 -07:00
gVisor bot 683910129f Support NVIDIA IMEX-620.30 in gVisor.
Add support for NVIDIA driver version 620.30.00 in nvproxy, aliasing ABI
620.06.00.

PiperOrigin-RevId: 978164660
2026-09-08 16:09:34 -07:00
gVisor bot 53136173e7 Merge pull request #14232 from tianyuzhou95:albert/readv-seccomp
PiperOrigin-RevId: 978132320
2026-09-08 15:01:51 -07:00
Shailend ChandandgVisor bot 46e35291a0 Support writing to cgroup v2 control files without an open file descriptor
Fix a panic when writing to cgroup v2 control files from outside the sandbox
(e.g., via Cgroups.WriteControlFiles / WriteControl), where writes occur
without an open in-sandbox VFS file descriptor (fd == nil). cgroup.procs now
safely falls back to context credentials with unconfined namespace migration,
and checkNSDelegateWrite skips namespace delegation checks. Add an integration
test for runsc cgroup v2 control file writes.

Also:
- Export cgroup2fs.InitHierarchy to initialize sandbox-wide cgroup2 state
  and root controller enablement.
- Plumb CgroupNamespace through sentry lifecycle and task context to derive
  initial container cgroup2 placements.
PiperOrigin-RevId: 978087817
2026-09-08 13:36:37 -07:00
Alexander CuevaandgVisor bot e11d0a7b1c Add block, goroutine, mutex, and trace subcommands to runsc profile
Implement the remaining runtime profiling subcommands for `runsc profile`:
- `goroutine`: Dumps the current stack traces of all running goroutines
in Sentry.
- `block`: Collects a goroutine blocking contention profile.
- `mutex`: Collects a mutex contention profile.
- `trace`: Collects an execution trace.

PiperOrigin-RevId: 978048587
2026-09-08 12:20:09 -07:00
Konstantin BogomolovandgVisor bot 34c27103db Fix race where unregistered sysmsg threads starts processing contexts.
If we don't register the newly created sysmsg thread _before_ we start executing
contexts on it, we are liable to race with any interrupts that may arrive to
contexts it starts executing, upon which point we won't find the newly created
thread.

Reported-by: [email protected]
PiperOrigin-RevId: 977899577
2026-09-08 07:30:28 -07:00
DonsonandHanzo Dev 80aad0dee6 docs(seccomp): correct syscall compatibility status
Mark seccomp as partially supported on amd64 and arm64 and document the accepted operation and flags.

Fixes #14627

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 11:01:05 +08:00
Konstantin BogomolovandgVisor bot ff3e78583c Make unexpected subprocess kills thread-safe.
To kill the subprocess, we kill the syscall thread, which in turn kills all
stub threads by virtue of being their parent. This means we have two critical
sections that need to be protected:
1. Creating new sysmsg threads -- the function expects that it's new baby
   will stay alive at least while it's being created;
2. Using the syscall thread itself.

We also kick out any currently sleeping task goroutines, and put guard-checks
on wait loops and possible entrances to subprocess functionality so that tasks
don't come back to a dead subprocess.

Additionally, killed subprocesses were not properly decref'ed before, this CL
also fixes that.

PiperOrigin-RevId: 977651945
2026-09-07 19:56:19 -07:00
Etienne PerotandgVisor bot 7cc5d43e6f Update gVisor licensing metadata for LLVM and gRPC.
PiperOrigin-RevId: 977633468
2026-09-07 18:33:56 -07:00
gVisor bot e1a08f5532 Merge pull request #14614 from milantracy:cncf-governance
PiperOrigin-RevId: 977287469
2026-09-06 23:09:51 -07:00
gVisor bot 634d9d18c5 Merge pull request #14583 from milantracy:header
PiperOrigin-RevId: 977052562
2026-09-06 04:52:07 -07:00
Jing Chen 86052cdfbf Update gVisor GOVERNANCE.md
Reference: https://github.com/cncf/project-template/blob/main/GOVERNANCE-maintainer.md
2026-09-06 09:41:16 +00:00
gVisor bot 7fa7fa8a48 Merge pull request #14442 from EtiennePerot:licensecheck
PiperOrigin-RevId: 976621383
2026-09-04 20:39:49 -07:00
gVisor bot 801e21b1df Merge pull request #14221 from ayushr2:rename
PiperOrigin-RevId: 976605832
2026-09-04 19:52:38 -07:00
Shailend ChandandgVisor bot 2842fb3414 cgroup2fs: DecRef inodes returned by Lookup for consistency
OrderedChildren.Lookup returns the inode with a ref that the caller is
expected to release (normally by handing it to a dentry via Init). Five
cgroup2fs call sites that look an inode up for a transient purpose
(permission checks, control-file read/write, interface-file removal)
never released that ref.

This is NOT a bug fix: every inode these sites can return
(cgroupInterfaceFile and eventFile, both via kernfs.DynamicBytesFile,
and cgroup directories themselves) embeds kernfs.InodeNoopRefCount, so
IncRef/DecRef are no-ops and nothing leaks today. The change only makes
the ref discipline explicit and correct, so these sites stay safe if a
lookup ever returns a genuinely refcounted inode.

PiperOrigin-RevId: 976563179
2026-09-04 17:16:35 -07:00
Etienne Perot 45d4f21950 Add license auditing tooling to the repository.
This adds a tool to pull in and analyze the licenses of all
dependencies in the project, and ensures they are in a set of allowed
licenses.

This ensures that if we add new deps down the line, they must be part
of the set of allowed licenses.
2026-09-04 16:14:52 -07:00
Xin ZhongandgVisor bot 5fc72b21ce seccheck: Add redis benchmarking docs and configs
PiperOrigin-RevId: 976537814
2026-09-04 16:11:23 -07:00
Shailend ChandandgVisor bot 32a2dc9364 cgroup2fs: Turn the cpuset mu into a lockdep mutex
PiperOrigin-RevId: 976537118
2026-09-04 16:05:04 -07:00
gVisor bot e12523b24f Merge pull request #14529 from milantracy:githubrelease
PiperOrigin-RevId: 976531624
2026-09-04 15:53:51 -07:00
Ryan El KochtaandgVisor bot 330ce7b6f7 Internal change.
PiperOrigin-RevId: 976504381
2026-09-04 14:49:09 -07:00
Shailend ChandandgVisor bot d894fdaee6 Return EOF, not EBADF, when receiving on a severed host UDS
SCMSender.beforeSave disconnects the endpoint on save: it shuts down and
closes the host fd, leaving fd = -1. Send already handles this state via
its IsSendClosed check (beforeSave sets sendShutdown), so writes fail
with EPIPE, but Recv had no guard and passed -1 to recvmsg(2), leaking
EBADF to applications for an fd that is still valid in their fd table.
Return ErrClosedForReceive instead, which the socket layer already
surfaces as read() == 0, matching Linux EOF semantics for a socket whose
read side is shut down and making a severed host endpoint behave like
any socket whose peer has gone away.

The guard keys on fd < 0 rather than IsRecvClosed: after a local SHUT_RD
on a live fd, the host socket may still hold queued data that must be
drained before EOF, and host recvmsg(2) handles that correctly.

PiperOrigin-RevId: 976063849
2026-09-03 20:21:53 -07:00
Jing Chen 5370dfb0cd check the license header on newly added files.
Fix #14564
2026-09-04 01:31:11 +00:00
Ryan El KochtaandgVisor bot 152c2f12d6 Internal change
PiperOrigin-RevId: 976028759
2026-09-03 18:24:53 -07:00
Shailend ChandandgVisor bot e6859917fb Report hangup in HostReadiness on closed FD and make beforeSave idempotent.
- In HostSender.HostReadiness, return hangup and readable/writeable events
  ((ReadableEvents | WriteableEvents | EventHUp | EventRdHUp) & mask) when
  the host FD is closed (c.fd < 0), ensuring pollers (epoll/poll) are notified
  of EOF and disconnection instead of masking the closed state.
- In SCMSender.beforeSave, return early if e.fd < 0 to make beforeSave
  idempotent and prevent nil-pointer dereferences in fdnotifier.RemoveFD(-1)
  on already-closed endpoints.

PiperOrigin-RevId: 975898094
2026-09-03 13:37:12 -07:00