Skip to content

Replace the config lock with a refcounted configuration snapshot - #631

Open
Roytak wants to merge 8 commits into
issue627-config-lockfrom
issue627-config-snapshot
Open

Replace the config lock with a refcounted configuration snapshot#631
Roytak wants to merge 8 commits into
issue627-config-lockfrom
issue627-config-snapshot

Conversation

@Roytak

@Roytak Roytak commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Fixes #627. Stacked on #630.

Readers no longer hold a lock while they use the configuration: a generation is refcounted and pinned with nc_server_config_acquire(), and an apply publishes a new generation under a write lock held only for the pointer swap. An in-flight handshake can therefore no longer delay or lose a configuration update. Runtime state that must survive a change (listening sockets, Call Home thread handles) moves out of the configuration into registries, and the options settable only through the API get their own leaf lock.

Note: conflicts mechanically with #629 in nc_server_tls_get_num_certs().

Roytak added 8 commits August 25, 2026 15:24
A listening socket file descriptor is runtime state, not configuration,
but it used to live in struct nc_bind inside struct nc_server_config.
The socket reconcile therefore had to write into the currently published
configuration to stop nc_server_config_free() from closing a descriptor
that the new generation reuses.

Move the descriptors into a registry in server_opts, keyed by the
endpoint name plus the resolved address and port, guarded by a new
binds_lock. struct nc_bind keeps address and port, which are actual
configuration. The reconcile now only opens sockets for binds with no
registry entry and closes entries the new configuration no longer
contains, so nothing is written to either configuration generation and
the PHASE 1 / PHASE 2 / rollback socket bookkeeping is gone. A removed
endpoint stops listening immediately.

nc_server_accept_binds() copies the registry into local poll arrays,
releases binds_lock and only then polls, so the lock is never held
across the poll. The accepted connection is mapped back to an endpoint
by name.

nc_server_config_free() no longer closes sockets or unlinks UNIX socket
files; both moved to the registry removal path, which also lets
nc_server_unix_get_socket_path() become static.

The client side Call Home binds keep their own descriptors, which moved
to the parallel ch_binds_aux array.
config_lock doubled as the lock for the server_opts fields that are set
through the API and are not part of struct nc_server_config. Their
setters took it in WRITE mode, while their readers were only protected
because the transport handshake happened to hold it in READ mode.

Give them a lock of their own, opts_lock, so that the handshake can stop
holding config_lock without leaving those readers unsynchronised. The
fields are ch_dispatch_data, interactive_auth_clb and its data,
pam_config_name, authkey_path_fmt, ssh_protocol_string, user_verify_clb,
unix_socket_dir and unix_paths.

opts_lock is a leaf lock, only config_lock may be held while acquiring
it. It is also held on the authentication path, so it is never held
across anything slow: the SSH protocol string and the PAM service name
are copied out and pam_start() runs unlocked, and the interactive
authentication and TLS verify callbacks are read together with their
data pointer, then called after unlocking.

Also free unix_socket_dir in nc_server_destroy(), it was leaked.
server_opts.config was held in READ mode across complete transport
handshakes - key exchange plus authentication, bounded only by
auth-timeout and unbounded when it is configured 0 - so a stalled
handshake blocked every configuration update.

It becomes a pointer to a refcounted, immutable struct nc_server_config.
A handshake path takes a reference under the READ lock, releases the
lock and works from the pinned pointer; an applier builds the next
generation off-line, swaps the pointer under the WRITE lock and drops
the old generation's reference, which is freed by its last reader. The
lock is now held for a pointer read plus a refcount change, never across
network I/O. The refcount is decremented with acq_rel so the reads of a
generation are ordered before the free() of whoever drops the last
reference; the increment stays relaxed, the READ lock orders it.

The transport options are resolved lazily during the handshake, so the
resolvers and the helpers between them and the handshake entry points
take an explicit configuration parameter. The pinned generation lives in
exactly one place, session->opts.server.config, a borrowed pointer set
only by nc_accept() and nc_connect_ch_endpt() and cleared by them once
the handshake is over.

struct nc_ch_client.thread is mutable runtime state that used to be
copied across generations, so the Call Home thread handles move to a
registry in server_opts, superseding the redispatch fix. Stopping a
client looks the thread up there, unlinks it, and only then joins it, so
the whole unlock/join/relock deadlock avoidance and its leak path are
gone. A thread whose client is not in the published generation now waits
for it instead of exiting, since the socket and thread reconciles run
before the swap.

