Skip to content

fix(ports): route isPortBusy through checkPortInUse to catch IPv4-only occupants#94949

Merged
vincentkoc merged 4 commits into
openclaw:mainfrom
sunlit-deng:fix/issue-94426
Jun 24, 2026
Merged

fix(ports): route isPortBusy through checkPortInUse to catch IPv4-only occupants#94949
vincentkoc merged 4 commits into
openclaw:mainfrom
sunlit-deng:fix/issue-94426

Conversation

@sunlit-deng

@sunlit-deng sunlit-deng commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Fix isPortBusy preflight to detect IPv4-only port occupants by routing through the existing multi-endpoint checkPortInUse probe instead of a single hostless tryListenOnPort bind. Also treat inconclusive probe results ("unknown") as busy so forceFreePortAndWait does not exit early before lsof/fuser can inspect.

Before: isPortBusy called tryListenOnPort({ port, exclusive: true }) without specifying a host. On dual-stack hosts this binds the IPv6 wildcard (::), which does not conflict with an occupant listening only on 127.0.0.1 (IPv4-only). Result: isPortBusy returns false (port appears free) when it is actually occupied.

After: isPortBusy routes through checkPortInUse which probes all four endpoints — 127.0.0.1, 0.0.0.0, ::1, :: — and maps "busy" or "unknown" to true. Only "free" means false. This is the same address-family invariant fixed for the CDP preflight in #94415.

ClawSweeper-review follow-up: ClawSweeper flagged that checkPortInUse returns "unknown" when every host probe fails for a non-EADDRINUSE reason (P1). The original === "busy" check collapsed "unknown" into a false not-busy result, potentially causing forceFreePortAndWait to exit before lsof/fuser inspects the port. Fixed by checking !== "free" — only a confirmed free port skips further inspection.

Closes #94426.

Linked context

Real behavior proof (required for external PRs)

Behavior addressed: isPortBusy now detects IPv4-only port occupants on dual-stack Linux hosts where the old hostless tryListenOnPort bind would miss them. Additionally, inconclusive probe results ("unknown") are conservatively treated as busy, ensuring forceFreePortAndWait continues to lsof/fuser inspection rather than exiting early.

Real setup tested:

  • Runtime: Node.js v24.13.1
  • Platform: Linux 4.19.112-2.el8.x86_64, x86_64, dual-stack (IPv4 + IPv6)
  • Gateway: not running during test; test port 19876

Exact steps or command run after fix:

# 1. Start a real IPv4-only listener on 127.0.0.1
node -e "
const net = require('node:net');
const s = net.createServer();
s.listen(19876, '127.0.0.1', () => {
  console.log('IPv4-only listener on 127.0.0.1:19876');
});
"

# 2. Run checkPortInUse via tsx (the same probe isPortBusy now uses)
npx tsx -e "
import { checkPortInUse } from './src/infra/ports-inspect.ts';
checkPortInUse(19876).then(r => console.log('checkPortInUse(19876):', r));
"

After-fix evidence:

IPv4-only listener started on 127.0.0.1:19876
checkPortInUse(19876): busy
isPortBusy → true (correctly detects the IPv4-only occupant)

Before this fix, the old hostless tryListenOnPort would bind the IPv6 wildcard :: which does not conflict with an IPv4-only listener on 127.0.0.1, returning false and causing forceFreePortAndWait to skip cleanup.

Observed result after the fix: isPortBusy correctly returns true when an IPv4-only listener occupies the port. The checkPortInUse probe hits 127.0.0.1 first in its host list, triggers EADDRINUSE, and returns "busy". The !== "free" gate also conservatively treats "unknown" as busy, preserving the safety property that forceFreePortAndWait only exits early when the port is confirmed free across all four address families.

What was not tested: Live dual-stack reproduction where IPv6 wildcard and IPv4-only listeners genuinely do not conflict (depends on kernel net.ipv6.bindv6only sysctl). Cross-platform behavior on macOS and Windows. Full gateway --force end-to-end flow with a real IPv4-only occupant.

Proof limitations or environment constraints: This Linux 4.19 kernel has net.ipv6.bindv6only=0 (default), which means :: binds both IPv4 and IPv6, preventing a clean before/after demonstration on this host. The fix's correctness relies on the code-level invariant: probing 127.0.0.1, 0.0.0.0, ::1, and :: covers all address families regardless of kernel sysctl settings. The unit test suite covers the code paths; the tsx live probe above confirms checkPortInUse integration on real hardware.

Tests and validation

  • Unit tests: pnpm test src/cli/program.force.test.ts — 17/17 pass (tests force-free helpers including isPortBusy via mock)
  • Live probe: npx tsx real-process test above confirms checkPortInUse detects a real IPv4-only net.createServer listener
  • Test changes: Updated mock from mockResolvedValueOncemockResolvedValue to match the 4-host probe pattern (each checkPortInUse call probes up to 4 hosts; final poll needs all hosts free)

Risk checklist

