Skip to content

ADFA-5035: Fix WebServer occasionally failing to start with EADDRINUSE - #1634

Merged
davidschachterADFA merged 8 commits into
stagefrom
bugfix/ADFA-5035-webserver-eaddrinuse
Aug 11, 2026
Merged

ADFA-5035: Fix WebServer occasionally failing to start with EADDRINUSE#1634
davidschachterADFA merged 8 commits into
stagefrom
bugfix/ADFA-5035-webserver-eaddrinuse

Conversation

@davidschachterADFA

@davidschachterADFA davidschachterADFA commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • WebServer.start() binds serverSocket on a background thread (launched from MainActivity.startWebServer()); stop() (called from onDestroy(), main thread) only closes it if already initialized -- otherwise it's a silent no-op.
  • If stop() runs before start() reaches bind(), that no-op lets start() bind anyway a moment later, orphaned, holding the port until the process dies. The next start() attempt on that port then fails with BindException: Address already in use (EADDRINUSE) -- matching ADFA-5035's "occasionally fails to start" report.
  • Fix: synchronize start()'s bind step and stop()'s close step on a shared lock, and have stop() record that a stop was requested so start() can abort before binding if one arrived first. Closes the race window instead of relying on timing (reuseAddress = true alone doesn't help here -- it only helps rebind a socket lingering in TIME_WAIT, not one still actively held by an orphaned listener).
  • Second commit is a Spotless ratchet reformat of the file (tabs, wrapped long lines, sql_query -> sqlQuery, HTTP_* -> camelCase) required once it was touched -- no behavioral change.

Test plan

  • :app:compileV8DebugKotlin succeeds.
  • spotlessCheck passes.
  • Manual repro of the original race wasn't attempted (timing-dependent, hard to reliably trigger); the fix closes the race by construction (start() and stop() can no longer interleave around the uninitialized-socket window).

start() binds serverSocket on a background thread (launched from
MainActivity.startWebServer()); stop() (called from onDestroy(), main
thread) only closes serverSocket if it's already initialized. If
stop() runs before start() reaches bind(), it's a silent no-op --
start() then binds anyway a moment later, orphaned, holding the port
until the process dies. The next start() attempt on that port fails
with "Address already in use."

Synchronize start()'s bind and stop()'s close on a shared lock, and
have stop() record that a stop was requested so start() can abort
before binding if one arrived first. Closes the race window instead of
relying on timing.

Also renamed HTTP_INTERNAL_SERVER_ERROR/HTTP_NOT_FOUND to camelCase
(pre-existing ktlint property-naming violations, unrelated to this fix
but required once this file falls under the Spotless ratchet).
WebServer.kt was space-indented and had several pre-existing
ktlint violations (max-line-length, snake_case sql_query). Touching
the file in the previous commit pulled it under the Spotless ratchet,
so bring it into compliance: tabs, wrapped long lines/comments, and
sql_query -> sqlQuery. No behavioral change.

@claude claude 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.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough
  • Fixed a WebServer race condition that could cause later starts to fail with BindException: Address already in use.
  • Synchronized socket binding and closing with a shared lifecycle lock.
  • Added stop-request tracking so start() aborts before binding when stop() runs first.
  • Closed the database during startup cleanup.
  • Added deterministic tests for both lifecycle orderings and port cleanup.
  • Prevented duplicate error responses after partial response-write failures.
  • Applied Spotless formatting and renamed identifiers to satisfy naming rules.
  • Preserved existing HTTP handling and Java compile/run behavior.
  • Validation passed with :app:compileV8DebugKotlin, spotlessCheck, and the new WebServerTest coverage.
  • Risk: The timing-dependent race was not reproduced manually.
  • Risk: The large formatting-focused diff increases review effort and can hide behavioral changes.

Walkthrough

WebServer synchronizes startup and shutdown, prevents binding after an early stop, closes the database during cleanup, and tracks response output before writes. Request handling and Java execution were reformatted without public API changes.

Changes

WebServer lifecycle and request handling

Layer / File(s) Summary
Lifecycle synchronization
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt, app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt
Startup and shutdown use synchronized lifecycle state. Early stop requests prevent binding. Cleanup closes the database. Tests verify binding and port release.
Request routing and rendering
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt
HTTP routing, database processing, template rendering, project queries, cursor traversal, and endpoint wiring were reformatted without behavior changes.
Responses and Java execution
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt
Project-table responses mark output before writing. Response helpers, escaping, playground processing, file creation, and Java compilation and execution retain their existing behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: hal-eisen-adfa

Poem

A rabbit guards the server door,
Start and stop now race no more.
Responses mark their writing place,
Java hops through compile space.
Clean routes follow every flow. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the WebServer startup failure caused by EADDRINUSE and matches the primary change.
Description check ✅ Passed The description directly explains the WebServer race condition, the synchronization fix, formatting changes, and validation results.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bugfix/ADFA-5035-webserver-eaddrinuse

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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 `@app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt`:
- Around line 639-645: The response-start state is updated too late, allowing
error handling to send a second response if a write fails. In WebServer.kt lines
639-645, update realHandleBsEndpoint(), and in lines 725-728, update
realHandlePrEndpoint(), to accept mutable response state and set it immediately
before the first header or body write, including before writeNormalToClient().
- Around line 149-184: Add lifecycle tests for WebServer.start and stop using a
controllable bind point: verify stop() invoked before binding prevents any
listener from remaining bound, and concurrently stopping during bind releases
the port so it can be reused. Keep the tests non-UI and deterministic by
synchronizing the bind/stop phases rather than relying on timing or sleeps.
- Around line 133-140: Update WebServer.stop() so calling it before start
remains a no-op as documented: check whether serverSocket is initialized before
setting stopRequested, and return without mutating lifecycle state when it is
not. Preserve the existing shutdown behavior for an initialized server.
- Around line 167-180: Update the startup cleanup surrounding the database
opened in the WebServer start flow to close the initialized database when
stopRequested causes the synchronized lifecycle check to return, including the
corresponding cleanup path noted near the later startup handling. Preserve
serverSocket cleanup and avoid closing an uninitialized database.
🪄 Autofix

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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 666aac53-7fe4-4a29-b0ba-560524156e3f

📥 Commits

Reviewing files that changed from the base of the PR and between dcda33c and 4ced1de.

📒 Files selected for processing (1)
  • app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt

Comment thread app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt
Comment thread app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt
Comment thread app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt
Comment thread app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt
- Close database in start()'s finally alongside serverSocket. It was
  opened before the stopRequested check that can now abort start()
  early, and was never closed on any other shutdown path either
  (normal accept-loop exit, exception) -- isInitialized guards the
  case where opening it failed and this finally still runs.
- Correct stop()'s doc comment: it's no longer a full no-op before
  start() binds -- it still records the stop request so start() can
  abort before binding, which is the fix itself. Only the socket-close
  side stays a no-op in that case. (Reverting the behavior back to a
  literal no-op, as literally suggested, would reopen the exact
  EADDRINUSE race this ticket fixes.)
- Add WebServerTest: deterministic coverage for both lifecycle
  orderings (stop-before-start aborts the bind; start-then-stop frees
  the port for reuse), synchronized via the port's own bind/connect
  behavior rather than fixed sleeps.

Skipped: the outputStarted-timing suggestion for realHandleBsEndpoint/
realHandlePrEndpoint is the same CodeRabbit finding already considered
and explicitly rejected in an existing code comment ("I disagree...
--DS, 23-Feb-2026"); out of scope to unilaterally revisit here.
outputStarted was only set after realHandleBsEndpoint/realHandlePrEndpoint
returned, so if writeNormalToClient threw partway through (e.g. after
the status line but mid-body), the catch block still saw
outputStarted=false and sent a second, well-formed response on top of
the already-partially-written one.

Pass a markOutputStarted callback into both functions and invoke it
right before the first write, so the caller's flag reflects reality
even when the write itself then fails.

Removes the "I disagree with CodeRabbit's message" comment that had
left this finding unaddressed.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt (1)

19-24: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add KDoc for the public test declarations.

The current block comments do not generate KDoc. Document WebServerTest, setup(), tearDown(), and the public test methods with concise KDoc that states their lifecycle contract.

As per coding guidelines, “Public classes, functions, and non-obvious logic must have KDoc or Javadoc documenting contracts, rationale, threading, nullability, side effects, or units.”

Also applies to: 25-43, 66-103

🤖 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 `@app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt`
around lines 19 - 24, The public declarations in WebServerTest lack KDoc.
Replace the class-level block comment and add concise KDoc to WebServerTest,
setup(), tearDown(), and each public test method, documenting their lifecycle
contracts and the relevant serialized start/stop behavior without changing test
logic.

Source: Coding guidelines

🤖 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 `@app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt`:
- Around line 95-102: Update the cleanup assertions around serverThread.join in
the WebServerTest case to verify serverThread.isAlive is false before asserting
assertPortIsFree(port). Keep the existing join and port-reuse assertion, but
explicitly fail the test if the server thread remains running after stop().
- Around line 9-13: Update WebServerTest to use JUnit Jupiter annotations and
Truth assertions instead of org.junit.After, org.junit.Before, org.junit.Test,
and org.junit.Assert methods. Add the libs.tests.junit.jupiter dependency in
app/build.gradle.kts if it is not already available, then replace the test
lifecycle annotations and assertions while preserving the existing test
behavior.

---

Nitpick comments:
In `@app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt`:
- Around line 19-24: The public declarations in WebServerTest lack KDoc. Replace
the class-level block comment and add concise KDoc to WebServerTest, setup(),
tearDown(), and each public test method, documenting their lifecycle contracts
and the relevant serialized start/stop behavior without changing test logic.
🪄 Autofix

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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0117faf8-5a85-49bd-864f-adfb311af5d9

📥 Commits

Reviewing files that changed from the base of the PR and between 4ced1de and 3b4a7c4.

📒 Files selected for processing (2)
  • app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt
  • app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt

@jatezzz

jatezzz commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

The WebServer fix is correct — I traced every start()/stop() interleaving on a single instance and none can leave a bound-but-unreachable socket. Verified locally at 3b4a7c4e3: :app:testV8DebugUnitTest --tests WebServerTest passes (2 tests), root spotlessCheck passes. I also diffed the reformat commit whitespace-insensitively and normalized the token streams — it's provably mechanical (trailing commas, braces, ${x} -> $x, sqlQuery rename, import order), so reviewers can skip it.

One gap, in MainActivity rather than in this diff: the same orphaned-listener outcome is still reachable, because stop() never runs on the instance at all.

private var webServer: WebServer? = null                    // :93

private fun startWebServer() {                              // :439
    lifecycleScope.launch(Dispatchers.IO) {
        val server = WebServer(ServerConfig(...))           // PebbleEngine build + 3x File.exists()
        webServer = server
        server.start()
    }
}

override fun onDestroy() { webServer?.stop() }              // :459 -- main thread

Two windows lifecycleLock can't close:

  • Assignment window. If the coroutine is past dispatch but hasn't reached webServer = server, onDestroy reads null, skips stop(), and the coroutine binds a moment later with stopRequested == false. Not instantaneous -- the constructor builds a PebbleEngine and stats three files. (The narrower case where the coroutine never starts executing is safe: lifecycleScope cancellation means the body never runs and no WebServer is constructed.)
  • Visibility. webServer is a non-volatile var written on Dispatchers.IO and read on the main thread with no happens-before edge, so onDestroy can read a stale null even after the write executes. This app is arm-only (v7/v8, weakly ordered) -- that's where this actually reorders, not a theoretical x86 concern.

Either path reproduces ADFA-5035's symptom identically: a listener holding 6174 with nothing able to stop it, then EADDRINUSE on the next MainActivity. So "closes the race by construction" holds for WebServer's internals but not yet for the bug as reported.

Suggested fix -- construct on the main thread, launch only start():

private fun startWebServer() {
    val server = try {
        WebServer(ServerConfig(databasePath = Environment.DOC_DB.absolutePath, ...))
    } catch (e: Exception) { log.error("Failed to create WebServer", e); return }
    webServer = server                                  // main thread, before any launch
    lifecycleScope.launch(Dispatchers.IO) {
        try { server.start() } finally { webServer = null }
    }
}

onDestroy then always has a non-null reference and stopRequested does the rest. Keeping construction off the main thread instead would need @Volatile on the field, but that fixes only the visibility half, not the assignment window.

Happy either way on sequencing -- fold it into this PR, or land this and follow up before ADFA-5035 moves to Done. Just shouldn't close the ticket on this alone.

Non-blocking nits:

  • companion object { private const val HTTP_INTERNAL_SERVER_ERROR = 500 } satisfies ktlint and keeps the SCREAMING_SNAKE constant signal, instead of camelCase instance fields.
  • stop()'s KDoc could note that stopRequested is never cleared, so the instance is single-use -- correct for current usage, but a future start-stop-start would silently no-op.
  • outputStarted = realHandle...(...) || outputStarted makes the flag monotonic; today no path calls markOutputStarted() then returns false, but the plain assignment would silently clobber the callback if one is added.
  • start()'s finally closes serverSocket outside lifecycleLock while stop() closes inside it. Harmless (ServerSocket.close() is internally synchronized), but the asymmetry invites a wrong "fix."
  • Test 1 leaks a daemon thread still holding the port if the fix ever regresses, which could cascade into unrelated failures -- a stop() in @After would contain it. And freePort() is TOCTOU (standard practice, but it's the flake vector).
  • The lifecycleLock, database.close(), and awaitPortBound comment blocks each restate their commit message; CLAUDE.md asks for the non-obvious why in a line or two, with the rest left to git history.

Pre-existing, out of scope, maybe worth a ticket: the debug-db hot-swap at WebServer.kt:337 does database.close() then reassigns, so if openDatabase throws, database stays pointing at a closed handle and every later request fails. The new finally close doesn't make it worse -- SQLiteDatabase.close() is safe twice and both run on the accept thread.

@davidschachterADFA

Copy link
Copy Markdown
Collaborator Author

Reviewed by checking out the branch in an isolated worktree and verifying, not just reading:

  • WebServerTest passes (both stop-before-start and start-then-stop cases) — confirms start()'s bind and stop()'s close are correctly serialized on lifecycleLock, closing the EADDRINUSE race.
  • spotlessCheck passes clean.
  • Diffed against stage (filtering out the reformat noise) to confirm the only structural change to start()/stop() is the lock + stopRequested flag.
  • The follow-on fixes (closing database in start()'s finally, and the outputStarted timing fix in handleBsEndpoint/handlePrEndpoint) are legitimate bugs, not scope creep — confirmed both existed in the pre-PR file.
  • No behavior regressions found; stopRequested never resetting is fine given MainActivity always constructs a fresh WebServer per start/stop cycle.

LGTM (self-review — GitHub won't let me approve my own PR).

@appdevforall appdevforall deleted a comment from coderabbitai Bot Aug 7, 2026
@davidschachterADFA
davidschachterADFA merged commit 0019903 into stage Aug 11, 2026
4 checks passed
@davidschachterADFA
davidschachterADFA deleted the bugfix/ADFA-5035-webserver-eaddrinuse branch August 11, 2026 13:40
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.

2 participants