server_opts gains an atomic mirror of idle_timeout, stored at swap time.
That fixes the two unlocked reads in nc_send_hello_io() and
nc_server_recv_hello_io() and removes the last configuration access from
nc_ps_poll_session_io(), so nc_ps_poll_sess() no longer takes the
configuration lock on every poll iteration of every session.

Also fixes nc_server_notif_cert_exp_dates_get(), which aliased the
keystore and the truststore in its declaration list, before taking the
lock - harmless with an embedded struct, a use-after-free with a
pointer.

test_config_update_during_auth now asserts the elapsed time, which is
what the change is actually about, and four new tests cover the
immediate close of a removed endpoint, the API setters during a
handshake, a Call Home thread surviving a swap, and concurrent applies
racing an accept.
The thread was appended to the registry only after pthread_create(),
so a concurrent config apply could miss it and dispatch a second
thread for the same client. Register and create the thread atomically
under ch_threads_lock and reject a client that already has one, which
restores the exclusivity the config write lock used to provide.

A thread that terminated for any other reason than being told to left
its registry entry behind, so every later apply considered the client
running and never dispatched it again. Define the registry entry as
the ownership token of the thread argument - whoever removes it joins
the thread and frees the argument - and have an abnormally exiting
thread unregister, detach and free itself.

The thread also gave up when the configuration could not be acquired,
which is a transient lock timeout; retry a few times instead. And do
not log through the session in the thread cleanup, at that point it
belongs to the user and may already have been freed.

Finally, keep the rollback of the dispatch reconcile from walking a
NULL entry: LY_ARRAY_NEW_GOTO() counts the new element in before the
strdup() of the client name, so an allocation failure left a NULL in
the array that the rollback then passed to dispatch_stop().
A connection accepted on a socket whose endpoint the pinned config
does not have was accepted and then dropped. That also happened for a
plain endpoint rename, which changes nothing about the socket. Resolve
the endpoint of every bind while the poll set is built and skip the
ones the pinned configuration does not know, so the pending
connections stay in the listen backlog for a call with a newer
configuration instead of being reset.

The reuse pass also wrote the new endpoint name into the live registry
entry right away, so a later failure of the apply left the registry
carrying a name the published generation does not have - with the
change above that socket would then never be polled again. Stage the
rename in the bind description and store it only once nothing can
fail, which is what the function documented all along.

Since the registry lock is not held while polling, an apply may close
a socket meanwhile. Accepting on a closed descriptor is a normal
outcome here, so skip it instead of failing the whole nc_accept().

Also move struct nc_bind_desc to the private header and document how
it differs from a configured bind and a registry entry.
A failed Call Home dispatch reconcile jumped to cleanup instead of
rollback. The listening sockets were already reconciled against the
generation being applied at that point, so a deleted endpoint stayed
closed while the published configuration still advertised it and newly
opened sockets accepted connections with no endpoint to serve them.

nc_server_init() left the initial configuration generation allocated
when a later init step failed, which both leaked it and made the
server look initialized. Release it and clear the initialized flag.

The options lock must not be held across anything slow, but the UNIX
socket path resolution ran two realpath() calls under it. Copy the
base directory and the hidden path mapping out and resolve the path
unlocked.

Also report a failed strdup() of the system public keys path format as
an allocation error instead of "path format not set", and reject a
NULL directory in the UNIX socket dir setter and getter.
A published configuration generation is immutable, but the accept and
Call Home paths cast the constness away to pass its keepalives to
nc_sock_connect() and to hand an endpoint out of nc_server_endpt_get().
All the callees only read, so make the types say so: nc_sock_connect()
takes const keepalives (nc_sock_configure_ka() already did) and
nc_server_endpt_get() returns a const endpoint. No cast is then left
to let a future caller mutate a generation other threads are reading.
Explain why the published configuration pointer needs the rwlock and
cannot be a plain atomic pointer: acquiring a generation is a pointer
load followed by a refcount increment on what was loaded, and the read
lock is what keeps an applier from publishing and freeing the old
generation in between the two. Doing that lock-free would need hazard
pointers, RCU or a double-width CAS, none of which the compatibility
layer provides, and there is nothing to gain either.

Also document why the idle timeout is mirrored into an atomic next to
the generation, and that a missing generation simply means no module
is ignored in the <hello>.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant