Skip to content

Fix dangling path pointer in StatWatcher under GC compaction#98

Merged
Watson1978 merged 2 commits into
socketry:mainfrom
Watson1978:fix-statwatcher-dangling-path
Jul 2, 2026
Merged

Fix dangling path pointer in StatWatcher under GC compaction#98
Watson1978 merged 2 commits into
socketry:mainfrom
Watson1978:fix-statwatcher-dangling-path

Conversation

@Watson1978

Copy link
Copy Markdown
Collaborator

Problem

ev_stat_init() retains the path pointer it is given for the entire lifetime of the watcher, and libev dereferences it on every stat (inotify rescans, lstat, statfs, …). Coolio::StatWatcher passed RSTRING_PTR(@path), which points into a Ruby String whose backing buffer GC.compact may relocate. After a compaction, libev is left holding a dangling pointer and may stat() freed/relocated memory — watching the wrong path or, in the worst case, crashing.

This is latent (its manifestation depends on GC timing and libev's access pattern), but it is a genuine object-lifetime hazard: a raw char* obtained from RSTRING_PTR must not be retained across code that can trigger compaction.

Fix

Keep an owned copy of the path in the watcher struct (stat_path) and hand that to ev_stat_init(), so libev points at stable memory the GC never moves. The copy is released in a new Coolio_Watcher_free() that replaces RUBY_DEFAULT_FREE (non-stat watchers leave stat_path as NULL, so the free is a no-op for them).

strlcpy is used for the copy; it is available on all Ruby-supported platforms via ruby/missing.h's fallback declaration, so no extconf.rb change is needed.

Test

Adds a spec that runs a StatWatcher across a forced compaction (GC.verify_compaction_references / GC.compact) and asserts on_change still fires.

Note: this is primarily a smoke/regression guard — because libev's retained pointer is not a Ruby-visible reference and compaction reuses (rather than unmaps) the vacated slot, the pre-fix bug cannot be surfaced deterministically from pure Ruby. The correctness of the fix rests on owning the buffer.

Full suite passes locally (Ruby 4.0.5), including the shared-free memory-leak spec.

🤖 Generated with Claude Code

Watson1978 and others added 2 commits July 3, 2026 00:11
ev_stat_init() retains the path pointer it is given for the entire
lifetime of the watcher; libev dereferences it on every stat (inotify
rescans, lstat, statfs, ...).  StatWatcher passed RSTRING_PTR(@path),
which points into a Ruby String whose buffer GC.compact may relocate,
leaving libev with a dangling pointer.

Keep an owned copy of the path in the watcher struct instead, and free
it in a new Coolio_Watcher_free() (replacing RUBY_DEFAULT_FREE).  The
copy is stable memory that the GC never moves, so libev's retained
pointer stays valid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RSTRING_PTR does not, by contract, guarantee a NUL terminator, and it
does nothing about embedded NUL bytes: a path such as "foo\0bar" would
be silently truncated to "foo" when libev treats it as a C string.

Copy from StringValueCStr(path) instead, which guarantees NUL
termination and raises ArgumentError on an embedded NUL, matching how
the standard library handles paths (e.g. File.stat).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Watson1978 Watson1978 force-pushed the fix-statwatcher-dangling-path branch from 1f2531c to 75ae12e Compare July 2, 2026 15:29
@Watson1978 Watson1978 merged commit 8c90220 into socketry:main Jul 2, 2026
14 checks passed
@Watson1978 Watson1978 deleted the fix-statwatcher-dangling-path branch July 2, 2026 15:32
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