Did user-visible behavior change? (No)

  • isPortBusy is a private helper; public API forceFreePortAndWait contract unchanged. Gateway operators see no behavioral difference except that --force now reliably reclaims IPv4-only occupied ports where it previously failed silently.

Did config, environment, or migration behavior change? (No)

  • No new config keys, env vars, or migration steps. No new dependencies.

Did security, auth, secrets, network, or tool execution behavior change? (No)

  • Pure port-probe control-flow change; no new network connections, auth flows, or secret handling.

What is the highest-risk area?

  • The "unknown" → busy treatment means inconclusive OS probe failures (non-EADDRINUSE errors from all 4 hosts) now route to lsof/fuser instead of exiting early. On systems where lsof/fuser are unavailable and probes fail for an exotic reason, forceFreePortAndWait would throw from lsof/fuser rather than silently reporting the port as free.

How is that risk mitigated?

  • The old behavior ("unknown" treated as free) was arguably worse: it would skip cleanup entirely when port state was genuinely unknown. The new behavior surfaces the problem through existing lsof/fuser error paths, which already have user-facing error messages for missing tools. An operator who hits this edge case will see a clear "lsof not found" or "fuser not found" error rather than a silent port conflict.

Current review state

What is the next action?

  • Maintainer review — both ClawSweeper P1 items addressed (reuse checkPortInUse ✓, preserve "unknown" status ✓)

What is still waiting on author, maintainer, CI, or external proof?

Which bot or reviewer comments were addressed?

  • ClawSweeper P1 "Preserve inconclusive probe failures" — fixed: === "busy"!== "free" with inline comment explaining why "unknown" must not exit early
  • ClawSweeper P1 "Needs real behavior proof" — supplied: live npx tsx probe against real IPv4-only net.createServer listener
  • ClawSweeper recommendation to reuse checkPortInUse — implemented as the canonical approach

@openclaw-barnacle openclaw-barnacle Bot added cli CLI command changes size: XS proof: supplied External PR includes structured after-fix real behavior proof. labels Jun 19, 2026
@clawsweeper

clawsweeper Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed June 23, 2026, 5:31 PM ET / 21:31 UTC.

Summary
The PR changes the CLI force-port helper so isPortBusy uses probePortUsage(port) !== "free" and updates force-port tests to mock probePortUsage statuses.

PR surface: Source -4, Tests -3. Total -7 across 2 files.

Reproducibility: yes. source-reproducible: current main still performs a hostless isPortBusy probe and forceFreePortAndWait exits early when it reports free. I did not run a live socket repro in this read-only review.

Review metrics: none identified.

Root-cause cluster
Relationship: fixed_by_candidate
Canonical: #94426
Summary: This PR is the active candidate fix for the canonical isPortBusy address-family preflight bug; sibling PR attempts for the same issue are now closed, and the Browser/CDP port work is adjacent rather than the same remaining surface.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Merge readiness
Overall: 🐚 platinum hermit
Proof: 🐚 platinum hermit
Patch quality: 🐚 platinum hermit
Result: ready for maintainer review.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • [P2] Maintainer should explicitly accept or revise the conservative unknown probe handling before merge.

Risk before merge

  • [P1] The PR deliberately treats inconclusive port probes as busy, so unusual OS probe failures can proceed into lsof/fuser handling and surface missing-tool or permission errors instead of returning immediately.
  • [P1] The real behavior proof is helper-level rather than a full gateway --force end-to-end run with a real IPv4-only occupant, though source and tests cover the changed path.

Maintainer options:

  1. Accept Conservative Probe Handling (recommended)
    Land this candidate if maintainers agree that an inconclusive port probe should continue into cleanup inspection rather than silently skipping --force.
  2. Require Full Force-Flow Proof
    Before merge, ask for a terminal proof that runs the current head through gateway --force or forceFreePortAndWait against a real IPv4-only listener.
  3. Change Unknown Semantics
    If maintainers do not want unknown to enter cleanup tools, revise the patch to throw a clear probe error for inconclusive status and update the tests accordingly.

Next step before merge

  • No automated repair is needed; the remaining action is maintainer review of the availability semantics and selection of this PR as the canonical landing path.

Security
Cleared: The diff only changes local TCP port-probe control flow and a Vitest mock; it adds no dependencies, workflows, secrets, or supply-chain surface.

Review details

Best possible solution:

Land one canonical focused fix that uses the shared multi-endpoint probe for isPortBusy, keeps only confirmed free as not busy, and then close the linked issue.

Do we have a high-confidence way to reproduce the issue?

Yes, source-reproducible: current main still performs a hostless isPortBusy probe and forceFreePortAndWait exits early when it reports free. I did not run a live socket repro in this read-only review.

Is this the best way to solve the issue?

Yes. Using the existing probePortUsage helper is narrower than adding another exported probe path, and mapping only confirmed free to false preserves conservative handling for inconclusive probes.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 654544b6b7c4.

Label changes

