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 0b76b64d2d..a16d05f1c0 100644 --- a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt +++ b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt @@ -29,8 +29,15 @@ import java.sql.Date import java.text.SimpleDateFormat import java.util.Locale import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.SynchronousQueue +import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write data class ServerConfig( val port: Int = 6174, @@ -52,6 +59,16 @@ data class ServerConfig( "/Download/CodeOnTheGo.webserver.cs0", // Yes, this is hack code. val projectDatabasePath: String = "/data/data/com.itsaky.androidide/databases/RecentProject_database", + // ADFA-5172: accept-loop iterations slower than this get one diagnostic log line. Zero reports + // every iteration's serving time (and silences the accept-wait half, whose "the last iteration + // was served promptly" test cannot hold at zero); a negative value is clamped to zero. + val stallThresholdMs: Long = 200, + // ADFA-5175: requests are served on a pool of at most this many threads. WebView opens up + // to 6 connections per host, so 16 leaves headroom for a second client. + val maxWorkerThreads: Int = 16, + // ADFA-5175: how often the sdcard debug database may be stat'ed. It lives on FUSE-backed + // emulated storage, and it is a developer-only override, so once a second is plenty. + val debugDatabaseCheckIntervalMs: Long = 1000, ) data class JavaExecutionResult( @@ -75,6 +92,9 @@ class WebServer( private var stopRequested = false private lateinit var serverSocket: ServerSocket private lateinit var database: SQLiteDatabase + + // Written under databaseLock's write lock, read without it in the fast path, hence volatile. + @Volatile private var databaseTimestamp: Long = -1 private val log = LoggerFactory.getLogger(WebServer::class.java) private val debugEnabled: Boolean = File(config.debugEnablePath).exists() @@ -94,12 +114,54 @@ class WebServer( .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) .create() private val dbContextType = object : TypeToken>() {}.type + + // Read and written by any worker; -1 means "not fetched yet". Two workers racing to fetch it + // both write the same id, so a plain volatile is enough. + @Volatile private var bookshelfTemplateId: Int = -1 private val httpInternalServerError = 500 private val httpNotFound = 404 private val contentChunkSize = 1024 * 1024 + // Sentinel for "no iteration has finished yet", distinct from an iteration that waited 0 ms. + private val noPreviousIteration = -1L + + private val stallThresholdNanos = TimeUnit.MILLISECONDS.toNanos(config.stallThresholdMs.coerceAtLeast(0)) + + // A park longer than this is an idle server, not a lost handshake: Linux's SYN retransmission + // ladder is 1 s, 3 s, 7 s, so anything past it is nobody browsing rather than ADFA-5172. + private val maxReportedAcceptWaitNanos = TimeUnit.SECONDS.toNanos(10) + + // Accept-thread-only state for ADFA-5172's report: how long the last accept() waited, and the + // wait before it. Only that one thread reads or writes them, so plain vars are safe. + private var acceptWaitNanos = 0L + private var previousAcceptWaitNanos = noPreviousIteration + + // Hal Eisen: required to fix StrictMode.VmPolicy.Builder.detectUntaggedSockets(). + private val socketStatsTag = 0xC0DE + + // Serializes swapping `database` (which closes the old handle) against the workers reading + // through it. Readers never block each other; only a swap waits. + private val databaseLock = ReentrantReadWriteLock() + + private val debugDatabaseCheckIntervalNanos = TimeUnit.MILLISECONDS.toNanos(config.debugDatabaseCheckIntervalMs) + + // Start one interval in the past so the first request still checks for a debug database. + private val lastDebugDatabaseCheckNanos = AtomicLong(System.nanoTime() - debugDatabaseCheckIntervalNanos) + + // Serves accepted connections, so a slow or idle client cannot delay the next accept(). + private var workers: ThreadPoolExecutor? = null + + // Live client sockets. Shutdown closes them, which is what unblocks a worker parked in a + // socket read -- shutdownNow()'s interrupt does not, since stream reads ignore it. + private val liveSockets: MutableSet = ConcurrentHashMap.newKeySet() + + private val workerShutdownTimeoutSeconds = 2L + + // How long an idle worker thread sticks around before the pool reclaims it. + private val workerIdleTimeoutSeconds = 30L + // 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( @@ -150,8 +212,7 @@ class WebServer( } fun start() { - // Hal Eisen: Required to fix StrictMode.VmPolicy.Builder.detectUntaggedSockets() - TrafficStats.setThreadStatsTag(0xC0DE) + TrafficStats.setThreadStatsTag(socketStatsTag) try { log.info( "Starting WebServer on {}, port {}, debugEnabled={}, debugEnablePath='{}', " + @@ -187,51 +248,22 @@ class WebServer( } 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'.") + val pool = newWorkerPool() + workers = pool - 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() + while (true) { + val clientSocket = acceptNextClient() ?: break - // CodeRabbit objects to the following line because clientSocket may print out as "null." This is intentional. --DS - if (debugEnabled) log.debug("clientSocket was {}.", clientSocket) + // The worker owns the socket from here, including closing it. Registered before the + // task starts, not inside it: shutdown closes what is registered to unblock workers, + // and a socket registered by the worker itself can be missed in that window. + liveSockets.add(clientSocket) + try { + pool.execute { handleConnection(clientSocket) } + } catch (e: RejectedExecutionException) { + log.warn("All {} workers are busy; dropping the connection {}.", config.maxWorkerThreads, clientSocket) + liveSockets.remove(clientSocket) + closeQuietly(clientSocket) } } } catch (e: Exception) { @@ -240,13 +272,34 @@ class WebServer( if (::serverSocket.isInitialized) { serverSocket.close() } + + // Workers have to finish before the database closes below, or one of them queries a + // handle this thread has already closed. Closing their sockets is what gets them out + // of a blocking read; the interrupt from shutdownNow() alone would not. + workers?.let { pool -> + pool.shutdownNow() + liveSockets.forEach { closeQuietly(it) } + try { + if (!pool.awaitTermination(workerShutdownTimeoutSeconds, TimeUnit.SECONDS)) { + log.warn("Workers still running {} s after shutdown; closing the database anyway.", workerShutdownTimeoutSeconds) + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } + } + workers = null + // 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. + // + // Under the write lock, because the drain above is not a guarantee: awaitTermination can + // time out, and a worker that outlived it holds the read lock across its query. Taking + // the write lock waits for that worker instead of closing the handle underneath it. if (::database.isInitialized) { try { - database.close() + databaseLock.write { database.close() } } catch (e: Exception) { log.error("Cannot close database: {}", e.message) } @@ -255,6 +308,224 @@ class WebServer( } } + /** + * Waits for the next connection, and reports the wait if it looks like ADFA-5172's stall. Null + * means the listening socket closed and the loop is done; an accept that failed for any other + * reason is logged and retried, since one bad accept is not a reason to stop serving. + */ + private fun acceptNextClient(): Socket? { + while (true) { + try { + if (debugEnabled) log.debug("About to call accept() on the server socket, {}.", serverSocket) + + val acceptStartNanos = System.nanoTime() + val clientSocket = serverSocket.accept() + acceptWaitNanos = System.nanoTime() - acceptStartNanos + reportAcceptWait(acceptWaitNanos, previousAcceptWaitNanos) + previousAcceptWaitNanos = acceptWaitNanos + + if (debugEnabled) log.debug("Returned from socket accept(), clientSocket is {}.", clientSocket) + return 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 (isSocketClosed(e)) { + if (debugEnabled) log.debug("WebServer socket closed, shutting down.") + return null + } + log.error("Accept() failed: {}", e.message) + } + } + } + + private fun sendInternalServerError(clientSocket: Socket) { + try { + val output = clientSocket.outputStream + + sendError(PrintWriter(output, true), output, httpInternalServerError, "Internal Server Error 1") + } catch (e: Exception) { + log.error("Error sending error response: {}", e.message) + } + } + + /** A closed socket reports itself only in the exception's message, hence the string test. */ + private fun isSocketClosed(e: java.net.SocketException): Boolean = e.message?.contains("Closed", ignoreCase = true) == true + + /** + * Serves one connection and closes it. Runs on a worker, so the accept loop is free to take + * the next connection while this one is still being answered. + */ + private fun handleConnection(clientSocket: Socket) { + val startNanos = System.nanoTime() + + try { + handleClient(clientSocket) + } 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 && isSocketClosed(e)) { + if (debugEnabled) log.debug("Client disconnected: {}", e.message) + } else { + log.error("Error handling client: {}", e.message) + sendInternalServerError(clientSocket) + } + } finally { + liveSockets.remove(clientSocket) + closeQuietly(clientSocket) + reportSlowRequest(System.nanoTime() - startNanos, clientSocket) + + if (debugEnabled) log.debug("Served and closed clientSocket {}.", clientSocket) + } + } + + /** + * Threads are created on demand up to [ServerConfig.maxWorkerThreads] and reused. The queue is + * a [SynchronousQueue] so a burst grows the pool instead of lining up behind a busy worker -- + * queueing would reintroduce the head-of-line delay this pool exists to remove. + */ + private fun newWorkerPool(): ThreadPoolExecutor { + val created = AtomicLong() + + return ThreadPoolExecutor( + 1, + config.maxWorkerThreads, + workerIdleTimeoutSeconds, + TimeUnit.SECONDS, + SynchronousQueue(), + ) { runnable -> + Thread({ + // Thread-local, so it has to be set on every worker, not just the accept thread. + TrafficStats.setThreadStatsTag(socketStatsTag) + runnable.run() + }, "WebServer-worker-${created.incrementAndGet()}").apply { isDaemon = true } + } + } + + private fun closeQuietly(socket: Socket) { + try { + socket.close() + } catch (e: Exception) { + if (debugEnabled) log.debug("Cannot close client socket: {}", e.message) + } + } + + /** + * Reports a long wait in accept(), for ADFA-5172's periodic ~1 s stall. A long wait is just an + * idle server, so only the first one after a busy stretch is reported: that is the stall under + * sustained load, not a user who stopped browsing the documentation. Deliberately independent + * of [debugEnabled], whose ~10 log lines per request perturb the timing being measured. + */ + private fun reportAcceptWait( + acceptWaitNanos: Long, + previousAcceptWaitNanos: Long, + ) { + if (!shouldReportAcceptWait(acceptWaitNanos, previousAcceptWaitNanos)) return + + log.warn( + "Waited {} ms in accept() for a connection under load (ADFA-5172: the loop was ready, " + + "so the delay is the handshake, not this server).", + millis(acceptWaitNanos), + ) + } + + /** + * Whether a wait in accept() is worth a line. Waiting is normal, so this reports only a wait + * that looks like ADFA-5172's stall: long enough to be a retransmitted handshake, short enough + * not to be an idle server, and preceded by an iteration that was served promptly -- meaning + * requests were arriving steadily right up to the stall. + * + * [previousAcceptWaitNanos] is [noPreviousIteration] until one iteration has completed, so the + * very first request after startup is never reported. + */ + internal fun shouldReportAcceptWait( + acceptWaitNanos: Long, + previousAcceptWaitNanos: Long, + ): Boolean { + val loadWasSteady = previousAcceptWaitNanos in 0 until stallThresholdNanos + return loadWasSteady && acceptWaitNanos >= stallThresholdNanos && acceptWaitNanos <= maxReportedAcceptWaitNanos + } + + /** The other half of ADFA-5172's instrumentation: time actually spent serving a connection. */ + private fun reportSlowRequest( + elapsedNanos: Long, + clientSocket: Socket, + ) { + if (elapsedNanos < stallThresholdNanos) return + + log.warn("Took {} ms to serve {} (ADFA-5172).", millis(elapsedNanos), clientSocket) + } + + private fun millis(nanos: Long): Long = TimeUnit.NANOSECONDS.toMillis(nanos) + + /** + * Swaps to the sdcard debug database once a newer one appears. Opens the replacement before + * closing the old handle, so a failed open leaves the server serving the database it had. + * Takes the write lock, so a caller must not already hold the read lock. + */ + private fun maybeSwapDebugDatabase() { + val debugDatabaseTimestamp = debugDatabaseTimestampIfDue() ?: return + if (debugDatabaseTimestamp <= databaseTimestamp) return + + databaseLock.write { + // Another worker may have swapped while this one waited for the lock. + if (debugDatabaseTimestamp <= databaseTimestamp) return@write + + // A missing, truncated or unreadable debug database must not fail the request that + // happened to notice it: log it and keep serving the database already open. The next + // check is one interval away, so a fixed file is picked up on its own. + val replacement = + try { + SQLiteDatabase.openDatabase(config.debugDatabasePath, null, SQLiteDatabase.OPEN_READONLY) + } catch (e: Exception) { + log.error( + "Cannot open the debug database '{}'; still serving '{}': {}", + config.debugDatabasePath, + config.databasePath, + e.message, + ) + return@write + } + + val previous = database + database = replacement + databaseTimestamp = debugDatabaseTimestamp + bookshelfTemplateId = -1 + // Templates are cached by id, and the replacement database can define the same ids with + // different content, so a stale compiled template would render for the new database. + templateCache.clear() + previous.close() + log.info("Swapped to the debug database '{}'.", config.debugDatabasePath) + } + } + + /** + * The debug database's last-modified time, or null when the last check was too recent. The + * path is FUSE-backed emulated storage, so ADFA-5175 rate-limits what used to be a stat on + * every request; one thread wins each interval and the rest skip the check. + */ + private fun debugDatabaseTimestampIfDue(): Long? { + val now = System.nanoTime() + val last = lastDebugDatabaseCheckNanos.get() + if (now - last < debugDatabaseCheckIntervalNanos) return null + if (!lastDebugDatabaseCheckNanos.compareAndSet(last, now)) return null + + val startNanos = System.nanoTime() + val timestamp = getDatabaseTimestamp(config.debugDatabasePath, true) + val elapsedNanos = System.nanoTime() - startNanos + + if (elapsedNanos >= stallThresholdNanos) { + log.warn( + "Stat of '{}' took {} ms; it is on FUSE-backed emulated storage (ADFA-5172).", + config.debugDatabasePath, + millis(elapsedNanos), + ) + } + + return timestamp + } + /** * 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. @@ -329,16 +600,25 @@ class WebServer( 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 - } + // Use a newer documentation.db from the sdcard if one has appeared. Outside the read lock + // below, because swapping takes the write lock and this lock does not upgrade. + maybeSwapDebugDatabase() + // Everything that reads `database` runs under the read lock, so a swap cannot close the + // handle from under this request. Readers do not block each other. + databaseLock.read { serveRequest(writer, output, path, brotliSupported) } + } + + /** + * Answers one parsed request. Called with [databaseLock]'s read lock held, which is what makes + * the shared [database] handle safe to use from a worker thread. + */ + private fun serveRequest( + writer: PrintWriter, + output: java.io.OutputStream, + path: String, + brotliSupported: Boolean, + ) { // Handle the special "pr" endpoint with highest priority if (path.startsWith("pr/", false)) { if (debugEnabled) log.debug("Found a pr/ path, '{}'.", path) diff --git a/app/src/test/java/com/itsaky/androidide/localWebServer/AcceptWaitReportingTest.kt b/app/src/test/java/com/itsaky/androidide/localWebServer/AcceptWaitReportingTest.kt new file mode 100644 index 0000000000..ab2cfadc72 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/localWebServer/AcceptWaitReportingTest.kt @@ -0,0 +1,104 @@ +package com.itsaky.androidide.localWebServer + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.util.concurrent.TimeUnit + +/** + * Covers when a wait in accept() is worth a log line (ADFA-5172). Waiting is the normal state of a + * server with nothing to do, so the interesting case is narrow: long enough to be a retransmitted + * handshake, short enough not to be an idle server, and arriving in the middle of steady traffic. + */ +class AcceptWaitReportingTest { + private val thresholdMs = 200L + + // Every path is given explicitly: ServerConfig's defaults reach for external storage, which a + // JVM test has no stub for. + private fun server(stallThresholdMs: Long = thresholdMs) = + WebServer( + ServerConfig( + port = 0, + 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", + stallThresholdMs = stallThresholdMs, + ), + ) + + private fun millis(value: Long) = TimeUnit.MILLISECONDS.toNanos(value) + + private val noPreviousIteration = -1L + + @Test + fun `a one second wait between promptly served requests is the stall being hunted`() { + assertThat(server().shouldReportAcceptWait(millis(1_020), previousAcceptWaitNanos = millis(0))).isTrue() + } + + @Test + fun `the first request after startup is never reported, however long the wait`() { + assertThat(server().shouldReportAcceptWait(millis(74_000), noPreviousIteration)).isFalse() + assertThat(server().shouldReportAcceptWait(millis(1_020), noPreviousIteration)).isFalse() + } + + @Test + fun `a wait after an already long wait is an idle server, not a stall`() { + assertThat(server().shouldReportAcceptWait(millis(1_020), previousAcceptWaitNanos = millis(30_000))).isFalse() + } + + @Test + fun `a wait far past the retransmission ladder is nobody browsing`() { + assertThat(server().shouldReportAcceptWait(millis(60_000), previousAcceptWaitNanos = millis(5))).isFalse() + } + + @Test + fun `a wait within the retransmission ladder is still reported`() { + // 1 s, 3 s and 7 s are Linux's first three SYN retransmissions. + assertThat(server().shouldReportAcceptWait(millis(3_100), previousAcceptWaitNanos = millis(5))).isTrue() + assertThat(server().shouldReportAcceptWait(millis(7_200), previousAcceptWaitNanos = millis(5))).isTrue() + } + + @Test + fun `an ordinary wait below the threshold is not reported`() { + assertThat(server().shouldReportAcceptWait(millis(30), previousAcceptWaitNanos = millis(5))).isFalse() + } + + @Test + fun `the threshold itself is inclusive, and one millisecond under it is not reported`() { + val server = server() + + assertThat(server.shouldReportAcceptWait(millis(thresholdMs - 1), previousAcceptWaitNanos = millis(5))).isFalse() + assertThat(server.shouldReportAcceptWait(millis(thresholdMs), previousAcceptWaitNanos = millis(5))).isTrue() + assertThat(server.shouldReportAcceptWait(millis(thresholdMs + 1), previousAcceptWaitNanos = millis(5))).isTrue() + } + + @Test + fun `the ten second ceiling is inclusive, and one millisecond over it is an idle server`() { + val server = server() + + assertThat(server.shouldReportAcceptWait(millis(9_999), previousAcceptWaitNanos = millis(5))).isTrue() + assertThat(server.shouldReportAcceptWait(millis(10_000), previousAcceptWaitNanos = millis(5))).isTrue() + assertThat(server.shouldReportAcceptWait(millis(10_001), previousAcceptWaitNanos = millis(5))).isFalse() + } + + @Test + fun `the previous wait counts as steady load right up to the threshold`() { + val server = server() + + assertThat(server.shouldReportAcceptWait(millis(1_020), previousAcceptWaitNanos = millis(thresholdMs - 1))).isTrue() + assertThat(server.shouldReportAcceptWait(millis(1_020), previousAcceptWaitNanos = millis(thresholdMs))).isFalse() + } + + @Test + fun `a zero or negative threshold silences the accept-wait report instead of flooding it`() { + // "The previous iteration waited less than the threshold" cannot hold when the threshold is + // zero, so this branch goes quiet rather than reporting every wait. A negative threshold is + // clamped to zero and behaves the same. The busy-phase report, which has no such + // precondition, does fire for every iteration at these settings. + assertThat(server(stallThresholdMs = 0).shouldReportAcceptWait(millis(1_020), previousAcceptWaitNanos = 0)).isFalse() + assertThat(server(stallThresholdMs = -5).shouldReportAcceptWait(millis(1_020), previousAcceptWaitNanos = 0)).isFalse() + } +} diff --git a/docs/documentation-database.md b/docs/documentation-database.md index 566703ad1b..5125161498 100644 --- a/docs/documentation-database.md +++ b/docs/documentation-database.md @@ -8,7 +8,7 @@ This is a **read-only, prebuilt** database — CoGo never creates or migrates it - Installed path: `context.getDatabasePath("documentation.db")` (`Environment.DOC_DB` in `common/.../utils/Environment.java`), i.e. the app's private `databases/` dir. - Bundled as an asset and extracted on install/update by `BundledAssetsInstaller` / `SplitAssetsInstaller`. -- **Debug override:** if `/sdcard/Download/documentation.db` exists and is newer than the installed copy, `WebServer` and `ToolTipManager` swap to it at request time (timestamp-compared per request, not just at startup) — a fast way to test a new database on-device without reinstalling. `WebServer`'s debug logging and experiment flags are also file-flag-gated under `/sdcard/Download/` (`CodeOnTheGo.webserver.debug`, `CodeOnTheGo.exp`, `CodeOnTheGo.webserver.cs0`). +- **Debug override:** if `/sdcard/Download/documentation.db` exists and is newer than the installed copy, `WebServer` and `ToolTipManager` swap to it at request time (`WebServer` compares timestamps at most once a second, not per request, since the path is FUSE-backed emulated storage) — a fast way to test a new database on-device without reinstalling. `WebServer`'s debug logging and experiment flags are also file-flag-gated under `/sdcard/Download/` (`CodeOnTheGo.webserver.debug`, `CodeOnTheGo.exp`, `CodeOnTheGo.webserver.cs0`). - **Don't trust a local copy's on-disk schema or row content as ground truth without checking freshness first.** Any manually downloaded or debug-override copy is independent of git history — a stale one can have a different schema (e.g. missing `UNIQUE(path)` or `templateId`) or be missing rows that already exist in the current, maintained database. A stale copy caused a real near-miss in ADFA-5088: a SQL script validated against it would have silently overwritten curated production tooltip content for several tags. Diff or re-download before authoring SQL against a local copy's state, not just before shipping it. ## Schema @@ -71,7 +71,7 @@ CREATE TABLE Tooltips ( All three sites below open the file with `SQLiteDatabase.openDatabase(..., OPEN_READONLY)` — no writes, ever, from this app (see ADR 0001 for why raw SQLite is justified here instead of Room). -- **`app/.../localWebServer/WebServer.kt`** — serves Tier 3. On each `GET`, runs: +- **`app/.../localWebServer/WebServer.kt`** — serves Tier 3. Requests are handled on a small worker pool, not on the accept loop, so the shared handle is read under a `ReentrantReadWriteLock` (the debug-database swap takes the write lock). On each `GET`, runs: ```sql SELECT C.content, CT.value, CT.compression, C.templateId