Repository navigation
feat: keep terminal sessions running in the background - #16
Merged
Merged
Conversation
Seven tasks for docs/design/background-sessions.md: ManagedSession and SessionManager test-first on the JVM, the ProotLauncher-backed PTY and factory (emulator on its own HandlerThread), the specialUse foreground service and notifications, TerminalActivity as a view onto a session, the container list's running mark and banners, and the docs, suites, device lifecycle checks and the gateway check. Ending a session goes further than the design's "End closes the session": EOF, then a kill of the login shell (proot's --kill-on-exit ends the rest; proot does not set PTRACE_O_EXITKILL, so killing proot first would leave its tracees running), then proot as a last resort, and the PTY is closed only by the reader thread after its last read.
A ManagedSession owns a PTY and a reader thread that keeps draining output with no screen attached, records the exit code, tracks the one screen that shows it, and ends with EOF, then a kill of the program, then a forced kill, closing the PTY only after the reader's last read. JVM unit tests through a fake PTY.
…on reader failure write, resize and the EOF in end() now take a lock shared with the reader's close and do nothing once the PTY is closed, so they can no longer reach a closed or reused file descriptor. The reader's teardown (exit code wait, close, release, exit state, onEnded) runs in a finally, so a throwing output or release callback ends the session instead of leaving it looking alive. A second end() is a no-op. Tests cover each case.
SessionManager keeps one ManagedSession per container alias, publishes the running aliases, starts the foreground service at 0 to 1 running sessions and stops it at 1 to 0 (resetting keep-awake), reports a foreground start failure instead of failing the session, posts a session-ended notice only for a program that exited with no screen attached and no End request, and keeps an ended session while a screen still shows it. Changes are serialised, so concurrent opens create one session.
SessionManager now starts or stops the foreground service, and resets keep-awake and the stale background error, before it publishes the new running list, so observers never see an empty list while the service is still up. A failing foreground stop is recorded as the background error instead of aborting the session-ended handling.
ProotPty adapts ProotLauncher.Session. Its kill signals the login shell, proot's only direct child, so proot's --kill-on-exit ends every process of the session; killing proot itself would leave its tracees running, so that is only the last resort. ProotSessionFactory runs each session's termlib emulator on its own HandlerThread, since termlib builds a snapshot on its looper after every burst of output.
If the emulator or ManagedSession could not be built after proot had started, the process, its master fd and the emulator thread were orphaned; create now tears them down and rethrows. Signalling is skipped for a non-positive pid, since a -1 would reach every process the app may signal.
SessionService keeps the process alive while sessions run, shows the running-sessions notification with End all and Keep CPU awake, holds a partial wake lock while the toggle is on, and stops at zero sessions. A session that exits with no screen attached posts a session-ended notification when notifications are allowed. App owns the SessionManager. specialUse is used because Android 15 limits dataSync to 6 hours a day.
…ke toggles At the first session the service reads the running list when it starts, so the list is published before the start request. SessionService decides whether to stop from a fresh read after handling its action, and a Keep CPU awake tap delivered with no session running no longer turns the wake lock on for the next one.
TerminalActivity gets its session from SessionManager, attaches to it (closing any other screen showing it) and detaches when it closes; closing the screen no longer ends the session. Back keeps its order (keyboard, then selection) and then asks End session / Keep running / Cancel; once the session has ended it shows the exit code and Back returns without asking. A session that cannot start shows the reason. The notification permission is requested once, the first time a session starts.
Containers with a running session show Running. When Keep CPU awake is on and the app is not exempt from battery optimization, a banner explains that Doze will pause sessions and opens the system's exemption dialog; a foreground start failure shows its reason. Removing a container ends its session first.
Ending a busy session can take about 6 s (EOF, then the shell kill and the proot kill 2 s apart, then up to 2 s for the exit code), longer than the 5 s the remove flow waited before removing anyway. Wait 10 s, and if the session is still running, cancel the removal and say so instead of deleting files under a live proot.
Session lifetime and Back now describes SessionManager, the foreground service, the Back prompt, how End stops a session, and why the 24/7 case needs the battery-optimization exemption.
termlib resizes on the caller's thread (main) but applies output on the emulator's looper without locking: updateLine reads rows unlocked and indexes currentLines, which resize replaces. With the emulator on its own HandlerThread a resize during output could throw IndexOutOfBounds on that thread and kill the process. Create the emulator on the main looper and drop the HandlerThread and its quit on release. The docs now say why the emulator stays on main.
ManagedSession.finish() records the exit before it calls onEnded. A detach in that gap removed the ended, screenless session from the map without updating running, and onEnded then returned early because the session was no longer in the map. The alias stayed in running, so the service never stopped and the list kept showing it as running. detach now calls updateRunning() when it removes a session. Tests cover that window and a detach after exit without a reopen.
SessionManager stopped the service with stopService from whichever thread ended the last session. If that happened before onStartCommand ran, the system tore down a service whose startForeground was still pending and crashed the app; a login that exits at once did this. With Keep CPU awake on, the collector could also re-post the running notification after the system had removed it. ForegroundController now only starts the service. SessionService tracks the last start id and whether it is in the foreground. When running empties after startForeground, it removes its notification and calls stopSelfResult with the last start id; onStartCommand calls startForeground first and then stops the same way if nothing runs. Tests assert the running list instead of stop calls.
With notifications denied the running notification is hidden, so Keep CPU awake, and with it the battery optimization banner, could not be reached. While a session runs, the container list shows a "Keep CPU awake" card with a switch bound to SessionManager.keepCpuAwake, above the other banners. The notification action stays. Docs say the toggle is in both places.
The first session starts the service before the user answers the notification permission prompt, so its foreground notification is posted while notifications are denied. The service only re-posts it when the running list or Keep CPU awake changes, so after Allow no notification appeared. When the permission is granted and a session runs, TerminalActivity starts the service again; onStartCommand calls startForeground, which posts the notification. With no session running it stops itself.
The content intent used only FLAG_ACTIVITY_SINGLE_TOP, so tapping a notification while a terminal was on top started a second container list above it. Add FLAG_ACTIVITY_CLEAR_TOP: the tap clears the terminal (it detaches, and its session keeps running) and delivers the intent to the existing MainActivity, which does not read its intent.
- Start the PTY at 24x80, the emulator's initial size. It defaulted to 50x200, and termlib skips the resize when the screen computes exactly 24x80, which left the PTY and the emulator out of step. - The leave prompt's End session ends the session the screen shows instead of looking it up by alias again, which could end a newer session for the same container. - Allow in the battery banner falls back to the battery optimization settings list when the request dialog has no handler. - Import Composable in TerminalActivity instead of fully qualified annotations, and drop stray blank lines in App.
The design doc described an earlier SessionManager API: open returning a ManagedSession, a sessions flow of SessionInfo and a separate emulator factory seam. Describe the real one: open returns Opened or Failed, running is a StateFlow of aliases, attach and detach connect screens, and the session factory creates both the PTY and the emulator. The architecture table now says that a terminal restored from recents after Android kills the process starts a fresh session.
The manager now owns the toggle and sets it only while a session is running, so a tap landing as the last session ends cannot leave the next session awake.
Without the service there is no wake lock to hold, so the switch did nothing.
On resume of the container list, restart the service when notification permission has just become granted while sessions run, so it posts again.
Nothing passes it any more; drops the parameter, its call in finish() and the test that exercised it.
Inside the LazyColumn the banners item is empty while nothing runs, so when a session starts the list keeps its scroll anchor on the next card and the Keep CPU awake switch and banners appear above the viewport, out of sight. Show them in a Column above the list instead.
… savers Measured under forced deep idle, a session kept its wake lock and network without the battery-optimization exemption: Android lets a foreground-service process keep both in Doze. Vendor battery savers can still stop an app that is not exempt, so the Allow banner stays, with text that no longer claims Doze pauses sessions. The design and architecture docs record the measurement.
TerminalActivity detached only in onDestroy, so after Home the screen stayed attached and a program exiting in the background posted no "session ended" notice. A recreation by a configuration change not in configChanges (dark mode, theme) dropped an ended session and started a fresh login in its place. The attached screen now carries a visible state set in onStart and onStop; the notice is posted when the user did not end the session and no screen is visible. onDestroy during a configuration change detaches with retainEnded, and the next open returns that session once.
End used to send EOF and then SIGKILL the login shell, so a background program such as the gateway never had a chance to clean up. Pty gains hangUp(): ProotPty walks /proc (the children file when present, otherwise a stat scan) and sends SIGHUP to every descendant of the login shell, not the shell or proot. The end thread now hangs up first, waits up to the grace period if anything was signalled, and only then sends EOF, kills the login shell and kills proot, stopping as soon as the session ends.
Opening a container while its session was ending returned that session, so the new screen attached and showed "Session ended" a moment later. open() now returns Ending(session) for a session whose end was requested and that has not exited yet; it still never blocks. TerminalActivity shows "Ending the previous session…", waits for that session's exit, then opens again and starts a fresh session.
- ManagedSession.finish() kills proot when the exit-code wait runs out, so a reader failure cannot leave proot running untracked. - SessionService reports a successful startForeground through SessionManager.foregroundStarted(), which clears a stale backgroundError. - keepCpuAwake is a read-only StateFlow; setKeepCpuAwake is the only way to change it. - Returning to the list after the battery exemption is granted restarts the service while sessions run, so the notification drops the battery note. - Allow catches a missing fallback battery settings screen too.
The spec now covers Remove while a session runs (end first, wait up to 10 s, cancel if it still runs). The spec and the architecture doc list the limits a 24/7 session still faces: the Android 12+ phantom process limit and the Android 14+ Task Manager "Stop".
The children file of the main thread lists only the processes that thread forked. A multi-threaded program such as a gateway can start subprocesses from other threads, which SIGHUP then missed. Read the children file of every task, and with the stat-scan fallback look up only the scanned parent map.
End now signals every process proot traces (TracerPid in /proc/<pid>/status), except proot and the login shell, so a daemon that left the shell's tree, such as a gateway respawned under setsid, also gets the chance to shut down. Each target gets SIGHUP, SIGCONT and SIGTERM in that order. If any status cannot be read, End falls back to the login shell's descendants. The end thread then waits up to 3 s (signalWaitMs) until either the session ends or every signalled process has exited, polling Pty.alive(pids), which treats zombies as exited. EOF, kill and forceKill follow as before. The two tests that stop early use a 2 s grace so a slow host cannot reach the next step.
The EOF write can block on a full PTY input queue while it holds the PTY lock. It now runs on a short-lived thread, still guarded by the lock and the closed flag, so the kill and force-kill steps keep their timing. When the reader stops with no exit code, finish() now kills the login shell first so proot's --kill-on-exit takes its tracees, waits up to 500 ms for the exit code, and only then kills proot.
The wait after signalling rounded the time left down to whole milliseconds and could stop up to 1 ms early, which made the ordering test flaky. It now counts in nanoseconds.
A retained session was released in open(), so if it ended between open() and the new screen's attach, it was dropped and a "session ended" notice was posted. The retained mark is now cleared in SessionManager.attach, under the manager's lock, so the session waits for the screen that opened it. TerminalActivity.onStop no longer marks its screen hidden when it stops for a configuration change, which closes the other window in which a recreation could post the notice.
While the terminal waits for an ending session, it now gives up after 10 s and shows "Couldn't stop the previous session.", mirroring the Remove wait. Back still returns to the list. The waiting screen also attaches to the ending session and detaches at once, so any other screen still showing that session closes.
The notification-shade Task Manager "Stop" arrived in Android 13. The "Disable child process restrictions" developer option exists on Android 14+; 12L and 13 need settings_enable_monitor_phantom_procs and 12 needs device_config.
Linux delivers a pending SIGHUP before SIGTERM, and SIGHUP's default action exits, so a Python program such as the gateway died before it could drain on SIGTERM; daemons also often treat HUP as "reload". End's gentle step now sends SIGTERM, then SIGCONT, to every tracee except proot and the login shell. TERM goes first so it stays pending on a stopped job and is handled as soon as CONT resumes it. The 3 s wait, EOF, kill and forceKill are unchanged.
show() marked the screen hidden when called from onCreate, so a program exiting before onStart could still post a "session ended" notice during recreation. It now sets visibility only on the late path after Ending; from onCreate the screen keeps attach's default until onStart, which always follows. The spec now says a retained session is returned by open until a screen attaches, not once.
The EOF now starts on its own thread, so a 100 ms grace could let a loaded host record the kill before the EOF. The ordering test and the blocked EOF test use a 1 s grace.
proot does not set PTRACE_O_EXITKILL, so killing it while a tracee is stuck (a login shell in D state) detached the survivors; they kept the PTY slave open, the reader never saw EOF and the session stayed running for good. forceKill now re-scans the tracees (TracerPid, or the descendant walk) and SIGKILLs each of them before proot.
It asserted that a constant equals its own value; the signal order is covered by the End behaviour tests.
A user who left the "Ending the previous session" screen with Home got a fresh login started out of sight, with a foreground-service start from the background. The reopen now waits until the activity is started again.
Between finish() and onStop the screen still counted as visible, so a program exiting during the close transition posted no notice and the end was never shown.
With a 100 ms grace a slow CI host could start the EOF thread after the kill step and fail the assertions; these tests now use a 1 s grace and the order test a longer wait.
… stop Design and architecture: End SIGKILLs every tracee before proot, the reader-error path waits for the exit code before killing the shell, the service stops itself, and the plan is marked historical.
oonid
added a commit
that referenced
this pull request
Oct 19, 2026
Decisions from the PR #16 follow-up list: a 10 s drain window with derived Remove and Ending timeouts, JVM tests through small seams for /proc scanning and the service, an app-scoped runner for install, remove and test, and no "0 sessions running" notification.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements
docs/design/background-sessions.md, followingdocs/plans/background-sessions.md.A container's terminal session now keeps running after its screen closes, so a long-running program such as the Hermes gateway can run around the clock.
What changes
session/ManagedSession.kt): one terminal session (PTY, termlib emulator, reader thread) that keeps draining output with no screen attached and is shown by at most one screen. End is gentle first: SIGTERM and SIGCONT go to every process in the session except the login shell (proot tracees byTracerPid, so daemons that left the shell's tree are included), and End waits up to 3 s for them to exit. Then it sends EOF, after 2 s kills the login shell (proot's--kill-on-exittakes the rest), and after 2 s more kills proot. The worst case is about 9 s.session/SessionManager.kt): app-wide, one session per container, owned byApp. It publishes the running list, starts the foreground service at 0 → 1, and holds the Keep CPU awake flag (off each time sessions start from zero) and the background error.ProotLauncher. The PTY starts at the emulator's 24x80. The emulator runs on the main looper, because termlib resizes on the caller's thread.specialUseforeground service. It keeps the process alive while any session runs, owns the notification (End all, Keep CPU awake / Let CPU sleep) and the partial wake lock, and stops itself on its main thread when the running list empties.docs/android-apk-architecture.md.Changes beyond the plan's code
These came out of review and device testing. Each is its own commit.
ManagedSessionguards PTY access after close, contains reader failures, and makesend()idempotent.SessionManagerpublishes running in the right order around the foreground start.detachrecomputes running when it drops an ended session (a race could leave a session marked running forever).stopSelfResult(lastStartId). Before,stopServicefrom the reader thread could crash the app ("did not then call startForeground") when a login failed at once, and could leave a stale notification behind.processPendingUpdatesand could throw on the emulator thread, killing the process.CLEAR_TOP | SINGLE_TOP).LazyColumnthey appeared above the viewport.From the pre-merge review (two rounds, each re-reviewed):
setsidshut down cleanly. SIGHUP is not sent: Python's default for it is an immediate exit, and daemons often treat it as "reload".SessionManager. The notification drops its battery note once the exemption is granted. The battery-settings fallback can't crash.From the final independent review:
PTRACE_O_EXITKILL, so a stuck tracee could otherwise outlive it, hold the PTY and leave the session marked running for good.Verification
cargo test169 passed;pr-cli test alpineandpr-cli test ubuntu55/55 each; in-apppit all55/55 (run while the session was in the background).Device checks (Android 16, build before the pre-merge fixes; the End timings here predate the SIGTERM step, which is re-measured below):
stty sizematches the visible grid.specialUse(types=0x40000000).sleep 1000: all processes gone in about 2.4 s. This kernel has no/proc/<pid>/task/<pid>/children, so the fallback/procscan finds the login shell.exit: "Session ended (exit 0)"; one Back returns to the list without a prompt./bin/shtemporarily missing): "Session ended (exit 1)", no crash, the service stops.while :; do date; done), 30 keyboard toggles and 10 rotations: no crash.exitand by End all with two sessions: no notification, wake lock or service left.pr:sessionspartial wake lock, and resets when sessions end.sleep 5; exit 3with Keep running posts "ubuntu: session ended (exit 3)".sleep 1000): the session ended before removal, then the rootfs was wiped.After the pre-merge fixes:
exit 3: the notice "alpine: session ended (exit 3)" is posted, and returning shows "Session ended (exit 3)".exitwith the terminal in front: no notice.setsiddaemon outside the shell's tree that traps SIGTERM: the daemon got SIGTERM, and the session ended in 0.4 s.specialUsenotification, reattach with the earlier output, and End all from the notification, which leaves no process, service, notification or wake lock.Doze (lifecycle 4):
dumpsys battery unplugplusdeviceidle force-idle: the counter advanced both with and without the battery-optimization exemption. A network probe (wgetevery 5 s) succeeded 15/15 without the exemption and 9/9 with it.Gateway check (the design's target check):
hermes gateway runin thehermescontainer with its OpenAI-compatible API server on; Keep running, Keep CPU awake on, battery exemption granted.adb forward.dumpsys battery unplug,deviceidle force-idlefrom 06:40:53 UTC./v1/chat/completionsunder forced idle, one about every 5 minutes (06:45 to 07:13 UTC). All 7 returned HTTP 200 withOK, in 2 to 4 s each. Deep idle was still in force at the end.Shutdown context: signal=SIGTERM, disconnected its adapters, closed its database, and stopped in 0.31 s. Its lifecycle ledger recordsgraceful_shutdown. The whole session ended in 0.7 s.adb forwardand the config change were removed.Known limits and follow-ups
SessionService's start/stop handling andProotPty's/procscanning are device-verified only, with no automated tests yet.