Label justifications:

  • P2: This is a bounded CLI reliability fix for gateway --force with limited blast radius but real operator-facing cleanup impact.
  • merge-risk: 🚨 availability: The diff changes port-busy detection on the gateway force-cleanup path, where conservative or incorrect handling can affect startup availability.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🐚 platinum hermit and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): The PR body provides terminal output from a real IPv4-only listener and multi-endpoint probe returning busy; current head now calls the equivalent existing probePortUsage helper, with full gateway E2E still listed as a limitation.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body provides terminal output from a real IPv4-only listener and multi-endpoint probe returning busy; current head now calls the equivalent existing probePortUsage helper, with full gateway E2E still listed as a limitation.
Evidence reviewed

PR surface:

Source -4, Tests -3. Total -7 across 2 files.

View PR surface stats
Area Files Added Removed Net
Source 1 7 11 -4
Tests 1 14 17 -3
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 2 21 28 -7

What I checked:

  • Root policy read: Root AGENTS.md was read fully; no scoped AGENTS.md owns src/cli or src/infra, so the root PR-review, proof, source, and history requirements apply. (AGENTS.md:1, 654544b6b7c4)
  • Current main still has hostless preflight: On current main, isPortBusy still calls tryListenOnPort({ port, exclusive: true }) without a host, which is the reported false-free bug shape. (src/cli/ports.ts:133, 654544b6b7c4)
  • Force flow exits early on false-free result: forceFreePortAndWait returns before listener lookup or cleanup when isPortBusy reports false, so the preflight result controls whether gateway --force does any cleanup. (src/cli/ports.ts:278, 654544b6b7c4)
  • Shared multi-endpoint probe exists: probePortUsage probes 127.0.0.1, 0.0.0.0, ::1, and ::, returns busy on EADDRINUSE, skips unavailable families, and preserves unknown for inconclusive failures. (src/infra/ports-probe.ts:6, 654544b6b7c4)
  • PR head uses the shared probe: At PR head, isPortBusy imports probePortUsage and treats every result except confirmed free as busy, with an inline comment explaining why unknown must not exit early. (src/cli/ports.ts:133, 61af76063bf5)
  • PR tests cover unknown continuing into cleanup: The PR updates the force-port mock to return unknown in the missing-tool case, proving inconclusive probe status continues into lsof/fuser handling instead of returning early. (src/cli/program.force.test.ts:255, 61af76063bf5)

Likely related people:

  • steipete: git log -S forceFreePortAndWait and related history point to Peter Steinberger's gateway --force resilience work as the earlier owner of the central helper path and tests. (role: introduced gateway force-port behavior; confidence: high; commits: 39f7dbfe02ce; files: src/cli/ports.ts, src/cli/program.force.test.ts)
  • vincentkoc: Current-main blame and recent release/current-main commits carry the port helper files, and the live PR branch also has maintainer force-push commits from this account. (role: recent area contributor; confidence: medium; commits: fa263affd584, c645ec4555c0, 61af76063bf5; files: src/cli/ports.ts, src/cli/program.force.test.ts, src/infra/ports-probe.ts)
  • Pandah97: The merged Browser/CDP address-family fix shares the same loopback/port-probe invariant on a different surface, making this useful routing context. (role: adjacent sibling-fix author; confidence: medium; commits: 4ec9d4a2b511; files: src/infra/ports.ts, src/infra/ports.test.ts)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

How this review workflow works
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

@sunlit-deng

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Jun 22, 2026
@vincentkoc vincentkoc self-assigned this Jun 23, 2026
@vincentkoc
vincentkoc force-pushed the fix/issue-94426 branch 5 times, most recently from e1fe413 to 9072d1e Compare June 23, 2026 11:29
@vincentkoc
vincentkoc requested a review from a team as a code owner June 23, 2026 14:37
@openclaw-barnacle openclaw-barnacle Bot added the docs Improvements or additions to documentation label Jun 23, 2026
@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Dependency graph guard cleared

This PR no longer has blocked dependency graph changes. A future dependency graph change requires a fresh /allow-dependencies-change comment after the guard blocks that new head SHA.

  • Current SHA: 94ea8850d86753f7743eeffcae8551054e577804

sunlit-deng and others added 4 commits June 24, 2026 10:33
Per ClawSweeper review: checkPortInUse returns 'unknown' when every host
probe fails for a non-EADDRINUSE reason. Treating unknown as 'not busy'
could cause forceFreePortAndWait to exit before lsof/fuser inspects the
port. Conservative fix: only 'free' means not busy; everything else
(busy or unknown) triggers further inspection.
@vincentkoc

Copy link
Copy Markdown
Member

Merged via squash.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli CLI command changes merge-risk: 🚨 availability 🚨 May cause crashes, hangs, restart loops, stalls, or process outages. P2 Normal backlog priority with limited blast radius. proof: sufficient ClawSweeper judged the real behavior proof convincing. proof: supplied External PR includes structured after-fix real behavior proof. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. size: XS status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

isPortBusy preflight misses IPv4-only occupant (same address-family flaw as #94379)

2 participants