Repository navigation
fix(daemon): keep a displaced daemon from deleting its replacement's socket - #300
Merged
Merged
Conversation
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
force-pushed
the
fix/daemon-socket-unlink-guard
branch
from
October 6, 2026 02:05
eef2669 to
b573331
Compare
…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
force-pushed
the
fix/daemon-socket-unlink-guard
branch
from
October 6, 2026 22:55
b573331 to
21af4e9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Completes the fix for #288 (follow-up to #291). Rebased on main (#299, #302, #303).
Why #291 had no effect
multiprocessing.connection.SocketListenerregistersutil.Finalize(self, os.unlink, args=(address,), exitpriority=0)at bind time forAF_UNIX, andclose()runs it unconditionally. Shutdown step 1 callslistener.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 comparedtouch()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 (
_UnixListenerindaemon.py). Windows keepsmultiprocessing.connection.Listener(named pipes) and is unchanged._UnixListenerbinds the path and records the socket file's identity. Onclose(), 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 underif sys.platform != "win32":, which keeps mypy happy on Windows, where typeshed has nosocket.AF_UNIX.I chose this over disarming the stdlib finalizer (
listener._listener._unlink.cancel()) for three reasons:getattrwould silently bring the bug back if they were renamed.close()does not wake a thread blocked inaccept(); onlyshutdown()does. macOS wakes it onclose(). I checked both: macOS locally, Linux inpython: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 saccept_thread.join(timeout=5)and the thread leaks._UnixListener.close()callsshutdown()first. Doing that through the stdlib object would need a third private attribute (_socket).socketplusConnection(fd), the same wrappingSocketListener.accept()does. It matches the stdlib's blocking mode andbacklog=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 apython:3.13-slimcontainer, 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()andunlink_socket_if_owned()in_daemon_paths.pyare used at bind time, at daemon shutdown, and by the client.Client
_cleanup_stale_filesalso 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()and the stat would be recorded as ours.bind()can't report the file's identity.I considered two ways to close them:
os.replaceit into place. This closes only the startup gap. The temp path must fit thesun_pathlimit (104 or 108 bytes) next to a path that was already length-checked, and a crash between bind and rename leaves stray socket files.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, becauseclose()wakesaccept()itself.Tests
test_displaced_daemon_keeps_replacement_socket(tests/test_daemon_idle.py).AF_UNIXsocket. After the daemon idle-exits, it asserts the path still exists with the replacement's identity and that the PID file was removed.test_stop_daemon_removes_only_the_stopped_daemons_socket[False|True](tests/test_client.py).Truefails without the client change. On Linux it also fails with a(dev, ino)-only identity.Falsechecks that a stale socket is still removed._assert_cleaned_upchecks still pass.Verification
uv run pytest tests/: 350 passed after the rebase onto main.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 .anduv run ruff format --check .: clean.🤖 Generated with Claude Code