Skip to content

fix(daemon): keep a displaced daemon from deleting its replacement's socket - #300

Merged
georgeh0 merged 1 commit into
mainfrom
fix/daemon-socket-unlink-guard
Oct 6, 2026
Merged

georgeh0 merged 1 commit into
mainfrom
fix/daemon-socket-unlink-guard

Conversation

@georgeh0

@georgeh0 georgeh0 commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Completes the fix for #288 (follow-up to #291). Rebased on main (#299, #302, #303).

Why #291 had no effect

multiprocessing.connection.SocketListener registers util.Finalize(self, os.unlink, args=(address,), exitpriority=0) at bind time for AF_UNIX, and close() runs it unconditionally. Shutdown step 1 calls listener.close(), so a displaced daemon deleted the replacement's socket before the inode check in step 4 ran. Step 4 never found a file to guard. The test added in #291 compared touch()ed files and never ran daemon code, so it passed either way. #299 later removed that test too.

Fix

The daemon owns its listening socket on POSIX (_UnixListener in daemon.py). Windows keeps multiprocessing.connection.Listener (named pipes) and is unchanged. _UnixListener binds the path and records the socket file's identity. On close(), it unlinks the path only if it still has that identity, and it does so before closing the socket, because an open socket keeps its inode in use. The class sits under if sys.platform != "win32":, which keeps mypy happy on Windows, where typeshed has no socket.AF_UNIX.

I chose this over disarming the stdlib finalizer (listener._listener._unlink.cancel()) for three reasons:

  • Private attributes. Disarming relies on two private attributes, neither in typeshed, and both can change between Python versions. A defensive getattr would silently bring the bug back if they were renamed.
  • A second bug on Linux. On Linux, close() does not wake a thread blocked in accept(); only shutdown() does. macOS wakes it on close(). I checked both: macOS locally, Linux in python:3.13-slim. On main, a displaced daemon's wake-up connection goes to the replacement, so its own accept thread stays blocked. Shutdown then waits out the 5 s accept_thread.join(timeout=5) and the thread leaks. _UnixListener.close() calls shutdown() first. Doing that through the stdlib object would need a third private attribute (_socket).
  • Typed, stable API only. It uses socket plus Connection(fd), the same wrapping SocketListener.accept() does. It matches the stdlib's blocking mode and backlog=1, so nothing else changes.

Identity is (st_dev, st_ino, st_ctime_ns), not just (dev, ino). On ext4/overlayfs, a freed inode number goes straight to the next file. In a python:3.13-slim container, a socket file recreated at the same path after the old socket was closed got the same inode number 20 out of 20 times. While the old socket was open, it never did. So once the original socket is gone, (dev, ino) alone can match a replacement's file. The ctime only collides if both files are created within one timestamp tick.

The guarded unlink now happens in listener.close() (shutdown step 1). That is when the stdlib finalizer actually removed the file before. Step 4 now only removes the PID file.

Single implementation. socket_identity() and unlink_socket_if_owned() in _daemon_paths.py are used at bind time, at daemon shutdown, and by the client.

Client _cleanup_stale_files also unlinked the socket path unconditionally (same class of bug). stop_daemon() now records the socket's identity before stopping and only removes that file. A stale socket left by a dead daemon is still removed.

Not fixed: two microsecond race windows (documented in comments)

  • Bind-to-stat gap. A replacement binding between bind() and the stat would be recorded as ours. bind() can't report the file's identity.
  • Stat-to-unlink gap. POSIX has no "unlink if inode matches", so a replacement binding between the stat and the unlink is still lost.

I considered two ways to close them:

  1. Bind to a temp name, then os.replace it into place. This closes only the startup gap. The temp path must fit the sun_path limit (104 or 108 bytes) next to a path that was already length-checked, and a crash between bind and rename leaves stray socket files.
  2. An flock-ed lock file shared by every daemon's and the client's bind/unlink. This closes both gaps, but adds a persistent lock-file artifact and a new coordination protocol.

Both windows need two daemons starting or stopping at the same instant, and the result heals itself: the client starts a new daemon when the socket is missing. Option 2 is the one to take if we want them closed.

Note: the shutdown wake-up connection

Step 1 still sends the Client(sock_path) wake-up on every platform, so the Windows path gets exercised everywhere. If a replacement owns the path, the wake-up reaches the replacement instead: one extra connection that counts as activity there. This is harmless and noted in the comment. On POSIX the wake-up is no longer needed for correctness, because close() wakes accept() itself.

Tests

  • test_displaced_daemon_keeps_replacement_socket (tests/test_daemon_idle.py).
    • It starts a real in-process daemon and replaces its socket path with a new bound AF_UNIX socket. After the daemon idle-exits, it asserts the path still exists with the replacement's identity and that the PID file was removed.
    • It fails on main on both macOS and Linux. On Linux, main also takes about 6 s because of the accept-thread join.
  • test_stop_daemon_removes_only_the_stopped_daemons_socket[False|True] (tests/test_client.py).
    • The stopped daemon's socket is closed before a replacement binds, so the inode number can be reused.
    • True fails without the client change. On Linux it also fails with a (dev, ino)-only identity.
    • False checks that a stale socket is still removed.
  • The existing _assert_cleaned_up checks still pass.

Verification

  • uv run pytest tests/: 350 passed after the rebase onto main.
  • Linux (python:3.13-slim): the daemon idle-exit tests and client stop tests pass, and the new tests fail against the old code.
  • uv run mypy --platform {win32,darwin,linux} src/ tests/: clean.
  • uv run ruff check . and uv run ruff format --check .: clean.

🤖 Generated with Claude Code

georgeh0 added a commit that referenced this pull request Oct 6, 2026
It never ran daemon code: it re-implemented the (st_dev, st_ino) comparison
on touch()ed files. On ext4 the replacement file reuses the unlinked inode
number, so its premise fails and the test fails on ubuntu. #300 removes it
too and replaces the guard it was meant to cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
georgeh0 added a commit that referenced this pull request Oct 6, 2026
…rides as memo arg (#299)

* fix(indexer): fingerprint chunkers at import time, pass overrides as memo arg

#287 hashed each chunker's source file from disk on every index run, but the
daemon runs the code it imported at startup. Editing a chunker and indexing
before a restart stored the old code's chunks under the new fingerprint, and
they stayed after the restart. A functools.partial or callable instance fell
back to repr(), whose memory address re-indexed everything on every restart.

- The daemon records a fingerprint per suffix when it imports a chunker: the
  "module:attr" spec plus a sha256 of the module file. The digest is kept per
  loaded module, so a project loaded again in the same daemon (ccc reset, a
  second project) gets the digest of the code that actually runs.
  Fingerprints reach process_file through a new CHUNKER_FINGERPRINTS context
  key with detect_change=True; CHUNKER_REGISTRY is unchanged.
- indexer_main builds the suffix -> language map once per run and passes it
  to process_file, which uses it; the per-file load_project_settings is gone.
- Remove chunking_fingerprint, _chunker_source_digest and the unused
  chunking_config argument.
- Move the duplicated test stub embedder into a conftest fixture.
- README: any chunking change re-chunks all files; unchanged chunk text keeps
  its cached embedding; only the chunker's own module file is hashed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(project): pass each chunker with its fingerprint

Project.create took the chunker functions and their fingerprints as two
dicts keyed by the same suffixes, so a caller could register chunkers
without fingerprints and silently keep stale chunks. It now takes one
{suffix: LoadedChunker(fn, fingerprint)} mapping and provides both context
keys from it; _resolve_chunker_registry returns that mapping directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(chunking): make CHUNKER_REGISTRY itself change-detected

Instead of a separate CHUNKER_FINGERPRINTS context key that process_file
read only to record a dependency, CHUNKER_REGISTRY now holds LoadedChunker
values and has detect_change=True. LoadedChunker.__coco_memo_key__ returns
(spec, module_sha256) and never the function: cocoindex fingerprints a
function by module and name only and cannot fingerprint lambdas or closures.

CHUNKER_REGISTRY's value type changes from dict[str, ChunkerFn] to
dict[str, LoadedChunker]; LoadedChunker is exported from chunking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(chunking): write exact-content test files as bytes

On Windows, write_text turns "\n" into "\r\n", so the chunk contents the
tests compare exactly came back as "hello\r\n". This already failed the
Windows CI job on main after #287.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style: fix lint left on main by #291

ruff format and end-of-file fixes in the daemon socket guard and its test.
Every Pre-commit CI job on main failed at the lint step, which also kept
pytest from running for any PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(daemon): remove #291's socket inode test

It never ran daemon code: it re-implemented the (st_dev, st_ino) comparison
on touch()ed files. On ext4 the replacement file reuses the unlinked inode
number, so its premise fails and the test fails on ubuntu. #300 removes it
too and replaces the guard it was meant to cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@georgeh0
georgeh0 force-pushed the fix/daemon-socket-unlink-guard branch from eef2669 to b573331 Compare October 6, 2026 02:05
…socket

Completes the fix for #288 started in #291. The inode guard added there
never ran first: multiprocessing's SocketListener registers an os.unlink
finalizer at bind time and runs it unconditionally in close() (shutdown
step 1), so a displaced daemon still deleted the replacement's socket.

- On POSIX the daemon now owns its listening socket (_UnixListener)
  instead of using multiprocessing.connection.Listener. It records the
  socket file's identity at bind and on close() unlinks the path only if
  it still has that identity, before closing the socket (an open socket
  pins its inode). close() also calls shutdown(), which is what wakes a
  blocked accept() on Linux; previously a displaced daemon there waited
  out the 5 s accept-thread join. Windows keeps Listener (named pipes).
- The identity is (st_dev, st_ino, st_ctime_ns): ext4/overlayfs hand a
  freed inode number straight to the next file, so (dev, ino) alone can
  match a replacement's socket once the original is gone.
- socket_identity() / unlink_socket_if_owned() in _daemon_paths are the
  single implementation, also used by the client's stop_daemon() cleanup,
  which unlinked the socket path unconditionally (same class of bug).
- Replace the tautological test with an end-to-end in-process daemon test
  that fails on main, plus a client stop_daemon() test that covers inode
  reuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@georgeh0
georgeh0 force-pushed the fix/daemon-socket-unlink-guard branch from b573331 to 21af4e9 Compare October 6, 2026 22:55
@georgeh0
georgeh0 merged commit b883be0 into main Oct 6, 2026
4 checks passed
@georgeh0
georgeh0 deleted the fix/daemon-socket-unlink-guard branch October 6, 2026 23:03
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