Skip to content

fix(sessions): let hidden pages release idle event streams - #1328

Merged
agegr merged 6 commits into
mainfrom
fix/issue-1283-hidden-tab-release
Oct 20, 2026
Merged

agegr merged 6 commits into
mainfrom
fix/issue-1283-hidden-tab-release

Conversation

@agegr

@agegr agegr commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Over HTTP/1.1 a browser opens at most six connections per origin, shared
by all its tabs. Every pi-web tab kept the selected session's agent event
stream open even while idle (the warm-session effect; the idle grace in
scheduleEventStreamClose() skips the selected session), plus the active
file tab's watch while the right panel was open. A few background tabs
filled the pool, so Settings loads and message sync queued in the browser
until one tab closed.

The selected session is now kept alive by the page, not by its stream.
Each page holds a liveness lease of its own (POST
/api/agent/[id]/lease?holder=, holdSessionLivenessLease(): one
per page and session, 90 s TTL), taken on selection and renewed every
30 s and on return, whether or not the stream is open. Its reply says what
is under way in the session, read from the wrapper without starting one or
resetting its idle timer: running (isRunning(): a prompt, a queued one, a
! command, a compaction), awaitingInput (an extension dialog waiting for
an answer) and subagentsActive (an unsettled subagent at any depth,
hasUnsettledSubagentRun()).

A visible page always keeps its stream. A page hidden for
HIDDEN_PAGE_GRACE_MS (30 s, hooks/usePageHidden.ts) ends the grace with a
lease request and closes the stream only if the reply shows nothing under
way (canReleaseHiddenEventStream()); a run, ! command, compaction or
dialog in the page keeps it without asking. No reply within 5 s, the
request queued behind a full pool, also releases; an error reply keeps
it and checks again next grace. While released and still hidden, a
renewal reply that shows activity opens the stream again
(sessionLeaseShowsActivity()), so a run another tab, a device or an
extension's timer starts there, and an extension dialog, still reach the
hidden page with the chime and browser notifications; it is let go again
a grace after the activity ends. The page's return reopens it at once.
Either way the session is read again with its state, applied as the mount
does (applyLoadedAgentState()); nothing is resent.

The stream no longer carries the lease, so dead streams are found by a
heartbeat watchdog: the server's 30 s heartbeat is a data event now (an
SSE comment never reaches EventSource), and AgentEventConnection
reconnects a ready stream silent for 75 s.

The file watch pauses after the same grace. On reconnect the viewers
compare the file's size and mtime (now in type=meta and type=read, the
pair the watch's change events carry) and reload only a changed file, so
coming back keeps a text file's Load more chunks and scroll position,
media playback and a PDF's place.

What a released page can still miss: a run that starts and settles
between two renewals plays no chime and shows no in-page notification
there; web push still announces its completion when subscribed. Terminal
streams are left alone: the server kills a shell that has had no listener
for two minutes. Feedback in the UI when requests are stuck is not
addressed.

Refs #1283

Trade-off to decide in review

The selected session is kept alive by the page, not by its stream: every page holds a liveness lease of its own (POST /api/agent/[id]/lease?holder=<page id>, one per page and session, 90 s TTL), renewed every 30 s whether or not the stream is open, so releasing a hidden page's stream no longer lets the idle wrapper be reaped. The renewal's reply says what is under way in the session (running, awaitingInput, subagentsActive, read from the wrapper without starting it or resetting its idle timer), and that one reply decides both ends of the release:

  • A page hidden for 30 s closes its idle stream only when a lease reply shows nothing under way (no reply within 5 s also releases: the request is then queued behind a full pool).
  • While released and still hidden, a renewal reply that shows activity opens the stream again, so a run started from another tab or device or by an extension's timer, and an extension dialog, still reach the hidden page with the chime and browser notifications. The stream is let go again a grace after the activity ends.

A visible page always keeps its stream. Dead streams are found by a heartbeat watchdog instead of the lease: the server's 30 s heartbeat is now a data event, and a ready stream silent for 75 s reconnects.

What is left: a run that starts and settles between two renewals (30 s apart; Chrome slows a page hidden for over 5 minutes to about one timer a minute) plays no chime and shows no in-page notification on the hidden page. Web push still announces its completion when subscribed. Before this PR every open tab held one idle stream (two with a file open), which is what fills the six-connection HTTP/1.1 pool.

Testing

  • hooks/hidden-page-stream.test.mjs (real hook, mocked timers): a quick hide/show keeps the stream; a page hidden past the grace releases on an idle lease reply and reopens on return; the lease is renewed the whole time, with or without the stream; a running, awaiting-input or subagent reply keeps it and is checked again each grace; a reply that never comes releases after 5 s, no network releases, an error reply keeps it; a released hidden page reopens when a renewal shows a run, receives its completion, and lets go a grace after it settles; the same for an extension dialog; a pending dialog in the page keeps it without asking.
  • app/api/agent/lease-route.integration.test.mjs: 400 without a holder; the reply reports an idle wrapper and the lease keeps it from being reaped; a lease for an unknown session never starts a wrapper.
  • lib/agent-event-connection.test.mjs (heartbeat watchdog), lib/agent-event-stream.test.mjs (heartbeat as a data event, no lease on the stream), lib/session-liveness.test.mjs (per-page leases), lib/file-version.test.mjs, lib/subagent-runtime.test.mjs.
  • tsc --noEmit, npm run lint, npm test 2820 / 0 fail on Node 24. Node 22 is left to CI for the last two commits.
  • Chrome against an isolated dev server with PI_WEB_IDLE_TIMEOUT_MS=40000: heartbeats arrive while visible; hidden 34 s holds 0 streams and the wrapper stays alive; hidden 94 s (past the idle timeout) the wrapper is still alive, renewed every 30 s; visible again reopens the stream; 140 s after the tab closes the wrapper is reaped.
  • Chrome, hidden and released page, another client runs a 60 s extension command in the session: the next renewal reports running: true, the stream reopens while still hidden, prompt_done reaches the page, and the stream is released again 30 s after the run. With a 20 s command that started and ended between two renewals, the hidden page did not reopen (the gap above).
  • An unchanged text file is not re-read on return (only type=meta), from the first commit's check.
  • Two independent reviews of the first commits found lost subagent-triggered runs, no grace, idle shown on return, the text viewer losing Load more, the idle check starving in a full pool, a window where a finished subagent's parent notice was not yet counted, and a stale git diff gutter after an unchanged reconnect; all fixed.

🤖 Generated with Claude Code

@agegr
agegr marked this pull request as draft October 18, 2026 03:23
@agegr
agegr marked this pull request as ready for review October 18, 2026 03:26
agegr and others added 3 commits October 18, 2026 22:10
Over HTTP/1.1 a browser opens at most six connections per origin, shared
by all its tabs. Every pi-web tab kept the selected session's agent event
stream open even while idle (the warm-session effect; the idle grace in
scheduleEventStreamClose() skips the selected session), plus the active
file tab's watch while the right panel was open. A few background tabs
filled the pool, so Settings loads and message sync queued in the browser
until one tab closed.

A page hidden for HIDDEN_PAGE_GRACE_MS (30 s, hooks/usePageHidden.ts)
now asks GET /api/agent/[id] whether anything is under way: a run, a `!`
command, a compaction, an open extension dialog, or an unsettled subagent
at any depth (new subagentsActive, hasUnsettledSubagentRun()). Only when
nothing is does it close the selected session's stream
(canReleaseHiddenEventStream()); otherwise it checks again each grace. A
quick tab switch closes nothing, and a run that settles while hidden
starts the grace again, which covers an extension's follow-up right after
prompt_done. eventStreamReleasedRef keeps shouldMaintain() and the lease
renewal from reopening it. On return the stream reopens and the session
is read again with its state, applied as the mount does
(applyLoadedAgentState()), so work still under way shows as such; nothing
is resent.

The file watch pauses after the same grace. On reconnect the viewers
compare the file's size and mtime (now in type=meta and type=read, the
pair the watch's change events carry) and reload only a changed file, so
coming back keeps a text file's Load more chunks and scroll position,
media playback and a PDF's place.

What a released page no longer hears: a run started in the session after
the release (another device or tab, an extension timer, a subagent
notification between two checks) plays no chime and shows no in-page
completion or attention notification in that tab until it returns. Web
push still announces completion when subscribed. Terminal streams are
left alone: the server kills a shell that has had no listener for two
minutes. Feedback in the UI when requests are stuck is not addressed.

Refs #1283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt notices

Second review follow-up to the hidden-page release.

The release waited on GET /api/agent/[id] with no timeout and kept the
stream when no reply came. In the very state #1283 is about, a pool
already full of streams, that request queues in the browser behind them
and never leaves, so no hidden tab ever let go. The check now aborts
after HIDDEN_RELEASE_CHECK_TIMEOUT_MS (5 s); no answer in time, like no
network, releases (canReleaseHiddenEventStream("unanswered")). An error
reply from the server still keeps the stream until the next grace.

A background subagent run leaves the run map before notifyParent() starts
the parent's turn (after waitUntilReady() and the idle poll), so a check
in that window saw no subagent and released. notifyParent() now records
the delivery until it is sent or fails, and hasUnsettledSubagentRun()
counts it.

The text viewer skipped the git diff along with the content on an
unchanged reconnect, leaving the gutter stale after a commit made while
hidden; it reads the diff again.

Refs #1283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After #1306 status reads go through agentStatusUrl() (?systemPrompt=0).
The hidden-page release asked GET /api/agent/[id] with the full state
every grace per hidden tab; it is a status read too.

Refs #1283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@agegr
agegr force-pushed the fix/issue-1283-hidden-tab-release branch from 6826260 to 357bc65 Compare October 18, 2026 13:11
agegr and others added 2 commits October 19, 2026 21:40
… own

