lsp-probe daemon: concurrent first connects leave an orphan daemon until idle timeout #611
Labels
No labels
P0
P1
P2
P3
bug
category/code-change
category/cross-repo
category/eval-run
category/investigation
category/no-test-target
category/owner-ruling
category/prose-artifact
category/recurring-chore
category/structure
category/umbrella
crc-refactor
create
delete
enhancement
filed-by/agent
filed-by/user
frozen
lint-rule
needs-info
needs-triage
next
plugin/cc-architect
plugin/os
plugin/os-adr
plugin/os-aidd-lint
plugin/os-backlog
plugin/os-context
plugin/os-doc-hygiene
plugin/os-sdlc
plugin/os-vault
project/cc-os
ready-for-agent
ready-for-human
recurring
review
rule-proposal
update
waiting
wayfinder:grilling
wayfinder:map
wayfinder:map
wayfinder:research
wayfinder:task
wayfinder:task
wontfix
worklist/deviations
worklist/lint-rule
worklist/new-implement-build
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
jared/cc-os#611
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
lsp-probe daemon: concurrent first connects leave an orphan daemon until idle timeout [capture]
Context
After the #606 merge (ADR-0201) the lsp-probe daemon is keyed on the project root and freshness is a socket connect. Two clients that connect at the same moment both see no socket and both spawn a daemon.
Observed
Two daemons for one root, started in the same second, both alive 17 minutes later. The hash directory holds one socket.
Mechanism. Daemon#fresh? fails for both clients before either socket exists, so both call spawn!. Server.listen runs FileUtils.rm_f(sock_path) then UNIXServer.new(sock_path), so the second bind deletes the first daemon's socket. The first daemon keeps running with an unreachable listener because Server#abandoned? only checks File.exist?(sock_path), and the second daemon's socket satisfies that check. The orphan lives until IDLE_TIMEOUT (1800 s) and holds one ruby-lsp process the whole time.
Reproduce
Two serve processes for the same root.
Expected
An evidence repro run: two parallel
bin/evidence --root . Handoffinvocations afterrm -rf .cc-os/lspleave exactly onelsp-probe serveprocess for the root.Slice
Daemon#connect takes an exclusive
File#flockon aspawn.lockfile inside the per-root hash directory (Paths#dir) aroundspawn! unless fresh?.Paths#prepare!runs first so the directory exists before the lock file is opened. Inside the lock, afterspawn!, connect pollsfresh?until the socket accepts (short sleep, bounded, ~5 s cap) before releasing the lock, so a second waiting client sees a live socket rather than racing a fresh spawn. The lock releases when the block ends, and the kernel releases it if the process exits first.Client.newhappens after the lock is released.Server.listenkeepsFileUtils.rm_ffor stale socket files from a crashed daemon; no other change toServer/abandoned?in this ticket. Amend ADR-0201 with one sentence noting the spawn lock.Acceptance criteria
cd plugins/os-sdlc && rm -rf .cc-os/lsp && (bin/evidence --root . Handoff >/dev/null &) && bin/evidence --root . Handoff >/dev/null; sleep 5; pgrep -af 'lsp-probe serve') leaves exactly onelsp-probe serveprocess for the root.Non-goals
Server#abandoned?or the socket inode check; the flock removes the race that made it matter.Server.listen'srm_fbehavior beyond leaving it as-is for stale sockets from a crashed daemon.Approach
Race, before the fix:
fresh?falsefresh?falserm_fdeletes A's socket, then bindsRace, after the fix:
fresh?true, does not spawnRejected alternatives:
Server#abandoned?(daemon exits when the socket path's inode is not its own): defends against a cause the lock removes; keep for a later ticket if orphans reappear.rm_finServer.listen: a stale socket file from a crashed daemon would then block every future spawn.Conditions this approach depends on:
spawn_and_waitmust not release the lock untilfresh?is true, or a second client can spawn in the gap.Paths#dir(per-root hash directory), so two roots never contend.Owner sketch
Cases
Daemon.connect(root:)calls race, then exactly onelsp-probe serveprocess spawns and both clients connect.Daemon#connectruns, then it does not take thespawn!path and returns promptly.spawn!has run but the socket never appears, whenDaemon#connectpolls past the bound, then it raises a clear error instead of hanging.Origin
Skeptic verdict
VERDICT: CREATE — observed resource leak traced to a design gap in ADR-0201 Server.listen/abandoned?; origin chain ends in DESIGN; root candidate is this ticket.
## Retired testsand## Approach#612Work started on branch ticket-611 via the os-sdlc pipeline (worktree .claude/worktrees/ticket-611). Approach section added before the run; #615 lint-gate path fix merged first.
Implementation 1 escalated at test-writer with unexpected_pass:test-writer/diff-test: diff-test exit 0, 0 ms, three times. Cause: #616 on this plugin. plugins/os-sdlc/.sdlc/project.yaml had no test_path, default test/ matched nothing under tests/, so the gate selected no files after #615 made paths cwd-relative. The written tests are red for the right reason (2 failures in tests/lsp_probe/daemon_test.rb). Fix: test_path: tests added to the config. Re-opening as implementation 2 with the red-round files on disk.
Resolution
Done: Daemon#connect holds an exclusive flock on spawn.lock in the per-root hash directory around fresh-check plus spawn and waits until the socket answers (SpawnTimeout after 5 s). Paths#lock_path owns the path. ADR-0201 amended with one sentence. Test fakes now listen (tests/support/listening_spawner.rb).
Evidence: Branch ticket-611 commit
94957824. os-sdlc suite 1349 runs green three times in a row; rubocop clean on 7 changed files. Live repro: daemons stopped, rm -rf .cc-os/lsp, two parallel bin/evidence runs, exactly one lsp-probe serve process for the root. Pipeline impl 2 reached implementation_complete after 19 dispatches; impl 1 escalated (unexpected_pass, #616 on this plugin, test_path added); programmer dispatch 10 stalled because the test-writer's concurrency test deadlocked under the fix (it waited for two spawns) and was hand-corrected; two suite runs went red intermittently during verification and did not reproduce in five later runs.Follow-ups: #616 remains open (test_path default detection); this commit adds test_path: tests to plugins/os-sdlc/.sdlc/project.yaml as its case 3. The two non-reproducing suite reds are unticketed; no test names were captured.
jared referenced this issue2026-09-19 21:08:42 +00:00