Skip to content

ADFA-2807 Webserver endpoint pr/ex returns 200 if experimental mode is on, 404 if not#976

Merged
hal-eisen-adfa merged 10 commits intostagefrom
fix/ADFA-2807
Feb 13, 2026
Merged

ADFA-2807 Webserver endpoint pr/ex returns 200 if experimental mode is on, 404 if not#976
hal-eisen-adfa merged 10 commits intostagefrom
fix/ADFA-2807

Conversation

@davidschachterADFA
Copy link
Collaborator

Added endpoint, pr/ex, to return 200 if experimental mode is on, 404 if not. Moved the db and pr endpoints to pr/db and pr/pr. Added more debug messages. Made a few cleanups to get rid of warnings and use idiomatic Kotlin.

…s on, 404 if not. Moved the db and pr endpoints to pr/db and pr/pr. Added more debug messages. Made a few cleanups to get rid of warnings and use idiomatic Kotlin.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 13, 2026

📝 Walkthrough

Release Notes: WebServer Endpoint Updates (PR #976)

Features

  • New experimental mode endpoint: Added pr/ex endpoint that returns HTTP 200 when experimental mode is enabled (indicated by existence of a flag file), HTTP 404 when disabled
  • Public API addition: New experimentsEnablePath property added to ServerConfig data class for centralized experimental feature flag configuration
  • Endpoint reorganization: Existing webserver endpoints refactored under /pr/ namespace:
    • /db/pr/db (database debug endpoint)
    • /pr/pr/pr (recent projects endpoint)
    • New /pr/ex (experimental mode check)

Improvements

  • Database cursor management: Added proper try/finally blocks ensuring cursors are always closed, reducing potential resource leaks
  • Error handling enhancements: Improved exception handling in database operations with explicit error logging (changed from debug to error level for failures)
  • Comprehensive debug logging: Added entry/exit debug traces throughout request handling flow for troubleshooting
  • XSS vulnerability mitigation: Implemented HTML escaping function with proper character entity encoding (&, <, >, ", ')
  • Brotli decompression: Enhanced compression handling with explicit error codes for failed decompression attempts
  • Code cleanup: Removed warnings and adopted more idiomatic Kotlin patterns

⚠️ Risks & Best Practices Violations

Configuration Hardcoding

  • Experimental mode flag file path hardcoded in ServerConfig (/Download/CodeOnTheGo.exp) rather than centralized configuration (noted in TODO comment in code)
  • Debug flag path similarly hardcoded to /Download/CodeOnTheGo.webserver.debug
  • Recommend implementing centralized feature flag system across codebase

Code Quality Issues

  • Explicit "hack code" comment in ServerConfig indicates technical debt in database path configuration
  • Mixed responsibilities in handleClient() method (routing, compression handling, error responses)
  • Magic number 1024 * 1024 hardcoded for fragment size without constants or configuration
  • Fragment path construction ("$path-$fragmentNumber") lacks input validation, potential for unexpected database queries

Performance Concerns

  • Large database content concatenation uses dbContent += dbContent2 which may result in multiple byte array copies; consider ByteArrayOutputStream for better performance
  • Silent database timestamp checks (getDatabaseTimestamp(..., silent=true)) may mask issues during initialization

Error Handling Gaps

  • Brotli decompression errors return generic 500 error without detailed context for debugging
  • Fragment retrieval failures log and break silently rather than returning explicit error to client
  • Catch-all exception handlers may mask specific failure modes

Testing & Database Lifecycle

  • New pr/ex endpoint requires test coverage for both enabled and disabled experimental mode states
  • Repeated try/finally cursor patterns indicate need for comprehensive cursor lifecycle testing
  • Fragment loading logic needs thorough edge case testing (boundary conditions, missing fragments)
  • Potential cursor leak discovered and addressed (per commit message)

Configuration Notes

  • ServerConfig.experimentsEnablePath defaults to: ${External Storage}/Download/CodeOnTheGo.exp
  • Server socket now uses property assignment (reuseAddress = true) instead of method call

Walkthrough

Adds public experimentsEnablePath to ServerConfig; consolidates /pr routing via startsWith("pr/") and adds handleExEndpoint; tightens cursor/fragment lifecycle with try/finally; standardizes HTTP headers, error responses, and logging across server request handling.

Changes

Cohort / File(s) Summary
WebServer core & API
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt
Exposed ServerConfig.experimentsEnablePath; replaced socket setReuseAddress(true) call with reuseAddress property assignment; added debug traces around start/stop flow.
Routing & endpoints
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt
Consolidated /pr routing to startsWith("pr/"); introduced handleExEndpoint; unified dispatch to pr/db, pr/pr, pr/ex, or 404/500.
DB access, fragments & resource safety
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt
Replaced deprecated cursor patterns with count checks; added try/finally to ensure cursor.close() for main and per-fragment queries; guarded single-row expectations and moved data extraction into safe blocks.
Compression & response handling
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt
Updated Brotli handling (set "br" when supported; robust local decompression fallback); consolidated header construction and Content-Length calculation; centralized sendError with composite messages and consistent multi-line responses.
HTML generation & logging
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt
Refactored HTML assembly for /db and /pr, added debug entry/exit logs, explicit closing tags, and safer header building for tables.
Parsing & misc fixes
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt
Switched header parsing to isNotEmpty() checks; minor formatting and log-level adjustments for accept/close failures and exception paths.

Sequence Diagram

sequenceDiagram
    participant Client
    participant WebServer
    participant DB
    participant FileSystem

    Client->>WebServer: HTTP GET /pr/...
    WebServer->>WebServer: route via startsWith("pr/")
    alt pr/db or pr/pr
        WebServer->>DB: query table (use cursor.count)
        DB-->>WebServer: rows/result
        WebServer->>WebServer: build HTML table, close cursors (finally)
        WebServer-->>Client: HTTP 200 + HTML
    else pr/ex
        WebServer->>FileSystem: check experimentsEnablePath exists
        FileSystem-->>WebServer: exists / not exists
        alt exists
            WebServer-->>Client: HTTP 200 + experiments HTML
        else
            WebServer-->>Client: HTTP 404 (experiments disabled)
        end
    else unknown pr/* variant
        WebServer-->>Client: HTTP 500 (internal error code)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • hal-eisen-adfa

Poem

🐰 I hopped through handlers, tidy and spry,
Routed pr paths beneath a starlit sky,
A tiny flag lets experiments play,
Cursors closed safe at the end of day,
Hop, log, respond — then nibble on hay.

🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (1 file):

⚔️ app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt (content)

These conflicts must be resolved before merging into stage.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: adding a new pr/ex endpoint with conditional status codes based on experimental mode.
Description check ✅ Passed The description is directly related to the changeset, mentioning the pr/ex endpoint addition, endpoint path migrations, debug messages, and code cleanups that align with the changes.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/ADFA-2807
⚔️ Resolve merge conflicts (beta)
  • Auto-commit resolved conflicts to branch fix/ADFA-2807
  • Create stacked PR with resolved conflicts
  • Post resolved changes as copyable diffs in a comment

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt (1)

441-443: Dead code: openDatabase never returns null — it throws on failure.

SQLiteDatabase.openDatabase(...) throws on error, so projectDatabase is never null after line 437. The catch at line 449 already handles that path. This branch is unreachable.

Proposed simplification
         try {
             projectDatabase = SQLiteDatabase.openDatabase(config.projectDatabasePath,
                                                           null,
                                                           SQLiteDatabase.OPEN_READONLY)
-
-            if (projectDatabase == null) {
-                log.error("Error handling /pr/pr endpoint 2. Could not open ${config.projectDatabasePath}.")
-                sendError(writer, 500, "Internal Server Error 5", "Error accessing database 2")
-
-            } else {
-                realHandlePrEndpoint(writer, output, projectDatabase)
-            }
+            realHandlePrEndpoint(writer, output, projectDatabase)

         } catch (e: Exception) {

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
Copy link
Contributor

coderabbitai bot commented Feb 13, 2026

Note

Unit test generation is a public access feature. Expect some limitations and changes as we gather feedback and continue to improve it.


Generating unit tests... This may take up to 20 minutes.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 13, 2026

Copyable Unit Test Edits

Copyable Edits

app/src/test/java/org/appdevforall/localwebserver/WebServerTest.kt

This is a new file.

/*
 *  This file is part of AndroidIDE.
 *
 *  AndroidIDE is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  AndroidIDE is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *   along with AndroidIDE.  If not, see <https://www.gnu.org/licenses/>.
 */

package org.appdevforall.localwebserver

import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import com.google.common.truth.Truth.assertThat
import io.mockk.*
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.PrintWriter
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketException
import java.nio.file.Files

/**
 * Comprehensive test suite for WebServer class.
 * Tests cover main functionality, edge cases, error handling, and security features.
 */
`@RunWith`(RobolectricTestRunner::class)
class WebServerTest {

    private lateinit var tempDir: File
    private lateinit var testDbPath: String
    private lateinit var config: ServerConfig

    `@Before`
    fun setup() {
        // Create temporary directory for test files
        tempDir = Files.createTempDirectory("webserver_test").toFile()
        testDbPath = File(tempDir, "test.db").absolutePath

        config = ServerConfig(
            port = 0, // Use random port for testing
            databasePath = testDbPath,
            bindName = "localhost",
            debugDatabasePath = File(tempDir, "debug.db").absolutePath,
            debugEnablePath = File(tempDir, "debug.enable").absolutePath,
            experimentsEnablePath = File(tempDir, "experiments.enable").absolutePath,
            projectDatabasePath = File(tempDir, "projects.db").absolutePath
        )

        MockKAnnotations.init(this)
    }

    `@After`
    fun teardown() {
        // Clean up temporary files
        tempDir.deleteRecursively()
        unmockkAll()
    }

    `@Test`
    fun `test getDatabaseTimestamp with existing file`() {
        // Create a test file
        val testFile = File(tempDir, "test_timestamp.db")
        testFile.writeText("test content")
        val expectedTimestamp = testFile.lastModified()

        val webServer = WebServer(config)
        val timestamp = webServer.getDatabaseTimestamp(testFile.absolutePath, silent = true)

        assertThat(timestamp).isEqualTo(expectedTimestamp)
        assertThat(timestamp).isGreaterThan(0)
    }

    `@Test`
    fun `test getDatabaseTimestamp with non-existing file`() {
        val nonExistentPath = File(tempDir, "non_existent.db").absolutePath

        val webServer = WebServer(config)
        val timestamp = webServer.getDatabaseTimestamp(nonExistentPath, silent = true)

        assertThat(timestamp).isEqualTo(-1)
    }

    `@Test`
    fun `test getDatabaseTimestamp with silent false logs timestamp`() {
        val testFile = File(tempDir, "test_log.db")
        testFile.writeText("test content")

        val webServer = WebServer(config)
        // Should not throw exception when silent = false
        val timestamp = webServer.getDatabaseTimestamp(testFile.absolutePath, silent = false)

        assertThat(timestamp).isGreaterThan(0)
    }

    `@Test`
    fun `test stop on uninitialized server does not throw`() {
        val webServer = WebServer(config)

        // Should not throw exception
        webServer.stop()
    }

    `@Test`
    fun `test stop closes server socket`() {
        mockkConstructor(ServerSocket::class)
        every { anyConstructed<ServerSocket>().setReuseAddress(any()) } just Runs
        every { anyConstructed<ServerSocket>().bind(any()) } just Runs
        every { anyConstructed<ServerSocket>().close() } just Runs

        val webServer = WebServer(config)

        // Manually initialize serverSocket using reflection to test stop()
        val serverSocketField = WebServer::class.java.getDeclaredField("serverSocket")
        serverSocketField.isAccessible = true
        val mockServerSocket = mockk<ServerSocket>(relaxed = true)
        serverSocketField.set(webServer, mockServerSocket)

        webServer.stop()

        verify { mockServerSocket.close() }
    }

    `@Test`
    fun `test logDatabaseLastChanged with valid database`() {
        val mockDatabase = mockk<SQLiteDatabase>()
        val mockCursor = mockk<Cursor>()

        every { mockDatabase.rawQuery(any(), any()) } returns mockCursor
        every { mockCursor.count } returns 1
        every { mockCursor.moveToFirst() } returns true
        every { mockCursor.getString(0) } returns "2024-02-13 10:00:00"
        every { mockCursor.getString(1) } returns "admin"
        every { mockCursor.close() } just Runs

        val webServer = WebServer(config)

        // Use reflection to set the database field
        val databaseField = WebServer::class.java.getDeclaredField("database")
        databaseField.isAccessible = true
        databaseField.set(webServer, mockDatabase)

        // Should not throw exception
        webServer.logDatabaseLastChanged()

        verify { mockCursor.close() }
    }

    `@Test`
    fun `test logDatabaseLastChanged with no records`() {
        val mockDatabase = mockk<SQLiteDatabase>()
        val mockCursor = mockk<Cursor>()

        every { mockDatabase.rawQuery(any(), any()) } returns mockCursor
        every { mockCursor.count } returns 0
        every { mockCursor.close() } just Runs

        val webServer = WebServer(config)

        val databaseField = WebServer::class.java.getDeclaredField("database")
        databaseField.isAccessible = true
        databaseField.set(webServer, mockDatabase)

        // Should not throw exception
        webServer.logDatabaseLastChanged()

        verify { mockCursor.close() }
    }

    `@Test`
    fun `test logDatabaseLastChanged handles exception gracefully`() {
        val mockDatabase = mockk<SQLiteDatabase>()

        every { mockDatabase.rawQuery(any(), any()) } throws Exception("Database error")

        val webServer = WebServer(config)

        val databaseField = WebServer::class.java.getDeclaredField("database")
        databaseField.isAccessible = true
        databaseField.set(webServer, mockDatabase)

        // Should not throw exception, just log it
        webServer.logDatabaseLastChanged()
    }

    `@Test`
    fun `test escapeHtml escapes special characters`() {
        val webServer = WebServer(config)

        // Use reflection to access private escapeHtml method
        val escapeHtmlMethod = WebServer::class.java.getDeclaredMethod("escapeHtml", String::class.java)
        escapeHtmlMethod.isAccessible = true

        val testCases = mapOf(
            "<script>alert('xss')</script>" to "&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;",
            "Hello & goodbye" to "Hello &amp; goodbye",
            "\"quoted\"" to "&quot;quoted&quot;",
            "<div class=\"test\">" to "&lt;div class=&quot;test&quot;&gt;",
            "normal text" to "normal text"
        )

        testCases.forEach { (input, expected) ->
            val result = escapeHtmlMethod.invoke(webServer, input) as String
            assertThat(result).isEqualTo(expected)
        }
    }

    `@Test`
    fun `test escapeHtml handles ampersand first to prevent double-escaping`() {
        val webServer = WebServer(config)

        val escapeHtmlMethod = WebServer::class.java.getDeclaredMethod("escapeHtml", String::class.java)
        escapeHtmlMethod.isAccessible = true

        // Test that & is replaced first to avoid double-escaping
        val result = escapeHtmlMethod.invoke(webServer, "&lt;") as String
        assertThat(result).isEqualTo("&amp;lt;")
    }

    `@Test`
    fun `test sendError generates correct HTTP error response`() {
        val webServer = WebServer(config)
        val outputStream = ByteArrayOutputStream()
        val writer = PrintWriter(outputStream, true)

        val sendErrorMethod = WebServer::class.java.getDeclaredMethod(
            "sendError",
            PrintWriter::class.java,
            Int::class.java,
            String::class.java,
            String::class.java
        )
        sendErrorMethod.isAccessible = true

        sendErrorMethod.invoke(webServer, writer, 404, "Not Found", "Page not found")

        val response = outputStream.toString()
        assertThat(response).contains("HTTP/1.1 404 Not Found")
        assertThat(response).contains("Content-Type: text/plain")
        assertThat(response).contains("Connection: close")
        assertThat(response).contains("404 Not Found")
        assertThat(response).contains("Page not found")
    }

    `@Test`
    fun `test sendError with empty details`() {
        val webServer = WebServer(config)
        val outputStream = ByteArrayOutputStream()
        val writer = PrintWriter(outputStream, true)

        val sendErrorMethod = WebServer::class.java.getDeclaredMethod(
            "sendError",
            PrintWriter::class.java,
            Int::class.java,
            String::class.java,
            String::class.java
        )
        sendErrorMethod.isAccessible = true

        sendErrorMethod.invoke(webServer, writer, 500, "Internal Server Error", "")

        val response = outputStream.toString()
        assertThat(response).contains("HTTP/1.1 500 Internal Server Error")
        assertThat(response).contains("500 Internal Server Error")
        assertThat(response).doesNotContain("\n\n\n") // Should not have extra newline for details
    }

    `@Test`
    fun `test getTableHtml generates valid HTML structure`() {
        val webServer = WebServer(config)

        val getTableHtmlMethod = WebServer::class.java.getDeclaredMethod(
            "getTableHtml",
            String::class.java,
            String::class.java
        )
        getTableHtmlMethod.isAccessible = true

        val result = getTableHtmlMethod.invoke(webServer, "Test Title", "Test Table") as String

        assertThat(result).contains("<!DOCTYPE html>")
        assertThat(result).contains("<html>")
        assertThat(result).contains("<head>")
        assertThat(result).contains("<title>Test Title</title>")
        assertThat(result).contains("<style>")
        assertThat(result).contains("border-collapse: collapse")
        assertThat(result).contains("<h1>Test Table</h1>")
        assertThat(result).contains("<table width='100%'>")
    }

    `@Test`
    fun `test getTableHtml escapes title and table name`() {
        val webServer = WebServer(config)

        val getTableHtmlMethod = WebServer::class.java.getDeclaredMethod(
            "getTableHtml",
            String::class.java,
            String::class.java
        )
        getTableHtmlMethod.isAccessible = true

        val result = getTableHtmlMethod.invoke(
            webServer,
            "<script>alert('xss')</script>",
            "<b>Bold</b>"
        ) as String

        assertThat(result).contains("&lt;script&gt;")
        assertThat(result).contains("&lt;b&gt;Bold&lt;/b&gt;")
        assertThat(result).doesNotContain("<script>")
        assertThat(result).doesNotContain("<b>Bold</b>")
    }

    `@Test`
    fun `test writeNormalToClient writes correct HTTP response`() {
        val webServer = WebServer(config)
        val outputStream = ByteArrayOutputStream()
        val writer = PrintWriter(outputStream, true)
        val htmlContent = "<html><body>Test Content</body></html>"

        val writeNormalMethod = WebServer::class.java.getDeclaredMethod(
            "writeNormalToClient",
            PrintWriter::class.java,
            java.io.OutputStream::class.java,
            String::class.java
        )
        writeNormalMethod.isAccessible = true

        writeNormalMethod.invoke(webServer, writer, outputStream, htmlContent)

        val response = outputStream.toString()
        assertThat(response).contains("HTTP/1.1 200 OK")
        assertThat(response).contains("Content-Type: text/html; charset=utf-8")
        assertThat(response).contains("Content-Length: ${htmlContent.toByteArray(Charsets.UTF_8).size}")
        assertThat(response).contains("Connection: close")
        assertThat(response).contains(htmlContent)
    }

    `@Test`
    fun `test ServerConfig default values`() {
        val config = ServerConfig(
            port = 6174,
            databasePath = "/test/path"
        )

        assertThat(config.port).isEqualTo(6174)
        assertThat(config.databasePath).isEqualTo("/test/path")
        assertThat(config.bindName).isEqualTo("localhost")
    }

    `@Test`
    fun `test ServerConfig custom values`() {
        val customConfig = ServerConfig(
            port = 8080,
            databasePath = "/custom/db.db",
            bindName = "0.0.0.0",
            debugDatabasePath = "/debug/db.db",
            debugEnablePath = "/debug/enable",
            experimentsEnablePath = "/experiments/enable",
            projectDatabasePath = "/projects/db.db"
        )

        assertThat(customConfig.port).isEqualTo(8080)
        assertThat(customConfig.databasePath).isEqualTo("/custom/db.db")
        assertThat(customConfig.bindName).isEqualTo("0.0.0.0")
        assertThat(customConfig.debugDatabasePath).isEqualTo("/debug/db.db")
        assertThat(customConfig.debugEnablePath).isEqualTo("/debug/enable")
        assertThat(customConfig.experimentsEnablePath).isEqualTo("/experiments/enable")
        assertThat(customConfig.projectDatabasePath).isEqualTo("/projects/db.db")
    }

    `@Test`
    fun `test getDatabaseTimestamp with different timestamp values`() {
        val webServer = WebServer(config)

        // Create file with specific timestamp
        val testFile = File(tempDir, "timestamp_test.db")
        testFile.writeText("content")
        val originalTimestamp = testFile.lastModified()

        val timestamp1 = webServer.getDatabaseTimestamp(testFile.absolutePath, silent = true)
        assertThat(timestamp1).isEqualTo(originalTimestamp)

        // Modify file
        Thread.sleep(10) // Ensure timestamp changes
        testFile.appendText(" more")
        val newTimestamp = testFile.lastModified()

        val timestamp2 = webServer.getDatabaseTimestamp(testFile.absolutePath, silent = true)
        assertThat(timestamp2).isEqualTo(newTimestamp)
        assertThat(timestamp2).isGreaterThan(timestamp1)
    }

    `@Test`
    fun `test stop handles exception when closing socket`() {
        val webServer = WebServer(config)

        // Use reflection to set a mock socket that throws on close
        val serverSocketField = WebServer::class.java.getDeclaredField("serverSocket")
        serverSocketField.isAccessible = true
        val mockServerSocket = mockk<ServerSocket>()
        every { mockServerSocket.close() } throws SocketException("Socket closed")
        serverSocketField.set(webServer, mockServerSocket)

        // Should not throw exception, just log it
        webServer.stop()
    }

    `@Test`
    fun `test escapeHtml with empty string`() {
        val webServer = WebServer(config)

        val escapeHtmlMethod = WebServer::class.java.getDeclaredMethod("escapeHtml", String::class.java)
        escapeHtmlMethod.isAccessible = true

        val result = escapeHtmlMethod.invoke(webServer, "") as String
        assertThat(result).isEmpty()
    }

    `@Test`
    fun `test escapeHtml with only special characters`() {
        val webServer = WebServer(config)

        val escapeHtmlMethod = WebServer::class.java.getDeclaredMethod("escapeHtml", String::class.java)
        escapeHtmlMethod.isAccessible = true

        val result = escapeHtmlMethod.invoke(webServer, "<>&\"'") as String
        assertThat(result).isEqualTo("&lt;&gt;&amp;&quot;&#x27;")
    }

    `@Test`
    fun `test sendError with various error codes`() {
        val webServer = WebServer(config)

        val errorCodes = mapOf(
            400 to "Bad Request",
            401 to "Unauthorized",
            403 to "Forbidden",
            404 to "Not Found",
            500 to "Internal Server Error",
            501 to "Not Implemented",
            503 to "Service Unavailable"
        )

        val sendErrorMethod = WebServer::class.java.getDeclaredMethod(
            "sendError",
            PrintWriter::class.java,
            Int::class.java,
            String::class.java,
            String::class.java
        )
        sendErrorMethod.isAccessible = true

        errorCodes.forEach { (code, message) ->
            val outputStream = ByteArrayOutputStream()
            val writer = PrintWriter(outputStream, true)

            sendErrorMethod.invoke(webServer, writer, code, message, "")

            val response = outputStream.toString()
            assertThat(response).contains("HTTP/1.1 $code $message")
        }
    }

    `@Test`
    fun `test writeNormalToClient with UTF-8 content`() {
        val webServer = WebServer(config)
        val outputStream = ByteArrayOutputStream()
        val writer = PrintWriter(outputStream, true)
        val htmlContent = "<html><body>测试 テスト 🚀</body></html>"

        val writeNormalMethod = WebServer::class.java.getDeclaredMethod(
            "writeNormalToClient",
            PrintWriter::class.java,
            java.io.OutputStream::class.java,
            String::class.java
        )
        writeNormalMethod.isAccessible = true

        writeNormalMethod.invoke(webServer, writer, outputStream, htmlContent)

        val response = outputStream.toString()
        assertThat(response).contains(htmlContent)
        assertThat(response).contains("charset=utf-8")
    }

    `@Test`
    fun `test writeNormalToClient calculates correct content length`() {
        val webServer = WebServer(config)
        val outputStream = ByteArrayOutputStream()
        val writer = PrintWriter(outputStream, true)
        val htmlContent = "Test with émojis: 😀"
        val expectedLength = htmlContent.toByteArray(Charsets.UTF_8).size

        val writeNormalMethod = WebServer::class.java.getDeclaredMethod(
            "writeNormalToClient",
            PrintWriter::class.java,
            java.io.OutputStream::class.java,
            String::class.java
        )
        writeNormalMethod.isAccessible = true

        writeNormalMethod.invoke(webServer, writer, outputStream, htmlContent)

        val response = outputStream.toString()
        assertThat(response).contains("Content-Length: $expectedLength")
    }

    `@Test`
    fun `test getDatabaseTimestamp boundary conditions`() {
        val webServer = WebServer(config)

        // Test with very new file (timestamp close to current time)
        val newFile = File(tempDir, "new_file.db")
        newFile.writeText("new")
        val currentTime = System.currentTimeMillis()
        val timestamp = webServer.getDatabaseTimestamp(newFile.absolutePath, silent = true)

        // Timestamp should be very close to current time (within 1 second)
        assertThat(Math.abs(currentTime - timestamp)).isLessThan(1000)
    }

    `@Test`
    fun `test escapeHtml preserves order of replacements`() {
        val webServer = WebServer(config)

        val escapeHtmlMethod = WebServer::class.java.getDeclaredMethod("escapeHtml", String::class.java)
        escapeHtmlMethod.isAccessible = true

        // This tests that & is replaced first, preventing double-escaping
        val input = "&<>\"'"
        val result = escapeHtmlMethod.invoke(webServer, input) as String

        // If & was replaced first, we get the correct result
        assertThat(result).isEqualTo("&amp;&lt;&gt;&quot;&#x27;")
        // If & was replaced after <, we would get &amp;lt; instead of &lt;
    }

    `@Test`
    fun `test sendError includes content length`() {
        val webServer = WebServer(config)
        val outputStream = ByteArrayOutputStream()
        val writer = PrintWriter(outputStream, true)
        val details = "Detailed error message"

        val sendErrorMethod = WebServer::class.java.getDeclaredMethod(
            "sendError",
            PrintWriter::class.java,
            Int::class.java,
            String::class.java,
            String::class.java
        )
        sendErrorMethod.isAccessible = true

        sendErrorMethod.invoke(webServer, writer, 404, "Not Found", details)

        val response = outputStream.toString()
        assertThat(response).contains("Content-Length:")
    }
}

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt`:
- Around line 228-236: The cursor returned by database.rawQuery(...) is not
closed on the early-return paths after checking rowCount, causing a resource
leak; update the logic in the method containing the cursor (the block using
variables cursor and rowCount and calling sendError) to ensure cursor.close() is
always executed before any return on error (or better, wrap usage in cursor.use
{ ... } or a try/finally) so that the normal-path close at the end is not the
only cleanup; ensure both the 0 and >1 branches call cursor.close() (or are
inside a cursor.use) before invoking sendError.
- Line 473: The debug exit log contains a copy-paste typo: change the message in
the debug call at the end of realHandlePrEndpoint so it reads "Leaving
realHandlePrEndpoint()." instead of "Leaving realHandleDbEndpoint()." — locate
the debugEnabled check and its log.debug(...) invocation inside the
realHandlePrEndpoint function and update the string accordingly.
- Around line 531-542: sendError currently computes Content-Length using
String.length and relies on PrintWriter.println which can add platform-dependent
line endings; change sendError(signature) to accept the client's OutputStream in
addition to the PrintWriter, build the response body as a UTF-8 byte array (e.g.
val bodyBytes = ("$code $message" + if (details.isEmpty()) "" else
"\n$details").toByteArray(Charsets.UTF_8)), set Content-Length = bodyBytes.size,
write the HTTP response headers using the writer but terminate header section
with a single CRLF (explicit "\r\n\r\n" or writer.println() then
writer.flush()), flush the writer, and then write bodyBytes directly to the
OutputStream (and flush) instead of using PrintWriter.println so the byte count
and line endings are exact; mirror the pattern used in writeNormalToClient.
- Around line 263-269: cursor2.moveToFirst() may return false for missing
fragment paths causing cursor2.getBlob(0) to throw; update the block that
queries with query2 so you check for row existence (e.g., test
cursor2.moveToFirst() or cursor2.count > 0) before calling getBlob(0) and set
dbContent2 to a safe default (null or empty ByteArray) or handle the missing-row
case appropriately; ensure you keep the existing try/finally that closes cursor2
and reference the same symbols (cursor2, query2, moveToFirst, getBlob,
dbContent2) when making the change.
- Line 218: The else branch handling unknown "pr/" sub-paths in WebServer.kt
currently calls sendError(writer, 500, ...), which should be a 404; update the
handler (the branch in the request dispatch logic that matches "pr/" paths—the
else case) to call sendError(writer, 404, "Not Found") (or similar 404 message)
instead of sendError(..., 500, ...), ensuring sendError usage remains consistent
with other handlers and keeping the response content unchanged aside from the
status code and message.
🧹 Nitpick comments (3)
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt (3)

192-198: parts.isEmpty() is always false — dead code branch.

String.split(",") in Kotlin always returns at least one element (even for an empty string it returns [""]). The break on line 194 is unreachable. This shadowed parts variable will never be empty.

Consider removing the dead branch or replacing it with a more meaningful guard (e.g., checking if the single element is blank).


372-373: Exception details exposed to client in error response.

e.message is forwarded verbatim to the HTTP response, which could leak internal paths, SQL errors, or other sensitive details to the client. Even on localhost, consider logging the details server-side only and returning a generic message to the client.

Proposed fix
-            sendError(writer, 500, "Internal Server Error 4", "Error generating database table: ${e.message}")
+            sendError(writer, 500, "Internal Server Error 4")

The same pattern applies to lines 407, 415 (handlePrEndpoint), and line 234 in the main handler.


521-521: Debug logging in escapeHtml is extremely noisy.

This fires for every table cell (140+ times for a 20-row × 7-column table) plus titles. Consider removing this log or gating it behind a more granular verbosity flag.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt (1)

512-520: ⚠️ Potential issue | 🟠 Major

Content-Length mismatch due to extra newline from println.

The triple-quoted string already ends with a blank line (the \n before the closing """), which forms the required header-body separator. Then println appends an additional platform line separator. This extra byte is written to the output stream before htmlBytes, but Content-Length only accounts for htmlBytes.size. Clients may truncate or misparse the response.

Use print instead of println, or use writer.print("...\r\n\r\n") explicitly for the header terminator:

Proposed fix
-        writer.println("""HTTP/1.1 200 OK
-Content-Type: text/html; charset=utf-8
-Content-Length: ${htmlBytes.size}
-Connection: close
-""")
+        writer.print("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: ${htmlBytes.size}\r\nConnection: close\r\n\r\n")
         writer.flush()
🤖 Fix all issues with AI agents
In `@app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt`:
- Around line 264-272: In WebServer.kt remove the redundant unconditional call
to cursor2.moveToFirst() inside the try block so only the conditional if
(cursor2.moveToFirst()) is used to check for rows; update the block around
cursor2, dbContent2 and path2 (the fragment-fetch logic) to rely on the single
conditional check and keep the existing debugEnabled/log.error and break
behavior unchanged.
🧹 Nitpick comments (1)
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt (1)

186-198: parts.isEmpty() check on line 193 is unreachable.

String.split(",") in Kotlin always returns a list with at least one element. This if branch is dead code. Not harmful, but worth noting for clarity.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (4)
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt (4)

328-330: ⚠️ Potential issue | 🟡 Minor

Redundant cursor.close() — already closed in the finally block at line 254.

The try/finally at lines 233–255 guarantees the cursor is closed. This second call at line 330 is dead code and should be removed to avoid confusion.

Proposed fix
     output.write(dbContent)
     output.flush()
-    cursor.close()
 }

336-392: ⚠️ Potential issue | 🟠 Major

Cursor resource leak in handleDbEndpointschemaCursor and dataCursor lack try/finally cleanup.

If an exception is thrown after either cursor is opened but before its .close() call (e.g., during column iteration or HTML building), the cursor leaks. The outer catch doesn't close them. This was fixed elsewhere in this PR (e.g., cursor2 at line 276); apply the same pattern here.

Proposed fix (sketch)
         val schemaCursor = database.rawQuery(schemaQuery, arrayOf())
-        val columnCount = schemaCursor.count
-        val columnNames = mutableListOf<String>()
-        
-        while (schemaCursor.moveToNext()) {
-            if (debugEnabled) log.debug("columnNames are {}.", columnNames)
-
-            columnNames.add(schemaCursor.getString(1)) // Column name is at index 1
-        }
-        schemaCursor.close()
+        val columnNames: List<String>
+        val columnCount: Int
+        try {
+            columnCount = schemaCursor.count
+            val names = mutableListOf<String>()
+            while (schemaCursor.moveToNext()) {
+                if (debugEnabled) log.debug("columnNames are {}.", names)
+                names.add(schemaCursor.getString(1))
+            }
+            columnNames = names
+        } finally {
+            schemaCursor.close()
+        }

Apply similarly around dataCursor.


518-531: ⚠️ Potential issue | 🟠 Major

Content-Length mismatch — println adds an extra line ending not accounted for in the body size.

The raw string template already ends with a blank line (the empty line before """), which produces the header-body separator (\n\n). Then writer.println(...) appends an additional \n. This extra byte is sent between the header terminator and output.write(htmlBytes), but Content-Length only counts htmlBytes.size. Clients will consider the extra newline as part of the body and truncate the last byte of htmlBytes.

Use writer.print(...) instead of writer.println(...), or remove the trailing blank line from the raw string.

Proposed fix
-        writer.println("""HTTP/1.1 200 OK
+        writer.print("""HTTP/1.1 200 OK
 Content-Type: text/html; charset=utf-8
 Content-Length: ${htmlBytes.size}
 Connection: close
-""")
+
+""")

456-485: ⚠️ Potential issue | 🟠 Major

cursor in realHandlePrEndpoint is not wrapped in try/finally.

Same resource leak pattern as handleDbEndpoint — if an exception occurs during row iteration (lines 471–481), the cursor at line 456 is never closed.

Proposed fix (sketch)
     val cursor = projectDatabase.rawQuery(query, arrayOf())
+    try {
 
         if (debugEnabled) log.debug("Retrieved {} rows.", cursor.count)
         ...
         // existing row iteration
         ...
-    cursor.close()
+    } finally {
+        cursor.close()
+    }
🧹 Nitpick comments (2)
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt (2)

186-197: Header parsing breaks early after finding Accept-Encoding.

This means if Accept-Encoding appears before other headers, the rest are unread. Currently fine since only GET is supported and the connection is closed after each request, but worth noting if you later need to parse additional headers.


343-347: Debug log inside the loop logs incomplete column list each iteration.

Line 344 logs columnNames before the current name is added, and repeats on every iteration. The final state is already logged at line 350. Consider removing the in-loop log or moving it after the add.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (2)
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt (2)

478-507: ⚠️ Potential issue | 🟠 Major

Resource leak: cursor in realHandlePrEndpoint is not closed in a finally block.

Every other cursor in this PR was moved into try/finally (lines 233–254, 276–287, 345–373, 380–398), but this one still uses a bare cursor.close() at line 507. Any exception thrown during iteration (lines 493–503) or HTML building will skip the close.

Proposed fix — wrap in try/finally
         val cursor = projectDatabase.rawQuery(query, arrayOf())
 
-        if (debugEnabled) log.debug("Retrieved {} rows.", cursor.count)
-
-        var html = getTableHtml("Projects", "Projects") + """
+        try {
+            if (debugEnabled) log.debug("Retrieved {} rows.", cursor.count)
+
+            var html = getTableHtml("Projects", "Projects") + """
 <tr>
 <th>Id</th>
 ...
 </tr>"""
 
-        while (cursor.moveToNext()) {
-            html += """<tr>
+            while (cursor.moveToNext()) {
+                html += """<tr>
 ...
 </tr>"""
-        }
+            }
 
-        html += "</table></body></html>"
+            html += "</table></body></html>"
 
-        cursor.close()
+        } finally {
+            cursor.close()
+        }

545-553: ⚠️ Potential issue | 🟠 Major

writer.println adds an extra line separator after the header block, causing a Content-Length mismatch.

The raw string template already ends with a blank line (the required HTTP header/body separator). println then appends an additional platform-dependent line separator. This extra byte(s) sits between headers and the actual body, so an HTTP client counting Content-Length bytes from the start of the body will mis-frame the response.

Use print + explicit separator, or write the headers as bytes, matching the pattern you'd use in sendError:

Proposed fix
-        writer.println("""HTTP/1.1 200 OK
+        writer.print("""HTTP/1.1 200 OK
 Content-Type: text/html; charset=utf-8
 Content-Length: ${htmlBytes.size}
 Connection: close
 
 """)
🧹 Nitpick comments (2)
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt (2)

280-283: Missing-fragment error is suppressed when debug is off.

log.error on line 281 is guarded by debugEnabled, so a missing fragment (likely a data-integrity issue) is silently swallowed in production. Consider logging this unconditionally since it signals corrupt or incomplete data, unlike the surrounding debug traces.

-                    } else {
-                        if (debugEnabled) log.error("No fragment found for path '{}'.", path2)
+                    } else {
+                        log.error("No fragment found for path '{}'.", path2)

540-541: Debug logs dump entire HTML response bodies.

Lines 400, 509, and 541 log the full HTML string, which can be very large (e.g., all rows of a DB table). Even guarded by debugEnabled, this can flood logs and cause GC pressure. Consider logging only the length, or truncating.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (1)
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt (1)

63-78: ⚠️ Potential issue | 🟡 Minor

Cursor leak in logDatabaseLastChanged if getString throws.

The cursor at line 69 is closed only on the happy path (line 74). If moveToFirst() succeeds but getString(0) or getString(1) throws, the catch block at line 75 is entered and the cursor is never closed. The rest of the file consistently uses try/finally for cursor cleanup — this function should follow suit.

Proposed fix
     fun logDatabaseLastChanged() {
         try {
             val query = """
 SELECT changeTime, who
 FROM   LastChange
 """
             val cursor = database.rawQuery(query, arrayOf())
-            if (cursor.count > 0) {
-                cursor.moveToFirst()
-                log.debug("Database last change: {} {}.", cursor.getString(0), cursor.getString(1))
+            try {
+                if (cursor.count > 0) {
+                    cursor.moveToFirst()
+                    log.debug("Database last change: {} {}.", cursor.getString(0), cursor.getString(1))
+                }
+            } finally {
+                cursor.close()
             }
-            cursor.close()
         } catch (e: Exception) {
             log.debug("Could not retrieve database last change info: {}", e.message)
         }
     }
🧹 Nitpick comments (1)
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt (1)

345-371: selectColumns is interpolated directly into SQL — safe here, but fragile.

Column names from PRAGMA table_info are not user-supplied, so this isn't exploitable. However, if this pattern were copied for user-controlled input it would be a SQL injection vector. A brief comment noting why interpolation is safe (values come from schema introspection) would prevent future confusion.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (2)
app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt (2)

63-75: ⚠️ Potential issue | 🟡 Minor

Ensure the cursor closes on exceptions in logDatabaseLastChanged.
cursor.close() is skipped if moveToFirst()/getString() throws. Wrap it in use {} for guaranteed cleanup.

🔧 Suggested fix
-            val cursor = database.rawQuery(query, arrayOf())
-            if (cursor.count > 0) {
-                cursor.moveToFirst()
-                log.debug("Database last change: {} {}.", cursor.getString(0), cursor.getString(1))
-            }
-            cursor.close()
+            database.rawQuery(query, emptyArray<String>()).use { cursor ->
+                if (cursor.moveToFirst()) {
+                    log.debug("Database last change: {} {}.", cursor.getString(0), cursor.getString(1))
+                }
+            }

268-293: ⚠️ Potential issue | 🟡 Minor

Avoid serving partial content when a fragment is missing.
When a fragment is missing, the loop breaks and the server still returns 200 with truncated content. Consider returning an error instead.

💡 Suggested fix
-            var fragmentNumber = 1
-            var dbContent2 = dbContent
+            var fragmentNumber = 1
+            var dbContent2 = dbContent
+            var missingFragmentPath: String? = null
@@
-                } else {
-                    log.error("No fragment found for path '{}'.", path2)
-                    break
-                }
+                } else {
+                    log.error("No fragment found for path '{}'.", path2)
+                    missingFragmentPath = path2
+                    break
+                }
@@
-            }
+            }
+            if (missingFragmentPath != null) {
+                return sendError(
+                    writer,
+                    500,
+                    "Internal Server Error",
+                    "Missing fragment for path: $missingFragmentPath"
+                )
+            }
🤖 Fix all issues with AI agents
In `@app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt`:
- Around line 542-550: The response headers are being sent with writer.println
on a multi-line string that already ends with a blank line, which causes an
extra CRLF before the body and makes Content-Length incorrect; in
writeNormalToClient() replace the writer.println call (or otherwise stop using
println for the header block) so the headers string ends with exactly one
CRLFCRLF and use writer.print (or trim the trailing newline in the triple-quoted
string) to ensure the body starts immediately and Content-Length
(htmlBytes.size) matches the actual body bytes.

Copy link
Collaborator

@hal-eisen-adfa hal-eisen-adfa left a comment

Choose a reason for hiding this comment

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

LGTM

@hal-eisen-adfa hal-eisen-adfa merged commit 78e1335 into stage Feb 13, 2026
2 checks passed
@hal-eisen-adfa hal-eisen-adfa deleted the fix/ADFA-2807 branch February 13, 2026 07:35
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