Skip to content

fix(daemon): eliminate kubo restart races that orphan kubo or hang shutdown#71

Merged
Rinse12 merged 4 commits into
masterfrom
fix/kubo-restart-cleanup-races
Jun 8, 2026
Merged

fix(daemon): eliminate kubo restart races that orphan kubo or hang shutdown#71
Rinse12 merged 4 commits into
masterfrom
fix/kubo-restart-cleanup-races

Conversation

@Rinse12

@Rinse12 Rinse12 commented Jun 7, 2026

Copy link
Copy Markdown
Member

Closes #70

What

Fixes the cluster of races behind the flaky CI test daemon.test.ts > stops kubo when daemon exits during a restart cycle (kubo outliving the daemon, or the daemon hanging in its exit hook). Root-cause analysis, CI history, and the smoking-gun trace are in #70.

Changes

src/cli/commands/daemon.ts

  • keepKuboUp is now a single-flight wrapper around keepKuboUpOnce — the kubo exit handler and the watchdog tick share one attempt instead of racing past the re-entrancy guard (bug 1)
  • state (incl. mainProcessExited) is re-checked after the awaits before committing to a spawn, so the daemon never spawns kubo mid-shutdown
  • failure paths and onKuboExit only clear kuboProcess/pendingKuboStart they own, so a failed attempt can no longer orphan another attempt's healthy kubo (bug 3)
  • defense in depth: every spawned kubo pid lives in liveKuboPids until its exit; both killKuboProcess and the synchronous emergency process.on("exit") handler SIGKILL anything left in the set
  • killKuboProcess also awaits any in-flight keepKuboUp before deciding what to kill

src/ipfs/startIpfs.ts

  • startKuboNode's preparation phase (init / config / repo migrate / port checks) no longer runs inside an async promise executor — failures now reject the returned promise instead of escaping as unhandledRejections that left it unsettled forever (wedging pendingKuboStart and hanging shutdown, bug 2)

Tests

New test/cli/daemon-kubo-restart-race.test.ts reproduces the bugs deterministically via the new PKC_CLI_TEST_KEEPKUBOUP_PORTCHECK_DELAY_MS hook (same pattern as PKC_CLI_TEST_IPFS_READY_DELAY_MS), which widens the guard→assignment window so a watchdog tick reliably lands inside it:

  • concurrent keepKuboUp entries must not orphan kubo or hang shutdown — before: expected false to be true (hang/orphan); after: passes
  • rejects (instead of never settling) when ipfs init fails because a daemon already runs on the repo — before: expected 'never-settled' to be 'rejected' + vitest-reported Unhandled Rejection; after: rejects in ~1s

Full suite: 31 files, 249 passed, 1 skipped.

Summary by CodeRabbit

  • Bug Fixes

    • Reduced daemon/IPFS restart race conditions and improved single-attempt startup coordination.
    • Stronger shutdown/cleanup to prevent orphaned IPFS processes (extra kill safeguards).
    • Improved error propagation from initialization so startup failures surface reliably.
  • Tests

    • Added regression suites covering restart races, wedged startups, and startup-settle failures.
    • Improved test determinism and failure diagnostics (timing control and richer logs).

…utdown (issue #70)

Three related bugs in the kubo restart/cleanup machinery, found while
investigating the flaky CI test "stops kubo when daemon exits during a
restart cycle":

- keepKuboUp could be entered concurrently by the kubo exit handler and
  the watchdog interval (its re-entrancy guard and the pendingKuboStart
  assignment are separated by awaits), spawning two kubo processes. It is
  now a single-flight wrapper, and state is re-checked after every await
  before committing to a spawn.
- startKuboNode used an async promise executor, so init/migrate failures
  escaped as unhandledRejections and the returned promise never settled,
  wedging pendingKuboStart forever and hanging the daemon's exit hook.
  The preparation phase now runs as plain awaits and rejects properly.
- The failure path cleared the shared kuboProcess/pendingKuboStart even
  when they tracked another attempt's healthy kubo, orphaning it past
  daemon exit. Clears are now ownership-checked.

Defense in depth: every spawned kubo pid is tracked in liveKuboPids until
its exit, and both the graceful kill path and the synchronous emergency
exit handler SIGKILL anything left in the set.

Regression tests reproduce the races deterministically via the new
PKC_CLI_TEST_KEEPKUBOUP_PORTCHECK_DELAY_MS test hook (same pattern as
PKC_CLI_TEST_IPFS_READY_DELAY_MS).
@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Rinse12, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 35 minutes and 28 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6635918a-fd6f-4c7b-b500-bdf8b43d648e

📥 Commits

Reviewing files that changed from the base of the PR and between 61a0594 and 36ac0f4.

📒 Files selected for processing (2)
  • src/cli/commands/daemon.ts
  • test/cli/daemon-kubo-restart-race.test.ts
📝 Walkthrough

Walkthrough

Harden daemon Kubo lifecycle: remove async-in-promise from startKuboNode, add single‑flight keepKuboUp with post-await state rechecks and ownership-checked clears, track all spawned Kubo PIDs, expand shutdown/emergency SIGKILL cleanup, and add regression tests reproducing races.

Changes

Kubo restart race condition fixes

Layer / File(s) Summary
startKuboNode preparation phase separation
src/ipfs/startIpfs.ts
Preparation steps (ipfs init/profile apply, repo migrate, port checks) moved out of the Promise executor into awaits so preparation failures reject normally and the start promise always settles.
keepKuboUpOnce re-entrancy guards and state re-checks
src/cli/commands/daemon.ts
Adds liveKuboPids set, optional test delay after port checks, and re-validates state after awaits to avoid duplicate spawns triggered by stale port-check results or concurrent adoption/shutdown.
Single-flight keepKuboUp and startup ownership tracking
src/cli/commands/daemon.ts
Introduces single‑flight wrapper so concurrent callers share one in-flight attempt; startup uses a local spawnedProcess, registers/unregisters PIDs in liveKuboPids, and clears pendingKuboStart/kuboProcess only when the attempt still owns them.
Shutdown safety and emergency process cleanup
src/cli/commands/daemon.ts
killKuboProcess waits (bounded) for in-flight start attempts to settle before killing, then SIGKILLs any remaining PIDs in liveKuboPids. process.on('exit') now SIGKILLs tracked kuboProcess and all liveKuboPids.
Regression and determinism tests
test/cli/daemon-kubo-restart-race.test.ts, test/cli/daemon.test.ts
Adds Vitest suites that reproduce concurrent restart races and wedged startups using PKC_CLI_TEST_KEEPKUBOUP_PORTCHECK_DELAY_MS, verifies startKuboNode rejects on failure instead of hanging, and improves test log diagnostics and teardown.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

  • #70: These changes implement the issue's fixes (single‑flight guard, startKuboNode async-in-promise removal, state re-checks, ownership-checked clears, and defense-in-depth cleanup) to resolve the flaky daemon restart race.

Poem

🐰 I nudged the burrow's tangled threads,

Promises unlatched like sleepy heads.
Now single‑flight and careful checks keep,
All tiny processes tucked to sleep.
Cheers — no orphans left to peep.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately and concisely summarizes the main change: fixing kubo restart races in the daemon that cause orphaned processes or shutdown hangs, matching the core objectives in issue #70.
Linked Issues check ✅ Passed The PR addresses all three root causes from issue #70: single-flight keepKuboUp with state re-checks, async promise executor removal from startKuboNode for proper settlement, ownership-checked state clears, and comprehensive PID tracking/cleanup.
Out of Scope Changes check ✅ Passed All changes are directly scoped to issue #70: daemon kubo restart races. The modifications to daemon.ts and startIpfs.ts fix the identified bugs, and test additions verify the fixes deterministically without introducing unrelated changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/kubo-restart-cleanup-races

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/ipfs/startIpfs.ts (1)

308-316: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't drop the startup exit handler before the daemon installs its steady-state one.

completeResolve() removes onProcessExit/onProcessError before src/cli/commands/daemon.ts attaches currentProcess.once("exit", onKuboExit) after await startPromise. If Kubo exits in that handoff window, kuboProcess stays pointed at a dead child and the watchdog never restarts it because hasKuboProcess is still true. Keep the startup listener alive until the caller acknowledges readiness, or register the restart listener from onSpawn and gate it on a startupResolved flag.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ipfs/startIpfs.ts` around lines 308 - 316, The startup completion
currently removes the startup exit/error handlers inside completeResolve(),
which can leave kuboProcess unmonitored if the caller (daemon.ts) hasn't yet
attached its steady-state handler (currentProcess.once("exit", onKuboExit")
after awaiting startPromise); instead, keep the onProcessExit/onProcessError
listeners until the caller signals readiness or change the registration so the
persistent restart watcher is attached in onSpawn and gated by a startupResolved
flag; update completeResolve (and any callers of startPromise) to either defer
removing listeners until a readiness acknowledgement or set startupResolved=true
and have the onSpawn-registered restart listener check that flag before
behaving, referencing completeResolve, onProcessExit, onProcessError, onSpawn,
startupResolved, startPromise and the caller's currentProcess.once("exit",
onKuboExit) to locate the logic to change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/cli/commands/daemon.ts`:
- Around line 539-549: The shutdown can deadlock because killKuboProcess awaits
keepKuboUpInFlight which may never settle; update killKuboProcess to first kill
any already-spawned processes (check kuboProcess and liveKuboPids and send
SIGINT/SIGKILL) before awaiting keepKuboUpInFlight, or wrap the await of
keepKuboUpInFlight in a bounded timeout so it won’t block forever; adjust logic
in killKuboProcess (and related onSpawn/startKuboNode interactions) to ensure
immediate termination of known PIDs even if startKuboNode never resolves.

In `@test/cli/daemon-kubo-restart-race.test.ts`:
- Around line 19-20: Tests mutate global DNS order via
dns.setDefaultResultOrder; instead, change the two constants RACE_RPC_URL and
RACE_KUBO_API_URL to use the IP 127.0.0.1 instead of "localhost" and remove the
dns import and the setDefaultResultOrder call so no global DNS behavior is
changed or needs restoring.

---

Outside diff comments:
In `@src/ipfs/startIpfs.ts`:
- Around line 308-316: The startup completion currently removes the startup
exit/error handlers inside completeResolve(), which can leave kuboProcess
unmonitored if the caller (daemon.ts) hasn't yet attached its steady-state
handler (currentProcess.once("exit", onKuboExit") after awaiting startPromise);
instead, keep the onProcessExit/onProcessError listeners until the caller
signals readiness or change the registration so the persistent restart watcher
is attached in onSpawn and gated by a startupResolved flag; update
completeResolve (and any callers of startPromise) to either defer removing
listeners until a readiness acknowledgement or set startupResolved=true and have
the onSpawn-registered restart listener check that flag before behaving,
referencing completeResolve, onProcessExit, onProcessError, onSpawn,
startupResolved, startPromise and the caller's currentProcess.once("exit",
onKuboExit) to locate the logic to change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6fee7d36-e059-499f-911d-77025ad06989

📥 Commits

Reviewing files that changed from the base of the PR and between afd8bca and 6b61044.

📒 Files selected for processing (3)
  • src/cli/commands/daemon.ts
  • src/ipfs/startIpfs.ts
  • test/cli/daemon-kubo-restart-race.test.ts

Comment thread src/cli/commands/daemon.ts Outdated
Comment on lines +19 to +20
import dns from "node:dns";
import path from "path";

@coderabbitai coderabbitai Bot Jun 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n --type=ts 'setDefaultResultOrder|getDefaultResultOrder' test

Repository: bitsocialnet/bitsocial-cli

Length of output: 755


🏁 Script executed:

cat -n test/cli/daemon-kubo-restart-race.test.ts

Repository: bitsocialnet/bitsocial-cli

Length of output: 9244


🏁 Script executed:

rg -n "localhost" test/cli/daemon-kubo-restart-race.test.ts

Repository: bitsocialnet/bitsocial-cli

Length of output: 257


🏁 Script executed:

rg -n "dns\." test/cli/daemon-kubo-restart-race.test.ts

Repository: bitsocialnet/bitsocial-cli

Length of output: 148


Use IP address instead of hostname to avoid process-global DNS mutation.

Line 30 mutates global DNS resolution order via setDefaultResultOrder and never restores it. This can leak into other test suites and cause order-dependent flakiness.

Replace the two localhost references with 127.0.0.1 to eliminate the need for DNS resolution:

  • Line 33: RACE_RPC_URL uses localhost
  • Line 36: RACE_KUBO_API_URL uses localhost

This allows removal of the dns import and the setDefaultResultOrder call.

Suggested change
-import dns from "node:dns";
 import path from "path";
-dns.setDefaultResultOrder("ipv4first"); // to be able to resolve localhost
-
 // --- Port allocations unique to this file (avoid conflicts with other test files and external processes) ---
-const RACE_RPC_URL = `ws://localhost:9548`;
+const RACE_RPC_URL = `ws://127.0.0.1:9548`;
 const RACE_KUBO_URL = `http://0.0.0.0:50299/api/v0`;
 const RACE_GATEWAY_URL = `http://0.0.0.0:6753`;
-const RACE_KUBO_API_URL = `http://localhost:50299/api/v0`;
+const RACE_KUBO_API_URL = `http://127.0.0.1:50299/api/v0`;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import dns from "node:dns";
import path from "path";
import path from "path";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/cli/daemon-kubo-restart-race.test.ts` around lines 19 - 20, Tests mutate
global DNS order via dns.setDefaultResultOrder; instead, change the two
constants RACE_RPC_URL and RACE_KUBO_API_URL to use the IP 127.0.0.1 instead of
"localhost" and remove the dns import and the setDefaultResultOrder call so no
global DNS behavior is changed or needs restoring.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining this one: the vitest config uses pool: "forks" — every test file runs in its own child process, so dns.setDefaultResultOrder cannot leak into other suites. The line is also the established convention in this repo's test files (test/cli/daemon.test.ts does exactly the same, for the same reason), so diverging here would just be inconsistency.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Rinse12, understood — pool: "forks" provides full process isolation so the DNS order mutation is truly local to each test file, and consistency with test/cli/daemon.test.ts is a good reason to keep the pattern as-is. I'll withdraw the suggestion.

(´• ω •`) 🐇


✏️ Learnings added
Learnt from: Rinse12
Repo: bitsocialnet/bitsocial-cli PR: 71
File: test/cli/daemon-kubo-restart-race.test.ts:19-20
Timestamp: 2026-06-07T10:16:44.262Z
Learning: In the bitsocialnet/bitsocial-cli repository, Vitest is configured with `pool: "forks"`, so each test file runs in its own child process. Calls to `dns.setDefaultResultOrder("ipv4first")` inside test files (e.g. `test/cli/daemon.test.ts`, `test/cli/daemon-kubo-restart-race.test.ts`) are intentional and safe — they cannot leak into other test suites. This is the established convention in the repo for tests that use `localhost`-based URLs; do not flag it as a global-state mutation issue.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: CR
Repo: bitsocialnet/bitsocial-cli PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-05-24T05:44:09.099Z
Learning: Applies to **/*.{ts,tsx} : Check `config/` for tsconfig and vitest settings before assuming defaults

…review)

killKuboProcess awaited keepKuboUpInFlight/pendingKuboStart unboundedly.
Both settle on all failure paths, but a spawned kubo that wedges before
"Daemon is ready" without exiting keeps them pending — SIGTERM then
blocked shutdown until the exit hook's 120s hard cap. Cap the wait at
15s so shutdown always reaches the SIGINT/SIGKILL flow, which kills the
spawned kubo via kuboProcess (set in onSpawn) or the liveKuboPids sweep.

Regression test simulates the wedged startup by holding the ready
acknowledgement via PKC_CLI_TEST_IPFS_READY_DELAY_MS and asserts the
daemon exits well before the 120s cap with kubo dead.
@Rinse12

Rinse12 commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

Re CodeRabbit's outside-diff finding on src/ipfs/startIpfs.ts ("don't drop the startup exit handler before the daemon installs its steady-state one"): declining — the claimed handoff window doesn't exist in practice.

completeResolve() (which removes the startup listeners and resolves) runs synchronously inside an event callback, and the daemon's await startPromise continuation — which registers currentProcess.once("exit", onKuboExit) with no intervening awaits — runs on the microtask queue. Node drains microtasks before delivering the next event-loop callback (true since v11, even between two event emissions in the same poll phase), so a child exit event can never be observed between the resolve and the onKuboExit registration — even if kubo prints "Daemon is ready" and immediately crashes, the exit callback fires only after onKuboExit is attached.

Additionally, after this PR a kubo exit can no longer strand the daemon even hypothetically: the pid is tracked in liveKuboPids until its real exit, and the watchdog tick re-runs keepKuboUp whenever kuboProcess/pendingKuboStart are clear.

Rinse12 added 2 commits June 7, 2026 10:39
…n CI (issue #70)

The original flaky test still fails on CI with the race fixes in place,
so the CI mechanism differs from the locally-reproduced ones. The CI log
only shows the bare assertion; the daemon's DEBUG output is redirected
to its log files. Pass an explicit --logPath and print the log file
tails (plus captured stdout/stderr and exit status) when the final
assertion is about to fail, so the next CI failure identifies which
kubo pids were spawned, restarted and killed.
…d-cleanup (issue #70)

The real CI mechanism behind the flaky restart-cleanup test: exit-hook
registers its SIGINT/SIGTERM handlers with process.once, so its listener
vanishes from the listener list the moment a signal is dispatched.
signal-exit (loaded by @pkcprotocol/proper-lock-file and other deps)
re-raises the signal when every remaining listener is its own family —
killing the daemon ~100ms into shutdown while the async kubo cleanup is
still parked. Signal deaths skip process.on("exit"), so the emergency
sweep never ran either; the freshly restarted kubo outlived the daemon
(CI forensics: signalCode=SIGTERM, dead in 120ms, orphaned kubo).

Fix: a persistent SIGINT/SIGTERM guard listener — its presence defeats
every signal-exit copy's only-family-left heuristic, letting the exit
hook finish and exit via process.exit (which does run the emergency
sweep). A repeated signal force-quits immediately via process.exit,
preserving the impatient-Ctrl+C behavior.

Regression test simulates a late signal-exit registrant via the new
PKC_CLI_TEST_SIMULATE_LATE_SIGNAL_EXIT hook, replicating signal-exit's
family-counting re-raise semantics.
@Rinse12

Rinse12 commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

Pushed two more commits beyond the original race fixes — the CI flake turned out to have a deeper mechanism (full analysis in #70):

  • 10905a9 bounds the shutdown wait on in-flight kubo starts (CodeRabbit finding, validated red-test-first)
  • 36ac0f4 guards shutdown against signal-exit re-raising SIGTERM mid-cleanup: exit-hook's process.once listener disappears when invoked, and signal-exit (via @pkcprotocol/proper-lock-file et al.) then re-raises the signal believing it's the only listener left — killing the daemon ~100ms into shutdown and orphaning kubo (this was the actual CI failure, confirmed by log forensics: signalCode=SIGTERM, dead in 120ms). A persistent SIGINT/SIGTERM guard listener defeats the heuristic; a repeated signal force-quits via process.exit, which (unlike signal death) runs the emergency kubo sweep.

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.

daemon: kubo restart races — concurrent keepKuboUp entries orphan kubo or hang shutdown (flaky "stops kubo when daemon exits during a restart cycle")

1 participant