From c4e9755d16fb05f30ef707db627b898e6e6c8690 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Wed, 5 Aug 2026 15:37:32 -0700 Subject: [PATCH 1/4] ADFA-5035: Fix WebServer occasionally failing to start with EADDRINUSE 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). --- .../androidide/localWebServer/WebServer.kt | 59 ++++++++++++------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt index 7ef3ac76e3..f085fbe60d 100644 --- a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt +++ b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt @@ -60,6 +60,14 @@ data class JavaExecutionResult( ) class WebServer(private val config: ServerConfig) { + // Guards serverSocket's creation/bind (in start(), on a background thread) against a + // concurrent close (in stop(), typically from the main thread on Activity#onDestroy()). + // Without this, a stop() arriving before start() reaches bind() finds serverSocket not + // yet initialized and is a silent no-op (see stop()'s isInitialized check below) -- the + // socket then binds anyway a moment later, orphaned, and holds the port until the process + // dies. The next start() attempt on that port then fails with "Address already in use." + private val lifecycleLock = Any() + private var stopRequested = false private lateinit var serverSocket : ServerSocket private lateinit var database : SQLiteDatabase private var databaseTimestamp : Long = -1 @@ -77,8 +85,8 @@ class WebServer(private val config: ServerConfig) { .create() private val dbContextType = object : TypeToken>() {}.type private var bookshelfTemplateId : Int = -1; - private val HTTP_INTERNAL_SERVER_ERROR = 500 - private val HTTP_NOT_FOUND = 404 + private val httpInternalServerError = 500 + private val httpNotFound = 404 private val contentChunkSize = 1024 * 1024 @@ -115,12 +123,15 @@ class WebServer(private val config: ServerConfig) { * Causes [start]'s accept loop to exit. No-op if not started or already stopped. */ fun stop() { - if (!::serverSocket.isInitialized) return - try { - serverSocket.close() + synchronized(lifecycleLock) { + stopRequested = true + if (!::serverSocket.isInitialized) return + try { + serverSocket.close() - } catch (e: Exception) { - log.error("Cannot close server socket: {}", e.message) + } catch (e: Exception) { + log.error("Cannot close server socket: {}", e.message) + } } } @@ -151,8 +162,14 @@ class WebServer(private val config: ServerConfig) { // NEW FEATURE: Log database metadata when debug is enabled if (debugEnabled) logDatabaseLastChanged() - serverSocket = ServerSocket().apply { reuseAddress = true } - serverSocket.bind(InetSocketAddress(config.bindName, config.port)) + synchronized(lifecycleLock) { + if (stopRequested) { + log.info("WebServer start() aborted: stop() was called before the socket could be bound.") + return + } + serverSocket = ServerSocket().apply { reuseAddress = true } + serverSocket.bind(InetSocketAddress(config.bindName, config.port)) + } log.info("WebServer started successfully on '{}', port {}.", config.bindName, config.port) while (true) { @@ -189,7 +206,7 @@ class WebServer(private val config: ServerConfig) { try { val output = socket.outputStream - sendError(PrintWriter(output, true), output, HTTP_INTERNAL_SERVER_ERROR, "Internal Server Error 1") + sendError(PrintWriter(output, true), output, httpInternalServerError, "Internal Server Error 1") } catch (e2: Exception) { log.error("Error sending error response: {}", e2.message) @@ -310,7 +327,7 @@ class WebServer(private val config: ServerConfig) { "pr/db" -> handleDbEndpoint(writer, output) "pr/pr" -> handlePrEndpoint(writer, output) "pr/ex" -> handleExEndpoint(writer, output) - else -> sendError(writer, output, HTTP_NOT_FOUND, "Not Found", "Path requested: '$path'.") + else -> sendError(writer, output, httpNotFound, "Not Found", "Path requested: '$path'.") } } @@ -326,8 +343,8 @@ class WebServer(private val config: ServerConfig) { // Process database fetch try { if (cursor.count != 1) { - return if (cursor.count == 0) sendError(writer, output, HTTP_NOT_FOUND, "Not Found") - else sendError(writer, output, HTTP_INTERNAL_SERVER_ERROR, "Corrupt database - multiple records found when unique record expected, Path requested: '$path'.") + return if (cursor.count == 0) sendError(writer, output, httpNotFound, "Not Found") + else sendError(writer, output, httpInternalServerError, "Corrupt database - multiple records found when unique record expected, Path requested: '$path'.") } cursor.moveToFirst() @@ -383,7 +400,7 @@ class WebServer(private val config: ServerConfig) { output.flush() } catch (e: Exception) { log.error("Error processing request: {}", e.message) - sendError(writer, output, HTTP_INTERNAL_SERVER_ERROR, "Internal Server Error", e.message ?: "") + sendError(writer, output, httpInternalServerError, "Internal Server Error", e.message ?: "") } finally { cursor.close() } @@ -552,7 +569,7 @@ class WebServer(private val config: ServerConfig) { sendError( writer, output, - HTTP_INTERNAL_SERVER_ERROR, + httpInternalServerError, "Internal Server Error 4.1", "Error creating output." ) @@ -566,7 +583,7 @@ class WebServer(private val config: ServerConfig) { } catch (e: Exception) { log.error("Error handling /pr/db endpoint: {}", e.message) - sendError(writer, output, HTTP_INTERNAL_SERVER_ERROR, "Internal Server Error 4", "Error generating database table.", true) + sendError(writer, output, httpInternalServerError, "Internal Server Error 4", "Error generating database table.", true) } } @@ -591,7 +608,7 @@ class WebServer(private val config: ServerConfig) { } catch (e: Exception) { log.error("Error handling /pr/bs endpoint: {}", e.message) - sendError(writer, output, HTTP_INTERNAL_SERVER_ERROR, "Internal Server Error 6", "Error generating bookshelf HTML.", outputStarted) + sendError(writer, output, httpInternalServerError, "Internal Server Error 6", "Error generating bookshelf HTML.", outputStarted) } if (debugEnabled) log.debug("Leaving handleBsEndpoint().") @@ -663,7 +680,7 @@ second response. } catch (e: Exception) { log.error("Error handling /pr/pr endpoint: {}", e.message) - sendError(writer, output, HTTP_INTERNAL_SERVER_ERROR, "Internal Server Error 6", "Error generating database table.", outputStarted) + sendError(writer, output, httpInternalServerError, "Internal Server Error 6", "Error generating database table.", outputStarted) } finally { projectDatabase?.close() @@ -741,7 +758,7 @@ SELECT '{"result" : [' || group_concat(Item) || ']}' FROM ( } catch (e: Exception) { log.error("Error processing request: {}", e.message) - sendError(writer, output, HTTP_INTERNAL_SERVER_ERROR, "Internal Server Error", e.message ?: "") + sendError(writer, output, httpInternalServerError, "Internal Server Error", e.message ?: "") return false } finally { cursor.close() @@ -764,9 +781,9 @@ SELECT '{"result" : [' || group_concat(Item) || ']}' FROM ( return true } if (cursor.count == 0) - sendError(writer, output, HTTP_NOT_FOUND, "Corrupt database, no rows found, expected one.") + sendError(writer, output, httpNotFound, "Corrupt database, no rows found, expected one.") else - sendError(writer, output, HTTP_INTERNAL_SERVER_ERROR, "Corrupt database - found ${cursor.count} rows when 1 was expected.") + sendError(writer, output, httpInternalServerError, "Corrupt database - found ${cursor.count} rows when 1 was expected.") return false } From 4ced1de42c8c34047ad293f5c8bc9b121a7858c8 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Wed, 5 Aug 2026 15:46:30 -0700 Subject: [PATCH 2/4] ADFA-5035: Apply Spotless ratchet reformat to WebServer.kt 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. --- .../androidide/localWebServer/WebServer.kt | 2100 +++++++++-------- 1 file changed, 1096 insertions(+), 1004 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt index f085fbe60d..0eab06d677 100644 --- a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt +++ b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt @@ -2,8 +2,18 @@ package com.itsaky.androidide.localWebServer import android.database.Cursor import android.database.sqlite.SQLiteDatabase +import android.net.TrafficStats +import android.os.Environment.getExternalStorageDirectory import com.aayushatharva.brotli4j.decoder.BrotliInputStream +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.ToNumberPolicy +import com.google.gson.reflect.TypeToken import com.itsaky.androidide.utils.DatabaseVersionResolver +import io.pebbletemplates.pebble.PebbleEngine +import io.pebbletemplates.pebble.loader.StringLoader +import io.pebbletemplates.pebble.template.PebbleTemplate +import okio.ByteString.Companion.toByteString import org.slf4j.LoggerFactory import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream @@ -11,7 +21,6 @@ import java.io.File import java.io.InputStream import java.io.PrintWriter import java.io.StringWriter -import android.net.TrafficStats import java.net.InetSocketAddress import java.net.ServerSocket import java.net.Socket @@ -19,644 +28,681 @@ import java.net.URLDecoder import java.sql.Date import java.text.SimpleDateFormat import java.util.Locale +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference -import io.pebbletemplates.pebble.PebbleEngine -import io.pebbletemplates.pebble.loader.StringLoader -import java.util.concurrent.ConcurrentHashMap -import io.pebbletemplates.pebble.template.PebbleTemplate -import android.os.Environment.getExternalStorageDirectory -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.google.gson.ToNumberPolicy -import com.google.gson.reflect.TypeToken -import okio.ByteString.Companion.toByteString - data class ServerConfig( - val port: Int = 6174, - val databasePath: String, - val fileDirPath: String, - val bindName: String = "localhost", - val debugDatabasePath: String = getExternalStorageDirectory().toString() + - "/Download/documentation.db", - val debugEnablePath: String = getExternalStorageDirectory().toString() + - "/Download/CodeOnTheGo.webserver.debug", - val experimentsEnablePath: String = getExternalStorageDirectory().toString() + - "/Download/CodeOnTheGo.exp", // TODO: Centralize this concept. --DS, 9-Feb-2026 - val clearCacheEnablePath: String = getExternalStorageDirectory().toString() + - "/Download/CodeOnTheGo.webserver.cs0", - -// Yes, this is hack code. - val projectDatabasePath: String = "/data/data/com.itsaky.androidide/databases/RecentProject_database" + val port: Int = 6174, + val databasePath: String, + val fileDirPath: String, + val bindName: String = "localhost", + val debugDatabasePath: String = + getExternalStorageDirectory().toString() + + "/Download/documentation.db", + val debugEnablePath: String = + getExternalStorageDirectory().toString() + + "/Download/CodeOnTheGo.webserver.debug", + val experimentsEnablePath: String = + getExternalStorageDirectory().toString() + + "/Download/CodeOnTheGo.exp", + // TODO: Centralize this concept. --DS, 9-Feb-2026 + val clearCacheEnablePath: String = + getExternalStorageDirectory().toString() + + "/Download/CodeOnTheGo.webserver.cs0", + // Yes, this is hack code. + val projectDatabasePath: String = "/data/data/com.itsaky.androidide/databases/RecentProject_database", ) data class JavaExecutionResult( - val compileOutput: String, - val runOutput: String, - val timedOut: Boolean, - val compileTimeMs: Long, - val timeoutLimit: Long + val compileOutput: String, + val runOutput: String, + val timedOut: Boolean, + val compileTimeMs: Long, + val timeoutLimit: Long, ) -class WebServer(private val config: ServerConfig) { - // Guards serverSocket's creation/bind (in start(), on a background thread) against a - // concurrent close (in stop(), typically from the main thread on Activity#onDestroy()). - // Without this, a stop() arriving before start() reaches bind() finds serverSocket not - // yet initialized and is a silent no-op (see stop()'s isInitialized check below) -- the - // socket then binds anyway a moment later, orphaned, and holds the port until the process - // dies. The next start() attempt on that port then fails with "Address already in use." - private val lifecycleLock = Any() - private var stopRequested = false - private lateinit var serverSocket : ServerSocket - private lateinit var database : SQLiteDatabase - private var databaseTimestamp : Long = -1 - private val log = LoggerFactory.getLogger(WebServer::class.java) - private val debugEnabled : Boolean = File(config.debugEnablePath).exists() - // TODO: Use the centralized experiments flag instead of this ad-hoc check. --DS, 10-Feb-2026 - private val experimentsEnabled : Boolean = File(config.experimentsEnablePath).exists() // Frozen at startup. Restart server if needed. - private val clearCacheEnabled : Boolean = File(config.clearCacheEnablePath).exists() // Frozen at startup. Restart server if needed. - private val encodingHeader : String = "Accept-Encoding" - private val brotliCompression : String = "br" - private val pebbleEngine = PebbleEngine.Builder().loader(StringLoader()).build() - private val templateCache = ConcurrentHashMap() - private val gson: Gson = GsonBuilder() - .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) - .create() - private val dbContextType = object : TypeToken>() {}.type - private var bookshelfTemplateId : Int = -1; - private val httpInternalServerError = 500 - private val httpNotFound = 404 - - private val contentChunkSize = 1024 * 1024 - - - //function to obtain the last modified date of a documentation.db database - // this is used to see if there is a newer version of the database on the sdcard - fun getDatabaseTimestamp(pathname: String, silent: Boolean = false): Long { - val dbFile = File(pathname) - var timestamp: Long = -1 - - if (dbFile.exists()) { - timestamp = dbFile.lastModified() - - if (!silent) { - val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - - if (debugEnabled) log.debug("{} was last modified at {}.", pathname, dateFormat.format(Date(timestamp))) - } - } - - return timestamp - } - - fun logDatabaseLastChanged() { - try { - log.debug("Database last change: {}.", DatabaseVersionResolver.resolveDatabaseVersion(database)) - } catch (e: Exception) { - log.error("Could not retrieve database last change info: {}", e.message) - } - } - - /** - * Stops the server by closing the listening socket. Safe to call from any thread. - * Causes [start]'s accept loop to exit. No-op if not started or already stopped. - */ - fun stop() { - synchronized(lifecycleLock) { - stopRequested = true - if (!::serverSocket.isInitialized) return - try { - serverSocket.close() - - } catch (e: Exception) { - log.error("Cannot close server socket: {}", e.message) - } - } - } - - fun start() { - // Hal Eisen: Required to fix StrictMode.VmPolicy.Builder.detectUntaggedSockets() - TrafficStats.setThreadStatsTag(0xC0DE) - try { - log.info( - "Starting WebServer on {}, port {}, debugEnabled={}, debugEnablePath='{}', debugDatabasePath='{}', experimentsEnabled={}, experimentsEnablePath='{}'.", - config.bindName, - config.port, - debugEnabled, - config.debugEnablePath, - config.debugDatabasePath, - experimentsEnabled, - config.experimentsEnablePath - ) - - databaseTimestamp = getDatabaseTimestamp(config.databasePath) - - try { - database = SQLiteDatabase.openDatabase(config.databasePath, null, SQLiteDatabase.OPEN_READONLY) - } catch (e: Exception) { - log.error("Cannot open database: {}", e.message) - return - } - - // NEW FEATURE: Log database metadata when debug is enabled - if (debugEnabled) logDatabaseLastChanged() - - synchronized(lifecycleLock) { - if (stopRequested) { - log.info("WebServer start() aborted: stop() was called before the socket could be bound.") - return - } - serverSocket = ServerSocket().apply { reuseAddress = true } - serverSocket.bind(InetSocketAddress(config.bindName, config.port)) - } - log.info("WebServer started successfully on '{}', port {}.", config.bindName, config.port) - - while (true) { - var clientSocket: Socket? = null - try { - try { - if (debugEnabled) log.debug("About to call accept() on the server socket, {}.", serverSocket) - clientSocket = serverSocket.accept() - - if (debugEnabled) log.debug("Returned from socket accept(), clientSocket is {}.", clientSocket) - - } catch (e: java.net.SocketException) { - if (debugEnabled) log.debug("Caught java.net.SocketException '$e'.") // SLF4J placeholders produce wrong formatting here. --DS, 23-Feb-2026 - - if (e.message?.contains("Closed", ignoreCase = true) == true) { - if (debugEnabled) log.debug("WebServer socket closed, shutting down.") - break - } - log.error("Accept() failed: {}", e.message) - continue - } - try { - clientSocket?.let { handleClient(it) } - - } catch (e: Exception) { - if (debugEnabled) log.debug("Caught exception '$e'.") // SLF4J placeholders produce wrong formatting here. --DS, 23-Feb-2026 - - if (e is java.net.SocketException && e.message?.contains("Closed", ignoreCase = true) == true) { - if (debugEnabled) log.debug("Client disconnected: {}", e.message) - - } else { - log.error("Error handling client: {}", e.message) - clientSocket?.let { socket -> - try { - val output = socket.outputStream - - sendError(PrintWriter(output, true), output, httpInternalServerError, "Internal Server Error 1") - - } catch (e2: Exception) { - log.error("Error sending error response: {}", e2.message) - } - } - } - } - - } finally { - clientSocket?.close() - - // CodeRabbit objects to the following line because clientSocket may print out as "null." This is intentional. --DS - if (debugEnabled) log.debug("clientSocket was {}.", clientSocket) - } - } - - } catch (e: Exception) { - log.error("Error: {}", e.message) - - } finally { - if (::serverSocket.isInitialized) { - serverSocket.close() - } - TrafficStats.clearThreadStatsTag() - } - } - - /** - * Reads a single line from the stream (bytes until newline). Same stream is used for headers - * and body so POST body bytes are not lost to a separate buffered reader. HTTP header lines are ASCII. - */ - private fun readLineFromStream(input: InputStream): String? { - val baos = ByteArrayOutputStream() - while (true) { - val b = input.read() - if (b == -1) return if (baos.size() == 0) null else baos.toString(Charsets.ISO_8859_1).trimEnd('\r') - if (b == '\n'.code) break - baos.write(b) - } - val bytes = baos.toByteArray() - val len = if (bytes.isNotEmpty() && bytes[bytes.size - 1] == '\r'.code.toByte()) bytes.size - 1 else bytes.size - return String(bytes, 0, len, Charsets.ISO_8859_1) - } - - private fun handleClient(clientSocket: Socket) { - if (debugEnabled) log.debug("In handleClient(), socket is {}.", clientSocket) - - val input = clientSocket.getInputStream() - if (debugEnabled) log.debug(" input is {}.", input) - - val output = clientSocket.getOutputStream() - if (debugEnabled) log.debug(" output is {}.", output) - - val writer = PrintWriter(output, true) - if (debugEnabled) log.debug(" writer is {}.", writer) - - var brotliSupported = false //assume nothing - - // Read the request method line, it is always the first line of the request - var requestLine = readLineFromStream(input) - if (requestLine == null) { - if (debugEnabled) log.debug("requestLine is null. Returning from handleClient() early.") - return - } - if (debugEnabled) log.debug("Request is {}", requestLine) - - // Parse the request - // Request line should look like "GET /a/b/c.html HTTP/1.1" - val parts = requestLine.split(" ") - if (parts.size != 3) { - return sendError(writer, output, 400, "Bad Request") - } - - //extract the request method (e.g. GET, POST, PUT) - val method = parts[0] - var path = parts[1].split("?")[0] // Discard any HTTP query parameters. - path = path.substring(1) - - // Read all headers until blank line (needed for Content-Length on POST and Accept-Encoding on GET) - val headers = mutableMapOf() - while (true) { - requestLine = readLineFromStream(input) ?: break - if (requestLine.isEmpty()) break - if (debugEnabled) log.debug("Header: {}", requestLine) - val colon = requestLine.indexOf(':') - if (colon > 0) { - headers[requestLine.substring(0, colon).trim().lowercase()] = requestLine.substring(colon + 1).trim() - } - } - brotliSupported = headers["accept-encoding"]?.contains(brotliCompression) == true - - // Playground endpoint: POST only, handled before GET-only check - if (false && path == "playground/execute") { - return handlePlaygroundExecute(input, writer, output, method, headers) - } - - // we only support teh GET method, return an error page for anything else - if (method != "GET") { - return sendError(writer, output, 501, "Not Implemented") - } - - //check to see if there is a newer version of the documentation.db database on the sdcard - // if there is use that for our responses - val debugDatabaseTimestamp = getDatabaseTimestamp(config.debugDatabasePath, true) - if (debugDatabaseTimestamp > databaseTimestamp) { - bookshelfTemplateId = -1 - database.close() - database = SQLiteDatabase.openDatabase(config.debugDatabasePath, null, SQLiteDatabase.OPEN_READONLY) - databaseTimestamp = debugDatabaseTimestamp - } - - // Handle the special "pr" endpoint with highest priority - if (path.startsWith("pr/", false)) { - if (debugEnabled) log.debug("Found a pr/ path, '{}'.", path) - - return when (path) { - "pr/bs" -> handleBsEndpoint(writer, output) - "pr/db" -> handleDbEndpoint(writer, output) - "pr/pr" -> handlePrEndpoint(writer, output) - "pr/ex" -> handleExEndpoint(writer, output) - else -> sendError(writer, output, httpNotFound, "Not Found", "Path requested: '$path'.") - } - } - - // Database fetch - val query = """ - SELECT C.content, CT.value, CT.compression, C.templateId - FROM Content C, ContentTypes CT - WHERE C.contentTypeID = CT.id - AND C.path = ? - """ - val cursor = database.rawQuery(query, arrayOf(path)) - - // Process database fetch - try { - if (cursor.count != 1) { - return if (cursor.count == 0) sendError(writer, output, httpNotFound, "Not Found") - else sendError(writer, output, httpInternalServerError, "Corrupt database - multiple records found when unique record expected, Path requested: '$path'.") - } - - cursor.moveToFirst() - var dbContent = cursor.getBlob(0) - val dbMimeType = cursor.getString(1) - var compression = cursor.getString(2) - val templateId = cursor.getInt(3) - - // Fragment handling for large content (> 1MB) - if (dbContent.size == contentChunkSize) { - val query2 = "SELECT content FROM Content WHERE path = ? AND languageId = 1" - var fragmentNumber = 1 - val combined = ByteArrayOutputStream().apply {write(dbContent)} - var dbContent2 = dbContent - while (dbContent2.size == contentChunkSize) { - val path2 = "$path-$fragmentNumber" - val cursor2 = database.rawQuery(query2, arrayOf(path2)) - try { - if (cursor2.moveToFirst()) { - dbContent2 = cursor2.getBlob(0) - combined.write(dbContent2) - fragmentNumber++ - } else break - } finally { cursor2.close() } - } - dbContent = combined.toByteArray() - } - - // If a document is stored in brotli form and the client doesn't support that encoding - // decompress and send that to the client. - // Pebble templates have to be in string form so the retrieved database content may need to be - // decompressed. - if (compression == "brotli" && (!brotliSupported || templateId > 0)) { - dbContent = BrotliInputStream(ByteArrayInputStream(dbContent)).use { it.readBytes() } - compression = "none" - } else if (compression == "brotli") { - compression = "br" - } - - // If the file is associated with a template, instantiate that template and send the result to the client - if (templateId > 0) { - dbContent = instantiatePebbleTemplate(templateId, dbContent, path, dbMimeType, compression) - } - - writer.println("HTTP/1.1 200 OK") - writer.println("Content-Type: $dbMimeType") - writer.println("Content-Length: ${dbContent.size}") - if (compression != "none") writer.println("Content-Encoding: $compression") - writer.println("Connection: close") - writer.println() - writer.flush() - output.write(dbContent) - output.flush() - } catch (e: Exception) { - log.error("Error processing request: {}", e.message) - sendError(writer, output, httpInternalServerError, "Internal Server Error", e.message ?: "") - } finally { - cursor.close() - } - } - - /** - * Renders a Pebble template identified by `templateId` using the provided JSON data and returns the rendered output as bytes. - * - * @param templateId The database ID of the Pebble template to load and compile. - * @param dbContent JSON bytes that will be parsed and supplied as the template context. - * @param path The request/content path associated with this template (used for diagnostic/logging purposes). - * @param dbMimeType The MIME type of the stored content (used for diagnostic/logging purposes). - * @param compression The compression label of the stored content (e.g., "br", "none") (used for diagnostic/logging purposes). - * @return The rendered template encoded as UTF-8 bytes. - * @throws Exception If the template ID is not found, is duplicated in the database, or if template lookup/instantiation fails. - */ - private fun instantiatePebbleTemplate(templateId: Int, dbContent: ByteArray, path: String, dbMimeType: String, compression: String): ByteArray { - if (debugEnabled) log.debug("Processing template for templateId={}", templateId) - - // 1. Get or Compile Template from Cache - val compiledTemplate = templateCache.getOrPut(templateId) { - if (debugEnabled) log.debug( - "Template cache miss for ID {}, path {}, MIME type {}, compression {}}", - templateId, - path, - dbMimeType, - compression - ) - - val tQuery = "SELECT content FROM Templates WHERE id = ?" - val tCursor = database.rawQuery(tQuery, arrayOf(templateId.toString())) - tCursor.use { cursor -> - when { - cursor.count == 0 -> { - log.debug( - "Template not found, for ID {}, path {}, MIME type {}, compression {}", - templateId, - path, - dbMimeType, - compression - ) - throw Exception("Template ID $templateId not found in the database") - } - cursor.count > 1 -> { - log.debug( - "More than one template found, for ID {}, path {}, MIME type {}, compression {}", - templateId, - path, - dbMimeType, - compression - ) - throw Exception("Template ID $templateId is shared by more than one template") - } - !cursor.moveToFirst() -> { - log.debug( - "Template not found, for ID {}, path {}, MIME type {}, compression {}", - templateId, - path, - dbMimeType, - compression - ) - throw Exception("Template ID $templateId not found in database.") - } - else -> { - val templateBlob = cursor.getBlob(0) - if (debugEnabled) log.debug("templateBlob = '${String(templateBlob)}'") - pebbleEngine.getTemplate(templateBlob.toString(Charsets.UTF_8)) - } - } - } - } - - // Load JSON data into a template context Map<> for instantiation - val dbContentStr = dbContent.toString(Charsets.UTF_8) - if (dbContentStr.isBlank() || dbContentStr.trim() == "null") - throw Exception("Template ID $templateId has empty or null JSON context") - val context: Map = gson.fromJson(dbContentStr, dbContextType) - - // Evaluate template with loaded data and return the output - val sw = StringWriter() - compiledTemplate.evaluate(sw, context) - return sw.toString().toByteArray() - } - - - /** - * Serve an HTML page showing the 20 most recent rows of the `LastChange` table. - * - * Queries the table schema to determine column names, selects the latest 20 rows - * ordered by `changeTime`, escapes cell values for HTML, assembles an HTML table, - * and writes a normal 200 HTML response to the client. On database or rendering - * errors a 500 error response is sent. All database cursors are closed before returning. - */ - private fun handleDbEndpoint(writer: PrintWriter, output: java.io.OutputStream) { - if (debugEnabled) log.debug("Entering handleDbEndpoint().") - - var html : String - - try { - // First, get the schema of the LastChange table to determine column count - val schemaQuery = "PRAGMA table_info(LastChange)" - val schemaCursor = database.rawQuery(schemaQuery, arrayOf()) - - var columnCount: Int - var selectColumns: String - - html = getTableHtml("LastChange Table", "LastChange Table (20 Most Recent Rows)") - - try { - columnCount = schemaCursor.count - val columnNames = mutableListOf() - - while (schemaCursor.moveToNext()) { - // Values come from schema introspection, therefore not subject to a SQL injection attack. - columnNames.add(schemaCursor.getString(1)) // Column name is at index 1 - } - - if (debugEnabled) log.debug( - "LastChange table has {} columns: {}", - columnCount, - columnNames - ) - - // Build the SELECT query for the 20 most recent rows - selectColumns = columnNames.joinToString(", ") - - // Add header row - html += """""" - for (columnName in columnNames) { - html += """${escapeHtml(columnName)}""" - } - html += """""" - - } finally { - schemaCursor.close() - } - - val dataQuery = - "SELECT $selectColumns FROM LastChange ORDER BY changeTime DESC LIMIT 20" - - val dataCursor = database.rawQuery(dataQuery, arrayOf()) - - try { - val rowCount = dataCursor.count - - if (debugEnabled) log.debug("Retrieved {} rows from LastChange table", rowCount) - - // Add data rows - while (dataCursor.moveToNext()) { - html += """""" - for (i in 0 until columnCount) { - html += """${escapeHtml(dataCursor.getString(i) ?: "")}""" - } - html += """""" - } - - html += """""" - - } finally { - dataCursor.close() - } - - if (debugEnabled) log.debug("html is '{}'.", html) - } catch (e: Exception) { - log.error("Error creating output for /pr/db endpoint: {}", e.message) - sendError( - writer, - output, - httpInternalServerError, - "Internal Server Error 4.1", - "Error creating output." - ) - return - } - - try { - writeNormalToClient(writer, output, html) - - if (debugEnabled) log.debug("Leaving handleDbEndpoint().") - - } catch (e: Exception) { - log.error("Error handling /pr/db endpoint: {}", e.message) - sendError(writer, output, httpInternalServerError, "Internal Server Error 4", "Error generating database table.", true) - } - } - - /** - * Handles the /pr/bs endpoint by invoking the bookshelf generator and sending a 500 error if generation fails. - * - * Calls realHandleBsEndpoint to produce and write the response body; if an exception occurs, sends an HTTP 500 - * error using the reported output-start state so no additional headers/body are written after output has begun. - * - * @param writer PrintWriter used for writing textual HTTP response headers. - * @param output Raw OutputStream used for writing the response body bytes. - */ - private fun handleBsEndpoint(writer: PrintWriter, output: java.io.OutputStream) { - if (debugEnabled) log.debug("Entering handleBsEndpoint().") - if(clearCacheEnabled) templateCache.clear() - - var outputStarted = false - - try { - - outputStarted = realHandleBsEndpoint(writer, output) - - } catch (e: Exception) { - log.error("Error handling /pr/bs endpoint: {}", e.message) - sendError(writer, output, httpInternalServerError, "Internal Server Error 6", "Error generating bookshelf HTML.", outputStarted) - } - - if (debugEnabled) log.debug("Leaving handleBsEndpoint().") - } - - - /** - * Writes a small CSS response that shows or hides elements with the - * `.code_on_the_go_experiment` class depending on the server's - * `experimentsEnabled` flag. - */ - private fun handleExEndpoint(writer: PrintWriter, output: java.io.OutputStream) { - val flag = if (experimentsEnabled) "{}" else "{display: none;}" - - if (debugEnabled) log.debug("Experiment flag='{}'.", flag) - - sendCSS(writer, output, ".code_on_the_go_experiment $flag") - } - - /** - * Handle the /pr/pr endpoint by opening the project database, delegating page generation to realHandlePrEndpoint, and sending an HTTP 500 error if generation fails. - * - * @param writer PrintWriter used to write response headers. - * @param output OutputStream used to write response body bytes. - */ - private fun handlePrEndpoint(writer: PrintWriter, output: java.io.OutputStream) { - if (debugEnabled) log.debug("Entering handlePrEndpoint().") - - var projectDatabase : SQLiteDatabase? = null - var outputStarted = false - - try { - projectDatabase = SQLiteDatabase.openDatabase(config.projectDatabasePath, - null, - SQLiteDatabase.OPEN_READONLY) - - /* I disagree with CodeRabbit's message, reproduced below. However, the - IDE's "Problems" window says that outputStarted is "always false." - - While writeNormalToClient() can fail in the middle of execution, - making the error reporting code more complicated is likely to - introduce more bugs, rather than helping fix existing ones. --DS, 23-Feb-2026 - - 482-494: ⚠️ Potential issue | 🟡 Minor +class WebServer( + private val config: ServerConfig, +) { + // Guards serverSocket's creation/bind (in start(), on a background thread) against a + // concurrent close (in stop(), typically from the main thread on Activity#onDestroy()). + // Without this, a stop() arriving before start() reaches bind() finds serverSocket not + // yet initialized and is a silent no-op (see stop()'s isInitialized check below) -- the + // socket then binds anyway a moment later, orphaned, and holds the port until the process + // dies. The next start() attempt on that port then fails with "Address already in use." + private val lifecycleLock = Any() + private var stopRequested = false + private lateinit var serverSocket: ServerSocket + private lateinit var database: SQLiteDatabase + private var databaseTimestamp: Long = -1 + private val log = LoggerFactory.getLogger(WebServer::class.java) + private val debugEnabled: Boolean = File(config.debugEnablePath).exists() + + // TODO: Use the centralized experiments flag instead of this ad-hoc check. --DS, 10-Feb-2026 + // Frozen at startup; restart the server to pick up a change. + private val experimentsEnabled: Boolean = File(config.experimentsEnablePath).exists() + + // Frozen at startup; restart the server to pick up a change. + private val clearCacheEnabled: Boolean = File(config.clearCacheEnablePath).exists() + private val encodingHeader: String = "Accept-Encoding" + private val brotliCompression: String = "br" + private val pebbleEngine = PebbleEngine.Builder().loader(StringLoader()).build() + private val templateCache = ConcurrentHashMap() + private val gson: Gson = + GsonBuilder() + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .create() + private val dbContextType = object : TypeToken>() {}.type + private var bookshelfTemplateId: Int = -1 + private val httpInternalServerError = 500 + private val httpNotFound = 404 + + private val contentChunkSize = 1024 * 1024 + + // function to obtain the last modified date of a documentation.db database + // this is used to see if there is a newer version of the database on the sdcard + fun getDatabaseTimestamp( + pathname: String, + silent: Boolean = false, + ): Long { + val dbFile = File(pathname) + var timestamp: Long = -1 + + if (dbFile.exists()) { + timestamp = dbFile.lastModified() + + if (!silent) { + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + + if (debugEnabled) log.debug("{} was last modified at {}.", pathname, dateFormat.format(Date(timestamp))) + } + } + + return timestamp + } + + fun logDatabaseLastChanged() { + try { + log.debug("Database last change: {}.", DatabaseVersionResolver.resolveDatabaseVersion(database)) + } catch (e: Exception) { + log.error("Could not retrieve database last change info: {}", e.message) + } + } + + /** + * Stops the server by closing the listening socket. Safe to call from any thread. + * Causes [start]'s accept loop to exit. No-op if not started or already stopped. + */ + fun stop() { + synchronized(lifecycleLock) { + stopRequested = true + if (!::serverSocket.isInitialized) return + try { + serverSocket.close() + } catch (e: Exception) { + log.error("Cannot close server socket: {}", e.message) + } + } + } + + fun start() { + // Hal Eisen: Required to fix StrictMode.VmPolicy.Builder.detectUntaggedSockets() + TrafficStats.setThreadStatsTag(0xC0DE) + try { + log.info( + "Starting WebServer on {}, port {}, debugEnabled={}, debugEnablePath='{}', " + + "debugDatabasePath='{}', experimentsEnabled={}, experimentsEnablePath='{}'.", + config.bindName, + config.port, + debugEnabled, + config.debugEnablePath, + config.debugDatabasePath, + experimentsEnabled, + config.experimentsEnablePath, + ) + + databaseTimestamp = getDatabaseTimestamp(config.databasePath) + + try { + database = SQLiteDatabase.openDatabase(config.databasePath, null, SQLiteDatabase.OPEN_READONLY) + } catch (e: Exception) { + log.error("Cannot open database: {}", e.message) + return + } + + // NEW FEATURE: Log database metadata when debug is enabled + if (debugEnabled) logDatabaseLastChanged() + + synchronized(lifecycleLock) { + if (stopRequested) { + log.info("WebServer start() aborted: stop() was called before the socket could be bound.") + return + } + serverSocket = ServerSocket().apply { reuseAddress = true } + serverSocket.bind(InetSocketAddress(config.bindName, config.port)) + } + log.info("WebServer started successfully on '{}', port {}.", config.bindName, config.port) + + while (true) { + var clientSocket: Socket? = null + try { + try { + if (debugEnabled) log.debug("About to call accept() on the server socket, {}.", serverSocket) + clientSocket = serverSocket.accept() + + if (debugEnabled) log.debug("Returned from socket accept(), clientSocket is {}.", clientSocket) + } catch (e: java.net.SocketException) { + // SLF4J placeholders produce wrong formatting here. --DS, 23-Feb-2026 + if (debugEnabled) log.debug("Caught java.net.SocketException '$e'.") + + if (e.message?.contains("Closed", ignoreCase = true) == true) { + if (debugEnabled) log.debug("WebServer socket closed, shutting down.") + break + } + log.error("Accept() failed: {}", e.message) + continue + } + try { + clientSocket?.let { handleClient(it) } + } catch (e: Exception) { + // SLF4J placeholders produce wrong formatting here. --DS, 23-Feb-2026 + if (debugEnabled) log.debug("Caught exception '$e'.") + + if (e is java.net.SocketException && e.message?.contains("Closed", ignoreCase = true) == true) { + if (debugEnabled) log.debug("Client disconnected: {}", e.message) + } else { + log.error("Error handling client: {}", e.message) + clientSocket?.let { socket -> + try { + val output = socket.outputStream + + sendError(PrintWriter(output, true), output, httpInternalServerError, "Internal Server Error 1") + } catch (e2: Exception) { + log.error("Error sending error response: {}", e2.message) + } + } + } + } + } finally { + clientSocket?.close() + + // CodeRabbit objects to the following line because clientSocket may print out as "null." This is intentional. --DS + if (debugEnabled) log.debug("clientSocket was {}.", clientSocket) + } + } + } catch (e: Exception) { + log.error("Error: {}", e.message) + } finally { + if (::serverSocket.isInitialized) { + serverSocket.close() + } + TrafficStats.clearThreadStatsTag() + } + } + + /** + * Reads a single line from the stream (bytes until newline). Same stream is used for headers + * and body so POST body bytes are not lost to a separate buffered reader. HTTP header lines are ASCII. + */ + private fun readLineFromStream(input: InputStream): String? { + val baos = ByteArrayOutputStream() + while (true) { + val b = input.read() + if (b == -1) return if (baos.size() == 0) null else baos.toString(Charsets.ISO_8859_1).trimEnd('\r') + if (b == '\n'.code) break + baos.write(b) + } + val bytes = baos.toByteArray() + val len = if (bytes.isNotEmpty() && bytes[bytes.size - 1] == '\r'.code.toByte()) bytes.size - 1 else bytes.size + return String(bytes, 0, len, Charsets.ISO_8859_1) + } + + private fun handleClient(clientSocket: Socket) { + if (debugEnabled) log.debug("In handleClient(), socket is {}.", clientSocket) + + val input = clientSocket.getInputStream() + if (debugEnabled) log.debug(" input is {}.", input) + + val output = clientSocket.getOutputStream() + if (debugEnabled) log.debug(" output is {}.", output) + + val writer = PrintWriter(output, true) + if (debugEnabled) log.debug(" writer is {}.", writer) + + var brotliSupported = false // assume nothing + + // Read the request method line, it is always the first line of the request + var requestLine = readLineFromStream(input) + if (requestLine == null) { + if (debugEnabled) log.debug("requestLine is null. Returning from handleClient() early.") + return + } + if (debugEnabled) log.debug("Request is {}", requestLine) + + // Parse the request + // Request line should look like "GET /a/b/c.html HTTP/1.1" + val parts = requestLine.split(" ") + if (parts.size != 3) { + return sendError(writer, output, 400, "Bad Request") + } + + // extract the request method (e.g. GET, POST, PUT) + val method = parts[0] + var path = parts[1].split("?")[0] // Discard any HTTP query parameters. + path = path.substring(1) + + // Read all headers until blank line (needed for Content-Length on POST and Accept-Encoding on GET) + val headers = mutableMapOf() + while (true) { + requestLine = readLineFromStream(input) ?: break + if (requestLine.isEmpty()) break + if (debugEnabled) log.debug("Header: {}", requestLine) + val colon = requestLine.indexOf(':') + if (colon > 0) { + headers[requestLine.substring(0, colon).trim().lowercase()] = requestLine.substring(colon + 1).trim() + } + } + brotliSupported = headers["accept-encoding"]?.contains(brotliCompression) == true + + // Playground endpoint: POST only, handled before GET-only check + if (false && path == "playground/execute") { + return handlePlaygroundExecute(input, writer, output, method, headers) + } + + // we only support teh GET method, return an error page for anything else + if (method != "GET") { + return sendError(writer, output, 501, "Not Implemented") + } + + // check to see if there is a newer version of the documentation.db database on the sdcard + // if there is use that for our responses + val debugDatabaseTimestamp = getDatabaseTimestamp(config.debugDatabasePath, true) + if (debugDatabaseTimestamp > databaseTimestamp) { + bookshelfTemplateId = -1 + database.close() + database = SQLiteDatabase.openDatabase(config.debugDatabasePath, null, SQLiteDatabase.OPEN_READONLY) + databaseTimestamp = debugDatabaseTimestamp + } + + // Handle the special "pr" endpoint with highest priority + if (path.startsWith("pr/", false)) { + if (debugEnabled) log.debug("Found a pr/ path, '{}'.", path) + + return when (path) { + "pr/bs" -> handleBsEndpoint(writer, output) + "pr/db" -> handleDbEndpoint(writer, output) + "pr/pr" -> handlePrEndpoint(writer, output) + "pr/ex" -> handleExEndpoint(writer, output) + else -> sendError(writer, output, httpNotFound, "Not Found", "Path requested: '$path'.") + } + } + + // Database fetch + val query = """ + SELECT C.content, CT.value, CT.compression, C.templateId + FROM Content C, ContentTypes CT + WHERE C.contentTypeID = CT.id + AND C.path = ? + """ + val cursor = database.rawQuery(query, arrayOf(path)) + + // Process database fetch + try { + if (cursor.count != 1) { + return if (cursor.count == 0) { + sendError(writer, output, httpNotFound, "Not Found") + } else { + sendError( + writer, + output, + httpInternalServerError, + "Corrupt database - multiple records found when unique record expected, Path requested: '$path'.", + ) + } + } + + cursor.moveToFirst() + var dbContent = cursor.getBlob(0) + val dbMimeType = cursor.getString(1) + var compression = cursor.getString(2) + val templateId = cursor.getInt(3) + + // Fragment handling for large content (> 1MB) + if (dbContent.size == contentChunkSize) { + val query2 = "SELECT content FROM Content WHERE path = ? AND languageId = 1" + var fragmentNumber = 1 + val combined = ByteArrayOutputStream().apply { write(dbContent) } + var dbContent2 = dbContent + while (dbContent2.size == contentChunkSize) { + val path2 = "$path-$fragmentNumber" + val cursor2 = database.rawQuery(query2, arrayOf(path2)) + try { + if (cursor2.moveToFirst()) { + dbContent2 = cursor2.getBlob(0) + combined.write(dbContent2) + fragmentNumber++ + } else { + break + } + } finally { + cursor2.close() + } + } + dbContent = combined.toByteArray() + } + + // If a document is stored in brotli form and the client doesn't support that encoding + // decompress and send that to the client. + // Pebble templates have to be in string form so the retrieved database content may need to be + // decompressed. + if (compression == "brotli" && (!brotliSupported || templateId > 0)) { + dbContent = BrotliInputStream(ByteArrayInputStream(dbContent)).use { it.readBytes() } + compression = "none" + } else if (compression == "brotli") { + compression = "br" + } + + // If the file is associated with a template, instantiate that template and send the result to the client + if (templateId > 0) { + dbContent = instantiatePebbleTemplate(templateId, dbContent, path, dbMimeType, compression) + } + + writer.println("HTTP/1.1 200 OK") + writer.println("Content-Type: $dbMimeType") + writer.println("Content-Length: ${dbContent.size}") + if (compression != "none") writer.println("Content-Encoding: $compression") + writer.println("Connection: close") + writer.println() + writer.flush() + output.write(dbContent) + output.flush() + } catch (e: Exception) { + log.error("Error processing request: {}", e.message) + sendError(writer, output, httpInternalServerError, "Internal Server Error", e.message ?: "") + } finally { + cursor.close() + } + } + + /** + * Renders a Pebble template identified by `templateId` using the provided JSON data and returns the rendered output as bytes. + * + * @param templateId The database ID of the Pebble template to load and compile. + * @param dbContent JSON bytes that will be parsed and supplied as the template context. + * @param path The request/content path associated with this template (used for diagnostic/logging purposes). + * @param dbMimeType The MIME type of the stored content (used for diagnostic/logging purposes). + * @param compression The compression label of the stored content (e.g., "br", "none") (used for diagnostic/logging purposes). + * @return The rendered template encoded as UTF-8 bytes. + * @throws Exception If the template ID is not found, is duplicated in the database, or if template lookup/instantiation fails. + */ + private fun instantiatePebbleTemplate( + templateId: Int, + dbContent: ByteArray, + path: String, + dbMimeType: String, + compression: String, + ): ByteArray { + if (debugEnabled) log.debug("Processing template for templateId={}", templateId) + + // 1. Get or Compile Template from Cache + val compiledTemplate = + templateCache.getOrPut(templateId) { + if (debugEnabled) { + log.debug( + "Template cache miss for ID {}, path {}, MIME type {}, compression {}}", + templateId, + path, + dbMimeType, + compression, + ) + } + + val tQuery = "SELECT content FROM Templates WHERE id = ?" + val tCursor = database.rawQuery(tQuery, arrayOf(templateId.toString())) + tCursor.use { cursor -> + when { + cursor.count == 0 -> { + log.debug( + "Template not found, for ID {}, path {}, MIME type {}, compression {}", + templateId, + path, + dbMimeType, + compression, + ) + throw Exception("Template ID $templateId not found in the database") + } + + cursor.count > 1 -> { + log.debug( + "More than one template found, for ID {}, path {}, MIME type {}, compression {}", + templateId, + path, + dbMimeType, + compression, + ) + throw Exception("Template ID $templateId is shared by more than one template") + } + + !cursor.moveToFirst() -> { + log.debug( + "Template not found, for ID {}, path {}, MIME type {}, compression {}", + templateId, + path, + dbMimeType, + compression, + ) + throw Exception("Template ID $templateId not found in database.") + } + + else -> { + val templateBlob = cursor.getBlob(0) + if (debugEnabled) log.debug("templateBlob = '${String(templateBlob)}'") + pebbleEngine.getTemplate(templateBlob.toString(Charsets.UTF_8)) + } + } + } + } + + // Load JSON data into a template context Map<> for instantiation + val dbContentStr = dbContent.toString(Charsets.UTF_8) + if (dbContentStr.isBlank() || dbContentStr.trim() == "null") { + throw Exception("Template ID $templateId has empty or null JSON context") + } + val context: Map = gson.fromJson(dbContentStr, dbContextType) + + // Evaluate template with loaded data and return the output + val sw = StringWriter() + compiledTemplate.evaluate(sw, context) + return sw.toString().toByteArray() + } + + /** + * Serve an HTML page showing the 20 most recent rows of the `LastChange` table. + * + * Queries the table schema to determine column names, selects the latest 20 rows + * ordered by `changeTime`, escapes cell values for HTML, assembles an HTML table, + * and writes a normal 200 HTML response to the client. On database or rendering + * errors a 500 error response is sent. All database cursors are closed before returning. + */ + private fun handleDbEndpoint( + writer: PrintWriter, + output: java.io.OutputStream, + ) { + if (debugEnabled) log.debug("Entering handleDbEndpoint().") + + var html: String + + try { + // First, get the schema of the LastChange table to determine column count + val schemaQuery = "PRAGMA table_info(LastChange)" + val schemaCursor = database.rawQuery(schemaQuery, arrayOf()) + + var columnCount: Int + var selectColumns: String + + html = getTableHtml("LastChange Table", "LastChange Table (20 Most Recent Rows)") + + try { + columnCount = schemaCursor.count + val columnNames = mutableListOf() + + while (schemaCursor.moveToNext()) { + // Values come from schema introspection, therefore not subject to a SQL injection attack. + columnNames.add(schemaCursor.getString(1)) // Column name is at index 1 + } + + if (debugEnabled) { + log.debug( + "LastChange table has {} columns: {}", + columnCount, + columnNames, + ) + } + + // Build the SELECT query for the 20 most recent rows + selectColumns = columnNames.joinToString(", ") + + // Add header row + html += """""" + for (columnName in columnNames) { + html += """${escapeHtml(columnName)}""" + } + html += """""" + } finally { + schemaCursor.close() + } + + val dataQuery = + "SELECT $selectColumns FROM LastChange ORDER BY changeTime DESC LIMIT 20" + + val dataCursor = database.rawQuery(dataQuery, arrayOf()) + + try { + val rowCount = dataCursor.count + + if (debugEnabled) log.debug("Retrieved {} rows from LastChange table", rowCount) + + // Add data rows + while (dataCursor.moveToNext()) { + html += """""" + for (i in 0 until columnCount) { + html += """${escapeHtml(dataCursor.getString(i) ?: "")}""" + } + html += """""" + } + + html += """""" + } finally { + dataCursor.close() + } + + if (debugEnabled) log.debug("html is '{}'.", html) + } catch (e: Exception) { + log.error("Error creating output for /pr/db endpoint: {}", e.message) + sendError( + writer, + output, + httpInternalServerError, + "Internal Server Error 4.1", + "Error creating output.", + ) + return + } + + try { + writeNormalToClient(writer, output, html) + + if (debugEnabled) log.debug("Leaving handleDbEndpoint().") + } catch (e: Exception) { + log.error("Error handling /pr/db endpoint: {}", e.message) + sendError(writer, output, httpInternalServerError, "Internal Server Error 4", "Error generating database table.", true) + } + } + + /** + * Handles the /pr/bs endpoint by invoking the bookshelf generator and sending a 500 error if generation fails. + * + * Calls realHandleBsEndpoint to produce and write the response body; if an exception occurs, sends an HTTP 500 + * error using the reported output-start state so no additional headers/body are written after output has begun. + * + * @param writer PrintWriter used for writing textual HTTP response headers. + * @param output Raw OutputStream used for writing the response body bytes. + */ + private fun handleBsEndpoint( + writer: PrintWriter, + output: java.io.OutputStream, + ) { + if (debugEnabled) log.debug("Entering handleBsEndpoint().") + if (clearCacheEnabled) templateCache.clear() + + var outputStarted = false + + try { + outputStarted = realHandleBsEndpoint(writer, output) + } catch (e: Exception) { + log.error("Error handling /pr/bs endpoint: {}", e.message) + sendError(writer, output, httpInternalServerError, "Internal Server Error 6", "Error generating bookshelf HTML.", outputStarted) + } + + if (debugEnabled) log.debug("Leaving handleBsEndpoint().") + } + + /** + * Writes a small CSS response that shows or hides elements with the + * `.code_on_the_go_experiment` class depending on the server's + * `experimentsEnabled` flag. + */ + private fun handleExEndpoint( + writer: PrintWriter, + output: java.io.OutputStream, + ) { + val flag = if (experimentsEnabled) "{}" else "{display: none;}" + + if (debugEnabled) log.debug("Experiment flag='{}'.", flag) + + sendCSS(writer, output, ".code_on_the_go_experiment $flag") + } + + /** + * Handle the /pr/pr endpoint by opening the project database, delegating page generation + * to realHandlePrEndpoint, and sending an HTTP 500 error if generation fails. + * + * @param writer PrintWriter used to write response headers. + * @param output OutputStream used to write response body bytes. + */ + private fun handlePrEndpoint( + writer: PrintWriter, + output: java.io.OutputStream, + ) { + if (debugEnabled) log.debug("Entering handlePrEndpoint().") + + var projectDatabase: SQLiteDatabase? = null + var outputStarted = false + + try { + projectDatabase = + SQLiteDatabase.openDatabase( + config.projectDatabasePath, + null, + SQLiteDatabase.OPEN_READONLY, + ) + +/* I disagree with CodeRabbit's message, reproduced below. However, the + IDE's "Problems" window says that outputStarted is "always false." + + While writeNormalToClient() can fail in the middle of execution, + making the error reporting code more complicated is likely to + introduce more bugs, rather than helping fix existing ones. --DS, 23-Feb-2026 + + 482-494: ⚠️ Potential issue | 🟡 Minor outputStarted is set too late to protect error handling. -If writeNormalToClient throws after headers are written, outputStarted remains false and the catch path will send a second response. Set/propagate this flag before the first write (e.g., via a mutable flag passed into realHandlePrEndpoint or by setting it just before writeNormalToClient and preserving it on exceptions). +If writeNormalToClient throws after headers are written, outputStarted remains false and the +catch path will send a second response. Set/propagate this flag before the first write (e.g., +via a mutable flag passed into realHandlePrEndpoint or by setting it just before +writeNormalToClient and preserving it on exceptions). Also applies to: 502-557 @@ -675,142 +721,149 @@ call to writeNormalToClient inside realHandlePrEndpoint; then have realHandlePrEndpoint update that flag as soon as headers/body begin to be written so sendError(writer, ...) checks the accurate flag and avoids sending a second response. - */ - outputStarted = realHandlePrEndpoint(writer, output, projectDatabase) - - } catch (e: Exception) { - log.error("Error handling /pr/pr endpoint: {}", e.message) - sendError(writer, output, httpInternalServerError, "Internal Server Error 6", "Error generating database table.", outputStarted) - - } finally { - projectDatabase?.close() - } - - if (debugEnabled) log.debug("Leaving handlePrEndpoint().") - } - - /** - * Builds the Bookshelf content, renders it with the `bookshelf` template, and sends the resulting response to the client. - * - * @param writer PrintWriter for sending HTTP headers and control output. - * @param output OutputStream for writing the response body bytes. - * @return `true` if the templated response was written to the client, `false` if an error response was sent or no output was produced. - */ - private fun realHandleBsEndpoint(writer: PrintWriter, output: java.io.OutputStream) : Boolean { - if (debugEnabled) log.debug("Entering realHandleBsEndpoint().") - - // Database fetch - val sql_query = + */ + outputStarted = realHandlePrEndpoint(writer, output, projectDatabase) + } catch (e: Exception) { + log.error("Error handling /pr/pr endpoint: {}", e.message) + sendError(writer, output, httpInternalServerError, "Internal Server Error 6", "Error generating database table.", outputStarted) + } finally { + projectDatabase?.close() + } + + if (debugEnabled) log.debug("Leaving handlePrEndpoint().") + } + + /** + * Builds the Bookshelf content, renders it with the `bookshelf` template, and sends the resulting response to the client. + * + * @param writer PrintWriter for sending HTTP headers and control output. + * @param output OutputStream for writing the response body bytes. + * @return `true` if the templated response was written to the client, `false` if an error response was sent or no output was produced. + */ + private fun realHandleBsEndpoint( + writer: PrintWriter, + output: java.io.OutputStream, + ): Boolean { + if (debugEnabled) log.debug("Entering realHandleBsEndpoint().") + + // Database fetch + val sqlQuery = """ SELECT '{"result" : [' || group_concat(Item) || ']}' FROM ( - SELECT - JSON_OBJECT( - 'category', IFNULL(BC.category, 'General'), - 'description', BC.description, - 'books', JSON_GROUP_ARRAY(JSON_OBJECT( - 'title', IFNULL(B.title, C.path), - 'description', B.description, - 'link', C.path, - 'pdf', IIF(SUBSTR(C.path, -4) == '.pdf', 1, 0) ) - ) - ) AS Item - FROM Content AS C, - Bookshelf AS B, - BookCategories AS BC - WHERE C.id = B.contentID - AND B.bookCategoryID = BC.id - GROUP BY BC.category - ORDER BY BC.category, - B.title +SELECT + JSON_OBJECT( + 'category', IFNULL(BC.category, 'General'), + 'description', BC.description, + 'books', JSON_GROUP_ARRAY(JSON_OBJECT( + 'title', IFNULL(B.title, C.path), + 'description', B.description, + 'link', C.path, + 'pdf', IIF(SUBSTR(C.path, -4) == '.pdf', 1, 0) ) + ) + ) AS Item +FROM Content AS C, + Bookshelf AS B, + BookCategories AS BC +WHERE C.id = B.contentID +AND B.bookCategoryID = BC.id +GROUP BY BC.category +ORDER BY BC.category, + B.title ); """.trimIndent() - var cursor = database.rawQuery(sql_query, arrayOf()) - lateinit var jsonText : ByteArray - - // Process database fetch - try { - if(!isCursorOneRow(cursor, writer, output)) { - return false - } - - //get the JSON from the bookshelf table - cursor.moveToFirst() - jsonText = cursor.getBlob(0) - if (debugEnabled) log.debug("json content = '${String(jsonText)}'.") - if (debugEnabled) log.debug("before fetch bookshelf template ID = '${bookshelfTemplateId}'") - - //Have we already fetched the template - if (bookshelfTemplateId == -1) { - /* safety first, close the cursor */ - cursor.close() - cursor = database.rawQuery("SELECT id FROM Templates WHERE name = 'bookshelf'", arrayOf()) - - if (!isCursorOneRow(cursor, writer, output)) { - return false - } - - cursor.moveToFirst() - bookshelfTemplateId = cursor.getInt(0); - if (debugEnabled) log.debug("after the fetch bookshelf template ID = '${bookshelfTemplateId}'") - - } - - } catch (e: Exception) { - log.error("Error processing request: {}", e.message) - sendError(writer, output, httpInternalServerError, "Internal Server Error", e.message ?: "") - return false - } finally { - cursor.close() - } - - val result = instantiatePebbleTemplate(bookshelfTemplateId, jsonText, "/bookshelf", "application/json", "none") - - if (debugEnabled) log.debug("Bookshelf result is '{}'.", String(result)) - - writeNormalToClient(writer, output, String(result)) - - if (debugEnabled) log.debug("Leaving realHandleBsEndpoint().") - - return true - } - - - private fun isCursorOneRow(cursor: Cursor, writer: PrintWriter, output: java.io.OutputStream) : Boolean { - if (cursor.count == 1) { - return true - } - if (cursor.count == 0) - sendError(writer, output, httpNotFound, "Corrupt database, no rows found, expected one.") - else - sendError(writer, output, httpInternalServerError, "Corrupt database - found ${cursor.count} rows when 1 was expected.") - return false - } - - - /** - * Builds an HTML table of recent projects from the provided project database and writes it to the client. - * - * @param writer PrintWriter used for writing HTTP response headers. - * @param output OutputStream used for writing the HTTP response body. - * @param projectDatabase Read-only SQLiteDatabase containing the `recent_project_table`. - * @return `true` if an HTML response was written to the client. - */ - private fun realHandlePrEndpoint(writer: PrintWriter, output: java.io.OutputStream, projectDatabase: SQLiteDatabase) : Boolean { - if (debugEnabled) log.debug("Entering realHandlePrEndpoint().") - - val query = """ + var cursor = database.rawQuery(sqlQuery, arrayOf()) + lateinit var jsonText: ByteArray + + // Process database fetch + try { + if (!isCursorOneRow(cursor, writer, output)) { + return false + } + + // get the JSON from the bookshelf table + cursor.moveToFirst() + jsonText = cursor.getBlob(0) + if (debugEnabled) log.debug("json content = '${String(jsonText)}'.") + if (debugEnabled) log.debug("before fetch bookshelf template ID = '$bookshelfTemplateId'") + + // Have we already fetched the template + if (bookshelfTemplateId == -1) { + // safety first, close the cursor + cursor.close() + cursor = database.rawQuery("SELECT id FROM Templates WHERE name = 'bookshelf'", arrayOf()) + + if (!isCursorOneRow(cursor, writer, output)) { + return false + } + + cursor.moveToFirst() + bookshelfTemplateId = cursor.getInt(0) + if (debugEnabled) log.debug("after the fetch bookshelf template ID = '$bookshelfTemplateId'") + } + } catch (e: Exception) { + log.error("Error processing request: {}", e.message) + sendError(writer, output, httpInternalServerError, "Internal Server Error", e.message ?: "") + return false + } finally { + cursor.close() + } + + val result = instantiatePebbleTemplate(bookshelfTemplateId, jsonText, "/bookshelf", "application/json", "none") + + if (debugEnabled) log.debug("Bookshelf result is '{}'.", String(result)) + + writeNormalToClient(writer, output, String(result)) + + if (debugEnabled) log.debug("Leaving realHandleBsEndpoint().") + + return true + } + + private fun isCursorOneRow( + cursor: Cursor, + writer: PrintWriter, + output: java.io.OutputStream, + ): Boolean { + if (cursor.count == 1) { + return true + } + if (cursor.count == 0) { + sendError(writer, output, httpNotFound, "Corrupt database, no rows found, expected one.") + } else { + sendError(writer, output, httpInternalServerError, "Corrupt database - found ${cursor.count} rows when 1 was expected.") + } + return false + } + + /** + * Builds an HTML table of recent projects from the provided project database and writes it to the client. + * + * @param writer PrintWriter used for writing HTTP response headers. + * @param output OutputStream used for writing the HTTP response body. + * @param projectDatabase Read-only SQLiteDatabase containing the `recent_project_table`. + * @return `true` if an HTML response was written to the client. + */ + private fun realHandlePrEndpoint( + writer: PrintWriter, + output: java.io.OutputStream, + projectDatabase: SQLiteDatabase, + ): Boolean { + if (debugEnabled) log.debug("Entering realHandlePrEndpoint().") + + val query = """ SELECT id, - name, - DATETIME(create_at / 1000, 'unixepoch'), - DATETIME(last_modified / 1000, 'unixepoch'), - location, - template_name, - language + name, + DATETIME(create_at / 1000, 'unixepoch'), + DATETIME(last_modified / 1000, 'unixepoch'), + location, + template_name, + language FROM recent_project_table ORDER BY last_modified DESC""" - var html = getTableHtml("Projects", "Projects") + """ + var html = + getTableHtml("Projects", "Projects") + """ Id Name @@ -821,13 +874,13 @@ ORDER BY last_modified DESC""" Language """ - val cursor = projectDatabase.rawQuery(query, arrayOf()) + val cursor = projectDatabase.rawQuery(query, arrayOf()) - try { - if (debugEnabled) log.debug("Retrieved {} rows.", cursor.count) + try { + if (debugEnabled) log.debug("Retrieved {} rows.", cursor.count) - while (cursor.moveToNext()) { - html += """ + while (cursor.moveToNext()) { + html += """ ${escapeHtml(cursor.getString(0) ?: "")} ${escapeHtml(cursor.getString(1) ?: "")} ${escapeHtml(cursor.getString(2) ?: "")} @@ -836,30 +889,33 @@ ORDER BY last_modified DESC""" ${escapeHtml(cursor.getString(5) ?: "")} ${escapeHtml(cursor.getString(6) ?: "")} """ - } + } - html += "" + html += "" + } finally { + cursor.close() + } - } finally { - cursor.close() - } + // May output a lot of stuff but better too much than too little. --DS, 23-Feb-2026 + if (debugEnabled) log.debug("html is '{}'.", html) - if (debugEnabled) log.debug("html is '{}'.", html) // May output a lot of stuff but better too much than too little. --DS, 23-Feb-2026 + writeNormalToClient(writer, output, html) - writeNormalToClient(writer, output, html) + if (debugEnabled) log.debug("Leaving realHandlePrEndpoint().") - if (debugEnabled) log.debug("Leaving realHandlePrEndpoint().") + return true + } - return true - } + /** + * Get HTML for table response page. + */ + private fun getTableHtml( + title: String, + tableName: String, + ): String { + if (debugEnabled) log.debug("Entering getTableHtml(), title='{}', tableName='{}'.", title, tableName) - /** - * Get HTML for table response page. - */ - private fun getTableHtml(title: String, tableName: String): String { - if (debugEnabled) log.debug("Entering getTableHtml(), title='{}', tableName='{}'.", title, tableName) - - return """ + return """ ${escapeHtml(title)} @@ -872,243 +928,279 @@ th { background-color: #f2f2f2; }

${escapeHtml(tableName)}

""" - } - - /** - * Tail of writing table data back to client. - */ - private fun writeNormalToClient(writer: PrintWriter, output: java.io.OutputStream, html: String) { - if (debugEnabled) log.debug("Entering writeNormalToClient(), html='{}'.", html.take(200)) - - val htmlBytes = html.toByteArray(Charsets.UTF_8) - - /* - println() is intentional: the triple-quoted string ends with a single '\n' (after "Connection: close"), - and println() appends the second '\n' to form the required blank-line HTTP header terminator ("\n\n"). --DS, 22-Feb-2026 - */ - writer.println("""HTTP/1.1 200 OK + } + + /** + * Tail of writing table data back to client. + */ + private fun writeNormalToClient( + writer: PrintWriter, + output: java.io.OutputStream, + html: String, + ) { + if (debugEnabled) log.debug("Entering writeNormalToClient(), html='{}'.", html.take(200)) + + val htmlBytes = html.toByteArray(Charsets.UTF_8) + + /* + println() is intentional: the triple-quoted string ends with a single '\n' (after "Connection: close"), + and println() appends the second '\n' to form the required blank-line HTTP header terminator ("\n\n"). --DS, 22-Feb-2026 + */ + writer.println( + """HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Content-Length: ${htmlBytes.size} Connection: close -""") - - output.write(htmlBytes) - output.flush() - } - - /** - * Escapes HTML special characters to prevent XSS attacks. - * Converts <, >, &, ", and ' to their HTML entity equivalents. - */ - private fun escapeHtml(text: String): String { +""", + ) + + output.write(htmlBytes) + output.flush() + } + + /** + * Escapes HTML special characters to prevent XSS attacks. + * Converts <, >, &, ", and ' to their HTML entity equivalents. + */ + private fun escapeHtml(text: String): String { // if (debugEnabled) log.debug("Entering escapeHtml(), text='{}'.", text) - return text - .replace("&", "&") // Must be first to avoid double-escaping - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - .replace("'", "'") - } - - private fun sendError(writer: PrintWriter, output: java.io.OutputStream, code: Int, message: String, details: String = "", outputStarted: Boolean = false) { - if (debugEnabled) log.debug("Entering sendError(), code={}, message='{}', details='{}', outputStarted={}.", code, message, details, outputStarted) - - val messageString = "$code $message" + if (details.isEmpty()) "" else "\n$details" - val bodyBytes = messageString.toByteArray(Charsets.UTF_8) - - if (!outputStarted) { - writer.println( - """HTTP/1.1 $code $message + return text + .replace("&", "&") // Must be first to avoid double-escaping + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") + } + + private fun sendError( + writer: PrintWriter, + output: java.io.OutputStream, + code: Int, + message: String, + details: String = "", + outputStarted: Boolean = false, + ) { + if (debugEnabled) { + log.debug( + "Entering sendError(), code={}, message='{}', details='{}', outputStarted={}.", + code, + message, + details, + outputStarted, + ) + } + + val messageString = "$code $message" + if (details.isEmpty()) "" else "\n$details" + val bodyBytes = messageString.toByteArray(Charsets.UTF_8) + + if (!outputStarted) { + writer.println( + """HTTP/1.1 $code $message Content-Type: text/plain; charset=utf-8 Content-Length: ${bodyBytes.size} Connection: close -""" - ) - output.write(bodyBytes) - output.flush() - } - if (debugEnabled) log.debug("Leaving sendError().") - } - - private fun sendCSS(writer: PrintWriter, output: java.io.OutputStream, message: String) { - if (debugEnabled) log.debug("Entering sendCSS(), message='{}'.", message) - - val bodyBytes = message.toByteArray(Charsets.UTF_8) - - writer.println("""HTTP/1.1 200 OK +""", + ) + output.write(bodyBytes) + output.flush() + } + if (debugEnabled) log.debug("Leaving sendError().") + } + + private fun sendCSS( + writer: PrintWriter, + output: java.io.OutputStream, + message: String, + ) { + if (debugEnabled) log.debug("Entering sendCSS(), message='{}'.", message) + + val bodyBytes = message.toByteArray(Charsets.UTF_8) + + writer.println( + """HTTP/1.1 200 OK Content-Type: text/css; charset=utf-8 Content-Length: ${bodyBytes.size} Cache-Control: no-store Connection: close -""") - - output.write(bodyBytes) - output.flush() - - if (debugEnabled) log.debug("Leaving sendCSS().") - } - - private fun handlePlaygroundExecute( - input: java.io.InputStream, - writer: PrintWriter, - output: java.io.OutputStream, - method: String, - headers: Map - ) { - if (method != "POST") { - return sendError(writer, output, 405, "Method Not Allowed") - } - val contentLengthStr = headers["content-length"] ?: run { - return sendError(writer, output, 400, "Bad Request", "Missing Content-Length") - } - val contentLength = contentLengthStr.toIntOrNull() ?: run { - return sendError(writer, output, 400, "Bad Request", "Invalid Content-Length") - } - if (contentLength <= 0) { - return sendError(writer, output, 400, "Bad Request", "Content-Length must be positive") - } - if (contentLength > 10_000) { - return sendError(writer, output, 413, "Payload Too Large") - } - val body = ByteArray(contentLength) - var offset = 0 - while (offset < contentLength) { - val read = input.read(body, offset, contentLength - offset) - if (read <= 0) { - return sendError(writer, output, 400, "Bad Request", "Input stream interrupted prematurely") - } - offset += read - } - val data = parseFormDataField(body, "data") ?: run { - return sendError(writer, output, 400, "Bad Request", "Missing or empty form field 'data'") - } - if (data.size > 10_000) { - return sendError(writer, output, 413, "Payload Too Large") - } - val workDir = - File(config.fileDirPath, "playground_${System.nanoTime()}_${java.util.UUID.randomUUID()}") - .apply { mkdirs() } - try { - val sourceFile = createFileFromPost(data, workDir) - val result = compileAndRunJava(sourceFile) - val sourceString = data.toString(Charsets.UTF_8) - val responseBody = sourceString + result - val responseBytes = responseBody.toByteArray(Charsets.UTF_8) - writer.println("HTTP/1.1 200 OK") - writer.println("Content-Type: text/plain; charset=utf-8") - writer.println("Content-Length: ${responseBytes.size}") - writer.println() - writer.flush() - output.write(responseBytes) - output.flush() - } finally { - workDir.deleteRecursively() - } - } - - private fun parseFormDataField(body: ByteArray, fieldName: String): ByteArray? { - val bodyStr = body.toString(Charsets.UTF_8) - val pairs = bodyStr.split("&") - for (pair in pairs) { - val eq = pair.indexOf('=') - if (eq < 0) continue - val key = URLDecoder.decode(pair.substring(0, eq), "UTF-8") - if (key != fieldName) continue - val value = pair.substring(eq + 1) - val decoded = URLDecoder.decode(value, "UTF-8") - if (decoded.isEmpty()) return null - return decoded.toByteArray(Charsets.UTF_8) - } - return null - } - - private fun createFileFromPost(data: ByteArray, workDir: File): File { - require(data.size <= 10_000) { "data exceeds 10000 bytes" } - val file = File(workDir, "Playground.java") - file.writeBytes(data) - return file - } - - private fun compileAndRunJava(sourceFile: File): String { - val dir = sourceFile.parentFile - val fileName = sourceFile.nameWithoutExtension - val classFile = File(dir, "$fileName.class") - classFile.delete() - val directoryPath = config.fileDirPath - val javacPath = "$directoryPath/usr/bin/javac" - val javaPath = "$directoryPath/usr/bin/java" - val filePath = sourceFile.absolutePath - - val compileTimeoutSec = 60L - val runTimeoutSec = 120L - val destroyWaitSec = 5L - - try { - val javac = ProcessBuilder(javacPath, filePath) - .directory(dir) - .redirectErrorStream(true) - .start() - javac.outputStream.close() - val compileOutputRef = AtomicReference("") - val compileReader = - Thread { - compileOutputRef.set( - javac.inputStream.bufferedReader().readText() - ) - } - compileReader.start() - val compileDone = - javac.waitFor(compileTimeoutSec, TimeUnit.SECONDS) - if (!compileDone) { - javac.destroyForcibly() - javac.waitFor(destroyWaitSec, TimeUnit.SECONDS) - compileReader.join(1000) - return "Compilation timed out after ${compileTimeoutSec}s:\n${compileOutputRef.get()}" - } - compileReader.join(2000) - val compileOutput = compileOutputRef.get() - if (javac.exitValue() != 0) { - return "Compilation failed:\n$compileOutput" - } - - val java = - ProcessBuilder( - javaPath, - "-cp", - dir?.absolutePath ?: "", - fileName - ) - .directory(dir) - .redirectErrorStream(true) - .start() - java.outputStream.close() - val runOutputRef = AtomicReference("") - val runReader = - Thread { - runOutputRef.set( - java.inputStream.bufferedReader().readText() - ) - } - runReader.start() - val runDone = java.waitFor(runTimeoutSec, TimeUnit.SECONDS) - if (!runDone) { - java.destroyForcibly() - java.waitFor(destroyWaitSec, TimeUnit.SECONDS) - runReader.join(1000) - return "Execution timed out after ${runTimeoutSec}s:\n${runOutputRef.get()}" - } - runReader.join(2000) - val runOutput = runOutputRef.get() - - return if (compileOutput.isNotBlank()) { - "Compile output\n $compileOutput\n Program output\n$runOutput" - } else { - "Program output\n $runOutput" - } - } catch (e: InterruptedException) { - Thread.currentThread().interrupt() - return "Compilation or execution interrupted." - } - } -} \ No newline at end of file +""", + ) + + output.write(bodyBytes) + output.flush() + + if (debugEnabled) log.debug("Leaving sendCSS().") + } + + private fun handlePlaygroundExecute( + input: java.io.InputStream, + writer: PrintWriter, + output: java.io.OutputStream, + method: String, + headers: Map, + ) { + if (method != "POST") { + return sendError(writer, output, 405, "Method Not Allowed") + } + val contentLengthStr = + headers["content-length"] ?: run { + return sendError(writer, output, 400, "Bad Request", "Missing Content-Length") + } + val contentLength = + contentLengthStr.toIntOrNull() ?: run { + return sendError(writer, output, 400, "Bad Request", "Invalid Content-Length") + } + if (contentLength <= 0) { + return sendError(writer, output, 400, "Bad Request", "Content-Length must be positive") + } + if (contentLength > 10_000) { + return sendError(writer, output, 413, "Payload Too Large") + } + val body = ByteArray(contentLength) + var offset = 0 + while (offset < contentLength) { + val read = input.read(body, offset, contentLength - offset) + if (read <= 0) { + return sendError(writer, output, 400, "Bad Request", "Input stream interrupted prematurely") + } + offset += read + } + val data = + parseFormDataField(body, "data") ?: run { + return sendError(writer, output, 400, "Bad Request", "Missing or empty form field 'data'") + } + if (data.size > 10_000) { + return sendError(writer, output, 413, "Payload Too Large") + } + val workDir = + File(config.fileDirPath, "playground_${System.nanoTime()}_${java.util.UUID.randomUUID()}") + .apply { mkdirs() } + try { + val sourceFile = createFileFromPost(data, workDir) + val result = compileAndRunJava(sourceFile) + val sourceString = data.toString(Charsets.UTF_8) + val responseBody = sourceString + result + val responseBytes = responseBody.toByteArray(Charsets.UTF_8) + writer.println("HTTP/1.1 200 OK") + writer.println("Content-Type: text/plain; charset=utf-8") + writer.println("Content-Length: ${responseBytes.size}") + writer.println() + writer.flush() + output.write(responseBytes) + output.flush() + } finally { + workDir.deleteRecursively() + } + } + + private fun parseFormDataField( + body: ByteArray, + fieldName: String, + ): ByteArray? { + val bodyStr = body.toString(Charsets.UTF_8) + val pairs = bodyStr.split("&") + for (pair in pairs) { + val eq = pair.indexOf('=') + if (eq < 0) continue + val key = URLDecoder.decode(pair.substring(0, eq), "UTF-8") + if (key != fieldName) continue + val value = pair.substring(eq + 1) + val decoded = URLDecoder.decode(value, "UTF-8") + if (decoded.isEmpty()) return null + return decoded.toByteArray(Charsets.UTF_8) + } + return null + } + + private fun createFileFromPost( + data: ByteArray, + workDir: File, + ): File { + require(data.size <= 10_000) { "data exceeds 10000 bytes" } + val file = File(workDir, "Playground.java") + file.writeBytes(data) + return file + } + + private fun compileAndRunJava(sourceFile: File): String { + val dir = sourceFile.parentFile + val fileName = sourceFile.nameWithoutExtension + val classFile = File(dir, "$fileName.class") + classFile.delete() + val directoryPath = config.fileDirPath + val javacPath = "$directoryPath/usr/bin/javac" + val javaPath = "$directoryPath/usr/bin/java" + val filePath = sourceFile.absolutePath + + val compileTimeoutSec = 60L + val runTimeoutSec = 120L + val destroyWaitSec = 5L + + try { + val javac = + ProcessBuilder(javacPath, filePath) + .directory(dir) + .redirectErrorStream(true) + .start() + javac.outputStream.close() + val compileOutputRef = AtomicReference("") + val compileReader = + Thread { + compileOutputRef.set( + javac.inputStream.bufferedReader().readText(), + ) + } + compileReader.start() + val compileDone = + javac.waitFor(compileTimeoutSec, TimeUnit.SECONDS) + if (!compileDone) { + javac.destroyForcibly() + javac.waitFor(destroyWaitSec, TimeUnit.SECONDS) + compileReader.join(1000) + return "Compilation timed out after ${compileTimeoutSec}s:\n${compileOutputRef.get()}" + } + compileReader.join(2000) + val compileOutput = compileOutputRef.get() + if (javac.exitValue() != 0) { + return "Compilation failed:\n$compileOutput" + } + + val java = + ProcessBuilder( + javaPath, + "-cp", + dir?.absolutePath ?: "", + fileName, + ).directory(dir) + .redirectErrorStream(true) + .start() + java.outputStream.close() + val runOutputRef = AtomicReference("") + val runReader = + Thread { + runOutputRef.set( + java.inputStream.bufferedReader().readText(), + ) + } + runReader.start() + val runDone = java.waitFor(runTimeoutSec, TimeUnit.SECONDS) + if (!runDone) { + java.destroyForcibly() + java.waitFor(destroyWaitSec, TimeUnit.SECONDS) + runReader.join(1000) + return "Execution timed out after ${runTimeoutSec}s:\n${runOutputRef.get()}" + } + runReader.join(2000) + val runOutput = runOutputRef.get() + + return if (compileOutput.isNotBlank()) { + "Compile output\n $compileOutput\n Program output\n$runOutput" + } else { + "Program output\n $runOutput" + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + return "Compilation or execution interrupted." + } + } +} From b474dcb2f53c09733a4f16208cbc04a6aebb0546 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Wed, 5 Aug 2026 16:43:08 -0700 Subject: [PATCH 3/4] ADFA-5035: Address code review findings - 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. --- .../androidide/localWebServer/WebServer.kt | 16 ++- .../localWebServer/WebServerTest.kt | 124 ++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt index 0eab06d677..ac07c03b70 100644 --- a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt +++ b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt @@ -132,7 +132,10 @@ class WebServer( /** * Stops the server by closing the listening socket. Safe to call from any thread. - * Causes [start]'s accept loop to exit. No-op if not started or already stopped. + * Causes [start]'s accept loop to exit. If [start] hasn't bound the socket yet -- + * including if it hasn't been called at all -- this still records that a stop was + * requested, so [start] aborts before binding instead of leaving an orphaned, + * unstoppable listener; only the socket-close side of shutdown is a no-op then. */ fun stop() { synchronized(lifecycleLock) { @@ -237,6 +240,17 @@ class WebServer( if (::serverSocket.isInitialized) { serverSocket.close() } + // database is opened before the stopRequested check that can abort start() + // early (and before the accept loop on every other exit path), so it must be + // closed here too, not just serverSocket -- isInitialized guards the case + // where opening it above failed and this finally still runs. + if (::database.isInitialized) { + try { + database.close() + } catch (e: Exception) { + log.error("Cannot close database: {}", e.message) + } + } TrafficStats.clearThreadStatsTag() } } diff --git a/app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt b/app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt new file mode 100644 index 0000000000..ef1e18de8f --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt @@ -0,0 +1,124 @@ +package com.itsaky.androidide.localWebServer + +import android.database.sqlite.SQLiteDatabase +import android.net.TrafficStats +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.TimeUnit + +// Covers the ADFA-5035 fix: start()'s bind and stop()'s close are serialized on a +// shared lock, with a stopRequested flag, so no ordering of the two calls can leave +// serverSocket bound-but-orphaned. The first test needs no real concurrency at all +// (stop() fully happens-before start()); the second uses a bounded, connect-based +// poll as the readiness signal instead of a fixed sleep. +class WebServerTest { + @Before + fun setup() { + mockkStatic(TrafficStats::class) + every { TrafficStats.setThreadStatsTag(any()) } returns Unit + every { TrafficStats.clearThreadStatsTag() } returns Unit + + // start() opens config.databasePath before ever reaching the bind step; + // stub it out since these tests exercise the bind/stop lifecycle, not the + // HTTP-serving behavior that depends on real database content. + mockkStatic(SQLiteDatabase::class) + every { + SQLiteDatabase.openDatabase(any(), isNull(), any()) + } returns mockk(relaxed = true) + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun testConfig(port: Int) = + ServerConfig( + port = port, + databasePath = "/nonexistent/test.db", + fileDirPath = "/tmp", + debugDatabasePath = "/nonexistent/debug.db", + debugEnablePath = "/nonexistent/debug-flag", + experimentsEnablePath = "/nonexistent/exp-flag", + clearCacheEnablePath = "/nonexistent/cs0-flag", + projectDatabasePath = "/nonexistent/recent-projects.db", + ) + + private fun freePort(): Int = ServerSocket(0).use { it.localPort } + + private fun assertPortIsFree(port: Int) { + ServerSocket().apply { reuseAddress = true }.use { probe -> + probe.bind(InetSocketAddress("localhost", port)) + assertTrue("Expected to rebind port $port", probe.isBound) + } + } + + @Test + fun `stop before start prevents the socket from ever binding`() { + val port = freePort() + val server = WebServer(testConfig(port)) + + server.stop() + // stopRequested is now true, so start() must abort inside its synchronized + // bind block without ever calling ServerSocket.bind(). Run it on a joined, + // bounded-timeout thread rather than calling it inline: if this fix ever + // regresses, start() binds anyway and blocks forever in its accept loop, + // and an inline call would hang this test (and the whole test JVM) instead + // of failing it. + val serverThread = Thread { server.start() }.apply { isDaemon = true } + serverThread.start() + serverThread.join(2_000) + assertFalse("Expected start() to return once stop() had already been requested", serverThread.isAlive) + + // If start() had bound anyway, this second bind on the same port would + // throw BindException ("Address already in use"). + assertPortIsFree(port) + } + + @Test + fun `start then stop closes the socket so the port can be reused`() { + val port = freePort() + val server = WebServer(testConfig(port)) + + val serverThread = Thread { server.start() }.apply { isDaemon = true } + serverThread.start() + try { + awaitPortBound(port) + } finally { + server.stop() + serverThread.join(2_000) + } + + assertPortIsFree(port) + } + + // Polls by attempting an actual TCP connect rather than sleeping a fixed + // duration: as soon as WebServer's accept() loop is listening, the connect + // succeeds, which is the readiness signal. (A bind-then-unbind probe was + // tried first and was itself racy against the server's own bind.) The small + // sleep between attempts matters -- a bare spin loop can starve the JVM's + // other threads, including the one running WebServer.start(), of a chance to + // run at all on a constrained number of cores. + private fun awaitPortBound(port: Int) { + val deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadlineNanos) { + try { + Socket().use { it.connect(InetSocketAddress("localhost", port), 200) } + return + } catch (_: Exception) { + Thread.sleep(10) + } + } + error("WebServer did not bind port $port in time") + } +} From 3b4a7c4e3a9d67ae522c387e6d57791c1b92f3c7 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Wed, 5 Aug 2026 16:49:36 -0700 Subject: [PATCH 4/4] ADFA-5035: Fix outputStarted timing in handleBsEndpoint/handlePrEndpoint 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. --- .../androidide/localWebServer/WebServer.kt | 48 +++++-------------- 1 file changed, 12 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt index ac07c03b70..0b76b64d2d 100644 --- a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt +++ b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt @@ -653,7 +653,7 @@ class WebServer( var outputStarted = false try { - outputStarted = realHandleBsEndpoint(writer, output) + outputStarted = realHandleBsEndpoint(writer, output) { outputStarted = true } } catch (e: Exception) { log.error("Error handling /pr/bs endpoint: {}", e.message) sendError(writer, output, httpInternalServerError, "Internal Server Error 6", "Error generating bookshelf HTML.", outputStarted) @@ -702,41 +702,7 @@ class WebServer( SQLiteDatabase.OPEN_READONLY, ) -/* I disagree with CodeRabbit's message, reproduced below. However, the - IDE's "Problems" window says that outputStarted is "always false." - - While writeNormalToClient() can fail in the middle of execution, - making the error reporting code more complicated is likely to - introduce more bugs, rather than helping fix existing ones. --DS, 23-Feb-2026 - - 482-494: ⚠️ Potential issue | 🟡 Minor - -outputStarted is set too late to protect error handling. - -If writeNormalToClient throws after headers are written, outputStarted remains false and the -catch path will send a second response. Set/propagate this flag before the first write (e.g., -via a mutable flag passed into realHandlePrEndpoint or by setting it just before -writeNormalToClient and preserving it on exceptions). - -Also applies to: 502-557 - -🤖 Prompt for AI Agents - -Verify each finding against the current code and only fix it if needed. - -In `@app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt` around -lines 482 - 494, The catch block can send a second response because -outputStarted is only set after realHandlePrEndpoint returns; ensure the -"response started" flag is set before any write occurs by changing -realHandlePrEndpoint to accept and update a mutable flag (e.g., pass a -BooleanWrapper/MutableBoolean or an AtomicBoolean named outputStarted into -realHandlePrEndpoint) or by setting outputStarted immediately before the first -call to writeNormalToClient inside realHandlePrEndpoint; then have -realHandlePrEndpoint update that flag as soon as headers/body begin to be -written so sendError(writer, ...) checks the accurate flag and avoids sending a -second response. - */ - outputStarted = realHandlePrEndpoint(writer, output, projectDatabase) + outputStarted = realHandlePrEndpoint(writer, output, projectDatabase) { outputStarted = true } } catch (e: Exception) { log.error("Error handling /pr/pr endpoint: {}", e.message) sendError(writer, output, httpInternalServerError, "Internal Server Error 6", "Error generating database table.", outputStarted) @@ -752,11 +718,15 @@ second response. * * @param writer PrintWriter for sending HTTP headers and control output. * @param output OutputStream for writing the response body bytes. + * @param markOutputStarted Invoked right before the first response byte is written, so the + * caller's "did we already respond" flag is accurate even if the write itself then fails + * partway through -- not just after this function returns. * @return `true` if the templated response was written to the client, `false` if an error response was sent or no output was produced. */ private fun realHandleBsEndpoint( writer: PrintWriter, output: java.io.OutputStream, + markOutputStarted: () -> Unit, ): Boolean { if (debugEnabled) log.debug("Entering realHandleBsEndpoint().") @@ -827,6 +797,7 @@ ORDER BY BC.category, if (debugEnabled) log.debug("Bookshelf result is '{}'.", String(result)) + markOutputStarted() writeNormalToClient(writer, output, String(result)) if (debugEnabled) log.debug("Leaving realHandleBsEndpoint().") @@ -856,12 +827,16 @@ ORDER BY BC.category, * @param writer PrintWriter used for writing HTTP response headers. * @param output OutputStream used for writing the HTTP response body. * @param projectDatabase Read-only SQLiteDatabase containing the `recent_project_table`. + * @param markOutputStarted Invoked right before the first response byte is written, so the + * caller's "did we already respond" flag is accurate even if the write itself then fails + * partway through -- not just after this function returns. * @return `true` if an HTML response was written to the client. */ private fun realHandlePrEndpoint( writer: PrintWriter, output: java.io.OutputStream, projectDatabase: SQLiteDatabase, + markOutputStarted: () -> Unit, ): Boolean { if (debugEnabled) log.debug("Entering realHandlePrEndpoint().") @@ -913,6 +888,7 @@ ORDER BY last_modified DESC""" // May output a lot of stuff but better too much than too little. --DS, 23-Feb-2026 if (debugEnabled) log.debug("html is '{}'.", html) + markOutputStarted() writeNormalToClient(writer, output, html) if (debugEnabled) log.debug("Leaving realHandlePrEndpoint().")