The event stream's lease is what keeps the selected session's idle wrapper
from being reaped (#705 liveness, "keep selected sessions alive"). A hidden
page that let go of the stream also let go of that lease and stopped
renewing, so ten minutes later (PI_WEB_IDLE_TIMEOUT_MS) the wrapper shut
down: an extension's timer in it never fired, and nothing ran to announce
by push either, while the session was still selected in that tab.

The page now holds a lease of its own in the stream's place, at the release
and on each renewal tick (POST /api/agent/[id]/lease?holder=<page id>,
holdSessionLivenessLease()): one per page and session, ended a TTL after the
page stops renewing it. The return renews the stream's lease again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot its stream

The selected session's liveness lease was carried by its event stream, so
a hidden page that let go of the stream (#1283) let go of the session too;
539e4cc patched that with a second, page-held lease taken only at the
release. The lease now belongs to the page alone: POST
/api/agent/[id]/lease?holder=<page id> creates or renews one lease per
page and session (holdSessionLivenessLease()), at selection, every 30 s and
on return, whether or not the stream is open. A closed tab's lease ends a
TTL (90 s) after its last renewal. The stream no longer takes a lease, and
the route answers 400 without a holder.

A renewal that found no lease used to mean a dead stream and reconnected
it. The page's own hold always finds one, so that probe could no longer
see a dead stream. The server now sends its 30 s heartbeat as a data
event ({ type: "heartbeat" }; an SSE comment never reaches EventSource),
and AgentEventConnection drops a ready stream silent for 75 s and
reconnects (staleAfterMs), which catches half-open connections directly.
The heartbeat never reaches onEvent.

Refs #1283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wowayou

wowayou commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

Thanks for #1328. On the trade-off: I'm fine with it. Releasing only after 30 s hidden and a server-side idle check, re-reading the session on return, and never resending covers acceptance point 1 and the no-replay boundary. Web push still covers completion, which is what matters most for a background tab.

Review notes, none blocking:

  • Treating an unanswered idle check as releasable is the right call, since a starved pool is exactly that case.
  • Held leases expire on their own timer, so stale keys left in the holds map until the next holdSessionLivenessLease() call are harmless.
  • Chrome throttles timers in long-hidden tabs to about once a minute. That still renews the 30 s lease well within the 90 s TTL. A frozen or discarded tab stops renewing, which behaves the same as a closed tab today.

Verified locally on 539e4cc: the PR's tests pass 44/44, tsc --noEmit is clean, the full npm test passes (4 plugin-update tests fail only with PI_OFFLINE=1 set in my env), and the branch merges cleanly onto main 628d6ac. I didn't run a browser smoke test.

Agree on keeping #1283 open for the stuck-request UI feedback.

…of its stream and when it opens it again

The page already renews its selected session's lease every 30 s whether or
not it holds the event stream. Its reply now says what is under way there,
read from the wrapper alone (never starting one, never resetting its idle
timer): running (isRunning(): a prompt, a queued one, a `!` command, a
compaction), awaitingInput (an extension dialog waiting for an answer) and
subagentsActive (hasUnsettledSubagentRun()).

That one reply drives both ends of the release:
- At the end of the hidden page's grace, the release check is a lease
  request (shared with the renewal tick when one is in flight) instead of
  GET /api/agent/[id]; the stream closes only if the reply shows nothing
  under way. No reply within SESSION_LEASE_TIMEOUT_MS (5 s) still releases.
  GET /api/agent/[id] is back to main's.
- While released and still hidden, a renewal reply that shows activity
  opens the stream again and reads the session, so a run another tab, a
  device or an extension's timer starts there, and an extension dialog,
  again reach the hidden page with their chime and browser notifications.
  The reopen arms the grace again, so the stream is let go a grace after
  the activity ends.

A run that starts and settles between two renewals is still missed on the
hidden page; web push covers its completion when subscribed.

Refs #1283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@agegr
agegr merged commit 70b2947 into main Oct 20, 2026
2 checks passed
Hwwwww-dev pushed a commit to Hwwwww-dev/pi-web that referenced this pull request Oct 22, 2026
Keeps the dev-side features (keepalive dock, turnRendered chat,
ThinkingRow paging, minimap, chat-only, mobile PWA) and takes main's
architecture where it superseded local copies (lease/reattach stream
model agegr#1328, applyLoadedAgentState, live active time agegr#1334, tool
argument rows agegr#1324, patch two-column view agegr#1325, search highlights).
@agegr
agegr deleted the fix/issue-1283-hidden-tab-release branch October 29, 2026 13:01
stkoecn pushed a commit to stkoecn/pi-web that referenced this pull request Oct 29, 2026
The two histories share no ancestor (this repository restarted from
`17088cf`), so `git merge` is impossible: every file was placed by hand,
three-way where both sides had touched it.

Nineteen files upstream changed and this repository had never touched came
across whole, along with the twenty-two files upstream added. The fifty-six
both-sides files were merged with `git merge-file` against v0.12.2: thirty-five
applied clean, and the rest were resolved by taking upstream's text and
re-injecting the device layer this fork adds.

What the merge had to protect, since upstream knows nothing about it:

- `deviceId` threading. `FileViewer`, `MessageView`, `useAgentSession`,
  `models-config-helpers`, `request-security`, `AppShell` and `ChatInput` all
  gained upstream rewrites; each got its `deviceId` parameter, `withDeviceId()`
  call or `useClientDeviceId()` back. `MessageView`'s deferred thinking cache
  key stays `${deviceId}:${sessionId}:${entryId}:${blockIndex}` — two devices
  may hold a session with the same id.
- `ownHostnames()` and `reportRejectedHostOnce()` in `request-security.ts`.
  Without them every write API answers 403 on a LAN or Tailscale hostname.
  Upstream's `isUserInitiatedDocumentNavigation()` + `isPdfFileReadUrl()` came
  in alongside.
- `app/api/files/[[...path]]/route.ts` keeps its doubled brackets (upstream's
  is single) but gained agegr#1328's `mtime` on the preview, the meta branch and the
  remote watch event. `lib/file-version.ts` requires a string `mtime`, so the
  watch used to report every remote file as changed on each reconnect.

Deliberately not taken:

- `README.md` stays this fork's Chinese gateway documentation; upstream's is a
  generic English page about a `demo/` directory that does not exist here.
- `AGENTS.md` keeps the four sections about this repository's dev server,
  tests, CRLF files and `devices.json`, and appends upstream's Topic Notes,
  Old Safari, session-file-format and CSS-variable sections.

Two upstream tests assumed files this fork does not have: the quote-caret test
walked a `demo/` copy, and the agegr#1313 workspace-memory case needed `deviceHref`
in its sandbox. Both were adjusted, not skipped.

Verified: `components/*.test.mjs` 607/607, the eighteen lib suites this merge
touches 182/183, and `tsc --noEmit` clean. The one lib failure
(`lib/session-search.test.mjs`) and the `hooks/*.test.mjs` loader failures are
pre-existing: they fail identically on `master`, from Node 24's ESM loader
rejecting `d:` paths and from `lib/session-search.ts` importing
`./shell-quote` without an extension.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants