From 0f857e4ccae0d8b96381f8e820a72277eb81f709 Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:07:20 +0900 Subject: [PATCH 1/3] perf: park always-on bridge polls while nothing can write to them Three file polls between the app and the in-guest agent ran for the life of the process, waking it every few hundred milliseconds whether or not anything could have been written: - Claude Code permission bridge: every 250 ms, started by the first session and never cancelled, even after every session had ended. - Schedule bridge: every 800 ms on the application scope. - Guest browser command watcher: every 1 s from a LaunchedEffect, which keeps running while the activity is stopped. Add Flow.pollWhileActive (poll at once when the gate opens, then every interval, and stay suspended with no timer while it is closed) and gate each loop on the condition under which a writer can exist: - permission bridge: a CLI process is registered. The hook that parks a request dies with its process, so with none alive nothing can appear. - schedule bridge: the app is in the foreground or runtimeWork is active. With neither, the device may suspend and the guest freezes with it. - browser command watcher: the app is in the foreground. ScheduleBridge.run now requires the gate rather than defaulting to "always" so a future call site cannot quietly bring the unbounded loop back. Covered by PollWhileActiveTest plus a gating test each for the schedule bridge and the browser watcher. Disabling the gate makes exactly those five fail. Co-Authored-By: Claude Sonnet 5.5 --- .../andcode/AndCodeApplication.kt | 10 +- .../andcode/core/util/FlowExt.kt | 32 +++++ .../feature/browser/BrowserCommandWatcher.kt | 34 ++++-- .../feature/schedule/ScheduleBridge.kt | 22 ++-- .../runtime/local/ClaudeCodeRuntime.kt | 70 +++++++---- .../yugahashimoto/andcode/ui/AndCodeApp.kt | 1 + .../andcode/core/util/PollWhileActiveTest.kt | 113 ++++++++++++++++++ .../browser/BrowserCommandWatcherTest.kt | 43 +++++++ .../feature/schedule/ScheduleBridgeTest.kt | 38 ++++++ 9 files changed, 322 insertions(+), 41 deletions(-) create mode 100644 app/src/test/java/com/yugahashimoto/andcode/core/util/PollWhileActiveTest.kt diff --git a/app/src/main/java/com/yugahashimoto/andcode/AndCodeApplication.kt b/app/src/main/java/com/yugahashimoto/andcode/AndCodeApplication.kt index 71c51e2dc..5c93b19d2 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/AndCodeApplication.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/AndCodeApplication.kt @@ -581,9 +581,15 @@ class AndCodeApplication : Application() { // Re-arm alarms for schedules that were saved in a previous process lifetime. scheduleManager.rescheduleAll() // Let guest agents read and manage schedules through the and-code-schedule MCP server. - // The poll loop is a cheap no-op (one directory stat) while no guest has touched the bridge. + // The guest can only be asking for something while it can run: the app is on screen, or + // something holds runtimeWork (and with it the wake lock). With neither, the device is free + // to suspend and the guest freezes with it, so the loop stays parked instead of polling for + // the life of the process. val scheduleBridge = ScheduleBridge(File(runtimeDirectory, "workspace"), AppScheduleStore(scheduleRepository, scheduleManager)) - applicationScope.launch { scheduleBridge.run() } + val scheduleBridgeActive = + combine(appForeground.foreground, runtimeWork.active) { foreground, working -> foreground || working } + .distinctUntilChanged() + applicationScope.launch { scheduleBridge.run(scheduleBridgeActive) } observeForegroundForRuntimeRestart() scheduleDeferredInitialization() } diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/util/FlowExt.kt b/app/src/main/java/com/yugahashimoto/andcode/core/util/FlowExt.kt index 1a9e907c9..e399845a1 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/util/FlowExt.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/util/FlowExt.kt @@ -2,8 +2,10 @@ package com.yugahashimoto.andcode.core.util import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull @@ -79,3 +81,33 @@ fun Flow.debounceFalseEdge(graceMillis: Long): Flow = if (values.isClosedForReceive) break } } + +/** + * Runs [action] straight away and then every [intervalMillis], for as long as the latest value of + * this flow is `true`; while it is `false` nothing runs and no timer is armed. + * + * Written for the file polls that sit between the app and the in-guest agent (the permission + * bridge, the schedule bridge, the browser command file). Each used to loop for the life of the + * process, so an app that had run once kept waking every few hundred milliseconds to stat a + * directory nobody could have written to - the guest only runs while the device is awake for it, and + * that is exactly when one of these gates is open. Parking the loop on a flow turns "poll forever" + * into "poll while something could be there". + * + * Opening the gate polls at once rather than after a first interval, so whatever arrived while it + * was shut - a request written just before the app was backgrounded, say - is picked up on the way + * back in instead of a beat later. Closing it cancels the wait in progress, so nothing fires after + * the flip; an [action] already running is allowed to finish. Never returns normally while the flow + * stays open, so callers launch it in a scope they cancel. + */ +suspend fun Flow.pollWhileActive( + intervalMillis: Long, + action: suspend () -> Unit, +) { + collectLatest { active -> + if (!active) return@collectLatest + while (true) { + action() + delay(intervalMillis) + } + } +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/browser/BrowserCommandWatcher.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/browser/BrowserCommandWatcher.kt index 60d13457d..5143a3c47 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/browser/BrowserCommandWatcher.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/browser/BrowserCommandWatcher.kt @@ -5,9 +5,10 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import com.yugahashimoto.andcode.core.storage.DeviceStorage +import com.yugahashimoto.andcode.core.util.pollWhileActive import com.yugahashimoto.andcode.runtime.LocalAgent import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext import java.io.File @@ -17,26 +18,41 @@ private const val POLL_INTERVAL_MILLIS = 1000L * Watches the active workspace for a browser command written by the in-guest agent * (`.and-code/browser-command.json`, e.g. `{"action":"open","url":"http://127.0.0.1:8080/"}`) * and opens the guest browser at the requested URL so the user can watch and join in. + * + * Only polls while [foreground] is `true`: opening a browser nobody is looking at achieves nothing, + * and a composition outlives the activity being stopped, so without the gate this read the disk + * every second for as long as the process was alive. A command written while the app was away is + * opened as soon as it returns. */ @Composable fun GuestBrowserCommandWatcher( workspacePath: String?, agent: LocalAgent?, runtimeDirectory: File, + foreground: Flow, onOpenUrl: (String) -> Unit, ) { val openUrl by rememberUpdatedState(onOpenUrl) - LaunchedEffect(workspacePath, agent, runtimeDirectory) { + LaunchedEffect(workspacePath, agent, runtimeDirectory, foreground) { if (workspacePath == null || agent == null) return@LaunchedEffect - while (true) { - delay(POLL_INTERVAL_MILLIS) - val url = + watchBrowserCommands( + active = foreground, + poll = { withContext(Dispatchers.IO) { consumeBrowserOpenCommand(workspacePath, agent, runtimeDirectory, DeviceStorage.mounts()) } - if (url != null) { - openUrl(url) - } - } + }, + onOpenUrl = { url -> openUrl(url) }, + ) } } + +/** The watcher's loop, apart from Compose so the gating can be tested with a virtual clock. */ +internal suspend fun watchBrowserCommands( + active: Flow, + poll: suspend () -> String?, + onOpenUrl: (String) -> Unit, + intervalMillis: Long = POLL_INTERVAL_MILLIS, +) { + active.pollWhileActive(intervalMillis) { poll()?.let(onOpenUrl) } +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridge.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridge.kt index f680cd302..be593e67b 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridge.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridge.kt @@ -1,10 +1,11 @@ package com.yugahashimoto.andcode.feature.schedule +import com.yugahashimoto.andcode.core.util.pollWhileActive import com.yugahashimoto.andcode.data.schedule.CronExpression import com.yugahashimoto.andcode.data.schedule.Schedule import com.yugahashimoto.andcode.data.schedule.ScheduleRepository import com.yugahashimoto.andcode.data.schedule.ScheduleRun -import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import org.json.JSONArray import org.json.JSONObject import java.io.File @@ -92,14 +93,17 @@ class ScheduleBridge( ) { val bridgeDir: File = File(workspaceDir, BRIDGE_RELATIVE_PATH) - /** Runs the poll loop until cancelled; typically launched on the application scope. */ - suspend fun run() { - while (true) { - // A single failing request or IO error must never kill the bridge for the process - // lifetime; drop it and keep polling. - runCatching { pollOnce() } - delay(pollIntervalMillis) - } + /** + * Runs the poll loop until cancelled; typically launched on the application scope. + * + * [active] says whether the guest could be talking to the bridge right now. It is required + * rather than defaulted to "always": a default would let a future call site forget it and + * quietly bring back a loop that polls for the life of the process. + */ + suspend fun run(active: Flow) { + // A single failing request or IO error must never kill the bridge for the process + // lifetime; drop it and keep polling. + active.pollWhileActive(pollIntervalMillis) { runCatching { pollOnce() } } } /** Handles every currently pending request and prunes stale files. */ diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeRuntime.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeRuntime.kt index b61f3aa2c..a5aa8239e 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeRuntime.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeRuntime.kt @@ -8,13 +8,13 @@ import com.yugahashimoto.andcode.core.api.OpenCodeTime import com.yugahashimoto.andcode.core.api.OpenCodeTodo import com.yugahashimoto.andcode.core.api.PromptAttachment import com.yugahashimoto.andcode.core.api.QuestionRequest +import com.yugahashimoto.andcode.core.util.pollWhileActive import com.yugahashimoto.andcode.runtime.PermissionResponse import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -66,6 +66,13 @@ class ClaudeCodeRuntime( ClaudePermissionBridge(File(runtimeDirectory, ClaudePermissionBridge.HOST_DIR_NAME)) private var bridgeWatchJob: Job? = null + /** + * Whether any CLI process is registered in [sessions]; what the permission-bridge watcher polls + * on. Published from every place [sessions] changes, under the same monitor, so the watcher can + * never be left parked while a process that could raise a request exists. + */ + private val hasLiveSessions = MutableStateFlow(false) + /** One live CLI process, plus what is needed to decide whether it can be reused. */ private class SessionProcess( val process: Process, @@ -235,32 +242,45 @@ class ClaudeCodeRuntime( .filter { sessions[it.androidSessionId]?.process?.isAlive == true } .mapNotNull(permissionBridge::toQuestionRequest) + @Synchronized + private fun publishLiveSessions() { + hasLiveSessions.value = sessions.isNotEmpty() + } + + /** + * Starts the watcher that turns permission requests parked on disk into events. It is a single + * long-lived coroutine, but it only polls while a CLI process exists: the hook that parks a + * request dies with its process (see [pendingQuestions]), so with none alive there is nothing + * that could appear. Without that it kept reading the directory every [BRIDGE_POLL_INTERVAL_MILLIS] + * for the rest of the process once any session had ever started. + */ private fun ensureBridgeWatcher() { if (bridgeWatchJob?.isActive == true) return bridgeWatchJob = scope.launch { - while (isActive) { - permissionBridge.pollPending().forEach { request -> - when (request.kind) { - ClaudePermissionBridge.Kind.QUESTION -> { - val question = permissionBridge.toQuestionRequest(request) - if (question != null) { - events.tryEmit(OpenCodeEvent.QuestionAsked(question)) - } else { - events.tryEmit( - OpenCodeEvent.PermissionAsked(permissionBridge.toPermissionRequest(request)), - ) - } - } - else -> - events.tryEmit( - OpenCodeEvent.PermissionAsked(permissionBridge.toPermissionRequest(request)), - ) - } + hasLiveSessions.pollWhileActive(BRIDGE_POLL_INTERVAL_MILLIS) { emitPendingBridgeRequests() } + } + } + + private fun emitPendingBridgeRequests() { + permissionBridge.pollPending().forEach { request -> + when (request.kind) { + ClaudePermissionBridge.Kind.QUESTION -> { + val question = permissionBridge.toQuestionRequest(request) + if (question != null) { + events.tryEmit(OpenCodeEvent.QuestionAsked(question)) + } else { + events.tryEmit( + OpenCodeEvent.PermissionAsked(permissionBridge.toPermissionRequest(request)), + ) } - delay(250) } + else -> + events.tryEmit( + OpenCodeEvent.PermissionAsked(permissionBridge.toPermissionRequest(request)), + ) } + } } fun update() { @@ -304,6 +324,7 @@ class ClaudeCodeRuntime( session.readerJob.cancel() if (session.process.isAlive) session.process.destroyForcibly() } + publishLiveSessions() messageStore.flush() } @@ -400,6 +421,7 @@ class ClaudeCodeRuntime( messageStore.flush() synchronized(this@ClaudeCodeRuntime) { if (sessions[sessionId]?.process === process) sessions.remove(sessionId) + publishLiveSessions() } // stop() cancels this job; the abort path owns the state transitions, and an idle // emitted here would announce the killed run as completed. @@ -417,7 +439,10 @@ class ClaudeCodeRuntime( } return SessionProcess(process, readerJob, parser, permissionMode, directory, model, effort, systemPrompt) - .also { sessions[sessionId] = it } + .also { + sessions[sessionId] = it + publishLiveSessions() + } } private fun processArguments( @@ -768,5 +793,8 @@ class ClaudeCodeRuntime( const val TITLE_TIMEOUT_SECONDS = 45L const val WORKSPACE_COMMAND_TIMEOUT_SECONDS = 60L const val INTERRUPTED_TOOL_ERROR = "Claude Code session ended before the tool result was received" + + /** How often a live session's parked permission requests are looked for. */ + const val BRIDGE_POLL_INTERVAL_MILLIS = 250L } } diff --git a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt index ca67efde7..c0ca2ca71 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt @@ -711,6 +711,7 @@ fun AndCodeApp( workspacePath = chatState.sessionDirectory ?: chatState.selectedWorkspacePath, agent = selectedRuntime?.agent, runtimeDirectory = java.io.File(context.filesDir, "runtime"), + foreground = app.appForeground.foreground, onOpenUrl = { url -> navController.navigate(guestBrowserRoute(url)) { launchSingleTop = true } }, diff --git a/app/src/test/java/com/yugahashimoto/andcode/core/util/PollWhileActiveTest.kt b/app/src/test/java/com/yugahashimoto/andcode/core/util/PollWhileActiveTest.kt new file mode 100644 index 000000000..231c816e8 --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/core/util/PollWhileActiveTest.kt @@ -0,0 +1,113 @@ +package com.yugahashimoto.andcode.core.util + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +private const val INTERVAL_MILLIS = 250L + +/** + * Covers the gate every always-on file poll in the app now sits behind: a loop that has nothing to + * watch for must be a suspended coroutine, not a timer that keeps waking the process every few + * hundred milliseconds for the rest of its life. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PollWhileActiveTest { + @Test + fun `never polls while the gate stays closed`() = + runTest { + val scope = TestScope(StandardTestDispatcher(testScheduler)) + val gate = MutableStateFlow(false) + var polls = 0 + try { + scope.launch { gate.pollWhileActive(INTERVAL_MILLIS) { polls++ } } + advanceTimeBy(100 * INTERVAL_MILLIS) + runCurrent() + + assertEquals(0, polls) + } finally { + scope.cancel() + } + } + + @Test + fun `polls immediately when the gate is open and then once per interval`() = + runTest { + val scope = TestScope(StandardTestDispatcher(testScheduler)) + val gate = MutableStateFlow(true) + var polls = 0 + try { + scope.launch { gate.pollWhileActive(INTERVAL_MILLIS) { polls++ } } + runCurrent() + assertEquals(1, polls) + + advanceTimeBy(INTERVAL_MILLIS) + runCurrent() + assertEquals(2, polls) + + advanceTimeBy(INTERVAL_MILLIS) + runCurrent() + assertEquals(3, polls) + } finally { + scope.cancel() + } + } + + @Test + fun `stops polling as soon as the gate closes`() = + runTest { + val scope = TestScope(StandardTestDispatcher(testScheduler)) + val gate = MutableStateFlow(true) + var polls = 0 + try { + scope.launch { gate.pollWhileActive(INTERVAL_MILLIS) { polls++ } } + runCurrent() + assertEquals(1, polls) + + gate.value = false + runCurrent() + advanceTimeBy(100 * INTERVAL_MILLIS) + runCurrent() + + assertEquals(1, polls) + } finally { + scope.cancel() + } + } + + /** + * Returning to the app has to pick up whatever arrived while it was away, so a reopened gate + * polls at once instead of first waiting out an interval. + */ + @Test + fun `polls straight away when the gate reopens`() = + runTest { + val scope = TestScope(StandardTestDispatcher(testScheduler)) + val gate = MutableStateFlow(true) + var polls = 0 + try { + scope.launch { gate.pollWhileActive(INTERVAL_MILLIS) { polls++ } } + runCurrent() + gate.value = false + runCurrent() + advanceTimeBy(10 * INTERVAL_MILLIS) + runCurrent() + assertEquals(1, polls) + + gate.value = true + runCurrent() + + assertEquals(2, polls) + } finally { + scope.cancel() + } + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/browser/BrowserCommandWatcherTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/browser/BrowserCommandWatcherTest.kt index 2cafaa275..e6365313a 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/feature/browser/BrowserCommandWatcherTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/browser/BrowserCommandWatcherTest.kt @@ -2,6 +2,15 @@ package com.yugahashimoto.andcode.feature.browser import com.yugahashimoto.andcode.core.storage.DeviceStorage import com.yugahashimoto.andcode.runtime.LocalAgent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -125,6 +134,40 @@ class BrowserCommandWatcherTest { assertTrue(file.exists()) } + /** + * The guest browser is only worth opening for someone looking at the app, so the watcher stays + * parked while it is in the background and hands over what arrived the moment it returns. + */ + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `watcher opens a pending command only while the app is in the foreground`() = + runTest { + val foreground = MutableStateFlow(false) + val opened = mutableListOf() + var pending: String? = "https://www.google.com/" + val scope = TestScope(StandardTestDispatcher(testScheduler)) + try { + scope.launch { + watchBrowserCommands( + active = foreground, + intervalMillis = 1_000L, + poll = { pending.also { pending = null } }, + onOpenUrl = { opened += it }, + ) + } + advanceTimeBy(60_000L) + runCurrent() + assertEquals(emptyList(), opened) + + foreground.value = true + runCurrent() + + assertEquals(listOf("https://www.google.com/"), opened) + } finally { + scope.cancel() + } + } + private fun consume( path: String?, agent: LocalAgent? = LocalAgent.CODEX, diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridgeTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridgeTest.kt index 56c38b74b..188ad0bc7 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridgeTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridgeTest.kt @@ -3,6 +3,15 @@ package com.yugahashimoto.andcode.feature.schedule import com.yugahashimoto.andcode.data.schedule.Schedule import com.yugahashimoto.andcode.data.schedule.ScheduleRun import com.yugahashimoto.andcode.data.schedule.ScheduleRunStatus +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -442,4 +451,33 @@ class ScheduleBridgeTest { assertTrue(!orphan.exists()) } + + /** + * Nothing can be waiting on the bridge while the app is backgrounded and no work is in flight - + * the device is free to suspend and the guest freezes with it - so the loop must stay parked + * then, and answer at once when the gate opens again. + */ + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `run answers pending requests only while the gate is open`() = + runTest { + val bridge = bridgeWith(FakeStore()) + val id = requestId() + writeRequest(bridge, id, "list") + val gate = MutableStateFlow(false) + val scope = TestScope(StandardTestDispatcher(testScheduler)) + try { + scope.launch { bridge.run(gate) } + advanceTimeBy(10 * ScheduleBridge.POLL_INTERVAL_MILLIS) + runCurrent() + assertFalse(File(responsesDir(bridge), "$id.json").exists()) + + gate.value = true + runCurrent() + + assertTrue(File(responsesDir(bridge), "$id.json").exists()) + } finally { + scope.cancel() + } + } } From 0f8ad22dbf590afacaa4aae5c06d76ba98fecf5a Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:18:17 +0900 Subject: [PATCH 2/3] fix: drop expired schedule bridge requests and harden session tracking Follow-ups from the pre-PR review of the polling gate. - ScheduleBridge: with the poll parked while the app is backgrounded and idle, a request written in that window used to run on the way back in even though the guest MCP had already given up after 90 s and told the agent the call failed. A create/delete/runNow the agent was told did not happen would then happen anyway, and a retry could duplicate it. Requests older than two minutes are now answered with an "expired" error and discarded. A missing or zero createdAtMs is "unknown" and still runs. - ClaudeCodeRuntime: route every change to the session map through registerSession/unregisterSession, which keep hasLiveSessions in step, so a future edit cannot forget to publish and leave permission prompts silently unanswered. - KDoc: say what pollWhileActive does to a suspending action, and that the browser watcher's foreground flow must be a stable reference. Co-Authored-By: Claude Sonnet 5.5 --- .../andcode/core/util/FlowExt.kt | 5 ++- .../feature/browser/BrowserCommandWatcher.kt | 12 +++++- .../feature/schedule/ScheduleBridge.kt | 13 ++++++ .../runtime/local/ClaudeCodeRuntime.kt | 43 +++++++++++++------ .../feature/schedule/ScheduleBridgeTest.kt | 39 ++++++++++++++++- 5 files changed, 95 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/core/util/FlowExt.kt b/app/src/main/java/com/yugahashimoto/andcode/core/util/FlowExt.kt index e399845a1..127a3602c 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/core/util/FlowExt.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/core/util/FlowExt.kt @@ -96,8 +96,9 @@ fun Flow.debounceFalseEdge(graceMillis: Long): Flow = * Opening the gate polls at once rather than after a first interval, so whatever arrived while it * was shut - a request written just before the app was backgrounded, say - is picked up on the way * back in instead of a beat later. Closing it cancels the wait in progress, so nothing fires after - * the flip; an [action] already running is allowed to finish. Never returns normally while the flow - * stays open, so callers launch it in a scope they cancel. + * the flip. An [action] that never suspends always runs to completion; one that does is cancelled + * at its next suspension point, so it should not suspend between taking something and handing it + * on. Never returns normally while the flow stays open, so callers launch it in a scope they cancel. */ suspend fun Flow.pollWhileActive( intervalMillis: Long, diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/browser/BrowserCommandWatcher.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/browser/BrowserCommandWatcher.kt index 5143a3c47..06011572e 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/browser/BrowserCommandWatcher.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/browser/BrowserCommandWatcher.kt @@ -23,6 +23,10 @@ private const val POLL_INTERVAL_MILLIS = 1000L * and a composition outlives the activity being stopped, so without the gate this read the disk * every second for as long as the process was alive. A command written while the app was away is * opened as soon as it returns. + * + * [foreground] keys the effect, so it must be a reference that stays the same across recompositions + * (the app's own `StateFlow` is); a flow built inline would restart the watcher, and its immediate + * first poll, on every recomposition. */ @Composable fun GuestBrowserCommandWatcher( @@ -47,7 +51,13 @@ fun GuestBrowserCommandWatcher( } } -/** The watcher's loop, apart from Compose so the gating can be tested with a virtual clock. */ +/** + * The watcher's loop, apart from Compose so the gating can be tested with a virtual clock. + * + * [poll] suspends while it reads the command file, so a gate that closes in that instant can drop + * the URL it has just consumed. That is a window of milliseconds at the moment the app is leaving + * the screen, when nobody is left to watch the browser open. + */ internal suspend fun watchBrowserCommands( active: Flow, poll: suspend () -> String?, diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridge.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridge.kt index be593e67b..4bd6a6cdf 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridge.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridge.kt @@ -133,6 +133,16 @@ class ScheduleBridge( writeResponse(responsesDir, requestId, errorResponse("Malformed request"), now) return } + // The guest stops waiting after RESPONSE_TIMEOUT_S and reports the call as failed. A request + // that sat unanswered past that - written while the poll was parked - must not run on the + // way back in, or a create/delete/runNow the agent was told did not happen would happen + // anyway. A missing or zero timestamp is "unknown", not "ancient", so it still runs. + val createdAtMs = request.optLong("createdAtMs", 0L) + if (createdAtMs > 0L && now - createdAtMs > REQUEST_EXPIRES_AFTER_MILLIS) { + file.delete() + writeResponse(responsesDir, requestId, errorResponse("Request expired before the AndCode app could handle it"), now) + return + } val response = try { execute(request) @@ -371,5 +381,8 @@ class ScheduleBridge( const val BRIDGE_RELATIVE_PATH = ".and-code/schedule-bridge" const val POLL_INTERVAL_MILLIS = 800L const val STALE_AFTER_MILLIS = 15 * 60_000L + + /** The guest's 90 s reply timeout (`RESPONSE_TIMEOUT_S`) plus headroom for clock drift. */ + const val REQUEST_EXPIRES_AFTER_MILLIS = 2 * 60_000L } } diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeRuntime.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeRuntime.kt index a5aa8239e..d054d2aa2 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeRuntime.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeRuntime.kt @@ -68,8 +68,9 @@ class ClaudeCodeRuntime( /** * Whether any CLI process is registered in [sessions]; what the permission-bridge watcher polls - * on. Published from every place [sessions] changes, under the same monitor, so the watcher can - * never be left parked while a process that could raise a request exists. + * on. Kept in step by [registerSession] and [unregisterSession], under the same monitor as + * [sessions], so the watcher can never be left parked while a process that could raise a request + * exists. */ private val hasLiveSessions = MutableStateFlow(false) @@ -85,6 +86,7 @@ class ClaudeCodeRuntime( val systemPrompt: String?, ) + /** Mutate only through [registerSession] and [unregisterSession], which keep [hasLiveSessions] in step. */ private val sessions = linkedMapOf() /** @@ -242,9 +244,31 @@ class ClaudeCodeRuntime( .filter { sessions[it.androidSessionId]?.process?.isAlive == true } .mapNotNull(permissionBridge::toQuestionRequest) + /** The only place [sessions] gains an entry, so [hasLiveSessions] can never lag behind it. */ @Synchronized - private fun publishLiveSessions() { + private fun registerSession( + sessionId: String, + session: SessionProcess, + ) { + sessions[sessionId] = session + hasLiveSessions.value = true + } + + /** + * The only place [sessions] loses an entry. With [onlyProcess] set, an entry that has since been + * replaced by a newer process is left alone - the case of an old reader job finishing after its + * session was relaunched. Returns the removed entry, or null if nothing was removed. + */ + @Synchronized + private fun unregisterSession( + sessionId: String, + onlyProcess: Process? = null, + ): SessionProcess? { + val session = sessions[sessionId] ?: return null + if (onlyProcess != null && session.process !== onlyProcess) return null + sessions.remove(sessionId) hasLiveSessions.value = sessions.isNotEmpty() + return session } /** @@ -320,11 +344,10 @@ class ClaudeCodeRuntime( /** Stops the process backing [sessionId]; the next send resumes the same Claude conversation. */ @Synchronized fun stop(sessionId: String) { - sessions.remove(sessionId)?.let { session -> + unregisterSession(sessionId)?.let { session -> session.readerJob.cancel() if (session.process.isAlive) session.process.destroyForcibly() } - publishLiveSessions() messageStore.flush() } @@ -419,10 +442,7 @@ class ClaudeCodeRuntime( }.exceptionOrNull() .takeUnless { it is CancellationException } messageStore.flush() - synchronized(this@ClaudeCodeRuntime) { - if (sessions[sessionId]?.process === process) sessions.remove(sessionId) - publishLiveSessions() - } + unregisterSession(sessionId, onlyProcess = process) // stop() cancels this job; the abort path owns the state transitions, and an idle // emitted here would announce the killed run as completed. if (!isActive) return@launch @@ -439,10 +459,7 @@ class ClaudeCodeRuntime( } return SessionProcess(process, readerJob, parser, permissionMode, directory, model, effort, systemPrompt) - .also { - sessions[sessionId] = it - publishLiveSessions() - } + .also { registerSession(sessionId, it) } } private fun processArguments( diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridgeTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridgeTest.kt index 188ad0bc7..754c10911 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridgeTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridgeTest.kt @@ -86,6 +86,7 @@ class ScheduleBridgeTest { requestId: String, op: String, args: JSONObject = JSONObject(), + createdAtMs: Long = 0L, ) { val file = File(pendingDir(bridge), "$requestId.json") file.parentFile?.mkdirs() @@ -93,7 +94,7 @@ class ScheduleBridgeTest { JSONObject() .put("op", op) .put("args", args) - .put("createdAtMs", 0L) + .put("createdAtMs", createdAtMs) .toString(), ) } @@ -480,4 +481,40 @@ class ScheduleBridgeTest { scope.cancel() } } + + /** + * The guest stops waiting after 90 seconds and tells the agent the call failed. A request that + * sat unanswered past that - written while the gate was shut - must not run on the way back in, + * or a `create`/`delete`/`runNow` the agent was told did not happen would happen anyway. + */ + @Test + fun `a request the guest has already given up on is dropped with an error instead of run`() { + val store = FakeStore() + val now = System.currentTimeMillis() + val bridge = bridgeWith(store, clock = { now }) + val id = requestId() + writeRequest(bridge, id, "create", createArgs(), createdAtMs = now - 5 * 60_000L) + + bridge.pollOnce() + + val response = readResponse(bridge, id) + assertFalse(response.getBoolean("ok")) + assertTrue(response.getString("error").contains("expired")) + assertEquals(0, store.schedules.size) + assertFalse(File(pendingDir(bridge), "$id.json").exists()) + } + + @Test + fun `a request the guest is still waiting on is run`() { + val store = FakeStore() + val now = System.currentTimeMillis() + val bridge = bridgeWith(store, clock = { now }) + val id = requestId() + writeRequest(bridge, id, "create", createArgs(), createdAtMs = now - 30_000L) + + bridge.pollOnce() + + assertTrue(readResponse(bridge, id).getBoolean("ok")) + assertEquals(1, store.schedules.size) + } } From 2026ae52bdf6171edd0fc2cd1ee5543e991cea78 Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:28:18 +0900 Subject: [PATCH 3/3] fix: tighten schedule request expiry to the guest's reply timeout Review follow-up. The expiry added for requests that sat unanswered while the bridge poll was parked was two minutes against the guest's 90 s reply timeout, which left a 90-120 s window where a request ran after its caller had already given up. The stated reason for the extra headroom, clock drift, does not apply: the guest runs under PRoot and reads the same kernel clock as the app. - REQUEST_EXPIRES_AFTER_MILLIS is now 95 s, with a note to keep it in step with RESPONSE_TIMEOUT_S in andcode-schedule-mcp.py. - Tests pin the boundary and the cases that must still run: a timestamp in the future and a request with no timestamp at all. - docs/LOCAL_RUNTIME.md says when the bridge polls and that stale requests are discarded. Co-Authored-By: Claude Sonnet 5.5 --- .../feature/schedule/ScheduleBridge.kt | 9 ++++- .../feature/schedule/ScheduleBridgeTest.kt | 38 +++++++++++++++++++ docs/LOCAL_RUNTIME.md | 1 + 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridge.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridge.kt index 4bd6a6cdf..83abed295 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridge.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridge.kt @@ -382,7 +382,12 @@ class ScheduleBridge( const val POLL_INTERVAL_MILLIS = 800L const val STALE_AFTER_MILLIS = 15 * 60_000L - /** The guest's 90 s reply timeout (`RESPONSE_TIMEOUT_S`) plus headroom for clock drift. */ - const val REQUEST_EXPIRES_AFTER_MILLIS = 2 * 60_000L + /** + * Just past the guest's 90 s reply timeout (`RESPONSE_TIMEOUT_S` in andcode-schedule-mcp.py, + * which must be kept in step with this), so a request is dropped once its caller has given up + * on it. No allowance for clock drift is needed: the guest runs under PRoot and reads the same + * kernel clock as this process. + */ + const val REQUEST_EXPIRES_AFTER_MILLIS = 95_000L } } diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridgeTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridgeTest.kt index 754c10911..16f551182 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridgeTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridgeTest.kt @@ -517,4 +517,42 @@ class ScheduleBridgeTest { assertTrue(readResponse(bridge, id).getBoolean("ok")) assertEquals(1, store.schedules.size) } + + @Test + fun `the expiry line falls at the configured age`() { + val store = FakeStore() + val now = System.currentTimeMillis() + val bridge = bridgeWith(store, clock = { now }) + val inside = requestId() + val outside = requestId() + val limit = ScheduleBridge.REQUEST_EXPIRES_AFTER_MILLIS + writeRequest(bridge, inside, "create", createArgs(prompt = "inside"), createdAtMs = now - limit + 1_000L) + writeRequest(bridge, outside, "create", createArgs(prompt = "outside"), createdAtMs = now - limit - 1_000L) + + bridge.pollOnce() + + assertTrue(readResponse(bridge, inside).getBoolean("ok")) + assertFalse(readResponse(bridge, outside).getBoolean("ok")) + assertEquals(listOf("inside"), store.schedules.map { it.prompt }) + } + + /** Only a timestamp that is plainly in the past counts as old; anything else is run as before. */ + @Test + fun `a request stamped in the future or with no timestamp is run`() { + val store = FakeStore() + val now = System.currentTimeMillis() + val bridge = bridgeWith(store, clock = { now }) + val future = requestId() + val missing = requestId() + writeRequest(bridge, future, "create", createArgs(prompt = "future"), createdAtMs = now + 60_000L) + File(pendingDir(bridge), "$missing.json").writeText( + JSONObject().put("op", "create").put("args", createArgs(prompt = "missing")).toString(), + ) + + bridge.pollOnce() + + assertTrue(readResponse(bridge, future).getBoolean("ok")) + assertTrue(readResponse(bridge, missing).getBoolean("ok")) + assertEquals(setOf("future", "missing"), store.schedules.map { it.prompt }.toSet()) + } } diff --git a/docs/LOCAL_RUNTIME.md b/docs/LOCAL_RUNTIME.md index e19646de7..716179f2f 100644 --- a/docs/LOCAL_RUNTIME.md +++ b/docs/LOCAL_RUNTIME.md @@ -218,6 +218,7 @@ OpenCodeがOAuthの `auto` 方式を返した場合、Androidはブラウザを - `andcode-schedule-mcp.py` が `/usr/local/bin` に入り、全エージェントのMCP設定(OpenCodeの`opencode.json`、Claude Codeの`.claude.json`、Antigravityの`mcp_config.json`)へ `and-code-schedule` サーバーとして登録される - サーバーは `/workspace/.and-code/schedule-bridge/pending/.json` へリクエストを書き、アプリ側の`ScheduleBridge`がポーリングして実行し、`responses/.json` へ結果を書き戻す +- ポーリングは、アプリが前面にあるか、ランタイムが作業中(チャットのセッション実行・スケジュール実行など)の間だけ動く。どちらでもないときは端末がスリープでき、ゲストも止まっているため待機し、前面に戻るか作業が始まった時点で即座に処理する。ゲスト側の応答待ち(90秒)を過ぎた古いリクエストは、実行せず期限切れエラーで破棄する - 書き込み系はスケジューリングUIと同じく暗号化リポジトリとアラーム(`ScheduleManager`)を通るため、変更はアプリのスケジュール画面へ即反映される - 提供ツール: `schedule_list` / `schedule_get` / `schedule_runs` / `schedule_create` / `schedule_update` / `schedule_delete` / `schedule_set_enabled` / `schedule_run_now`