Skip to content

Repository files navigation

outlive

Solari cloud browser sessions end about ten minutes after they start. outlive checkpoints the session, notices the death, launches a new browser, and calls your task again where it left off.

CI npm License: MIT

Read this first, because it is the whole shape of the thing. A checkpoint is cookies, localStorage and the current URL. That is all that survives. The DOM is gone, anything the page held in JavaScript is gone, a half-typed form is gone, and your task function starts again from the top. A task that is not safe to run twice has to be written so that it is — checkpoint straight after the step that must not repeat, and check for that step's effect on re-entry. outlive puts this in the API rather than hiding it: your task is called again, with ctx.attempt and ctx.resumedFrom.

npm install outlive
import { Solari } from "@solarisdk/browser"
import { outlive } from "outlive"

const solari = new Solari({ apiKey: process.env.SOLARI_API_KEY })

const rows = await outlive(solari, async (page, ctx) => {
  if (!ctx.resumedFrom) {
    await page.goto("https://bank.example/login")
    await signIn(page)          // password + 2FA, the expensive part
    await ctx.checkpoint()      // never do that twice
  }
  return await scrapeForTwentyMinutes(page)   // outlives the session
}, { checkpointEveryMs: 30_000, maxRelaunches: 5 })
  • The session dying is not an error. It is expected, it is handled, and it costs about two seconds.
  • A step can replace the browser itself. ctx.relaunch() hands your task a new browser, restored from the last checkpoint, without calling it again — for the code that is holding a Page in the middle of something and cannot start over.
  • Liveness comes from the connection, never from the API. GET /sessions/:id reported active for a session that had been dead for five minutes. outlive uses the disconnected event, isConnected(), and the "Browser closed" message substring as a last resort.
  • One wide event per run, quiet by default: outcome, relaunches, checkpoints, lost work, total time.
  • One runtime dependency, @solarisdk/browser. playwright-core is a peer dependency and is used for types only — outlive imports no value from it. Note that the SDK actually returns patchright-core's Page: patchright is a Playwright fork with the same runtime surface, and the two declarations differ only in optional-property variance, so the object you get behaves exactly like the Page the types describe. handraise makes the same choice, so the series is consistent; the cost is that a consumer who has no Playwright installed gets playwright-core pulled in for its .d.ts files.

Part 2 of a series with handraise, which handles the other way a Solari session stops being useful: it needs a human.

Measured

Against the live API. Method and raw data in benchmarks/ and docs/measurements/.

  • 5/5 tasks of 12 minutes completed. Baseline 0/5. Ten task runs, fifteen browser sessions, arms concurrent and alternating, against one live Aurora Bank instance: sign in with a real TOTP, then poll the account page every 20 s until 36 polls have succeeded — 720 s of sleeps against a session that lives ~600 s. Every baseline run reached poll 29 or 30 and threw goto: Browser closed.
  • 5 relaunches, no failures. Median lost work 4.9 s per run, worst single death 15.0 s, at a 30 s checkpoint interval. No session was left unreleased, no checkpoint capture failed.
  • The overhead does not show up in the wall clock. outlive's median run was 756 s. The baseline's own pace — 29 polls in 624 s, 21.5 s each — extrapolates to ~774 s for 36, so surviving a session death cost less than the run-to-run variation. The cost that is measurable is the lost work above.
  • 2121 ms to replace a browser: launch + context from storageState + goto. The checkpoint itself is 200 ms and 1640 bytes.

An earlier run of the same bench drew a browser that was dead 1.5 s after launch() returned. The baseline lost a whole run to it; outlive relaunched and finished. Sessions do not only die of old age.

The live end-to-end test (bun run test:e2e) is the same claim without a control: sign in once, then poll for fourteen minutes. It now takes both kinds of replacement in one run. Latest run — 40/40 polls, two relaunches, one TOTP typed, 824 s total, 28 checkpoints:

  • after poll 2, at 33 s, the task called ctx.relaunch() on a browser that was still healthy. The replacement came back on the same account URL, already signed in — no second TOTP — and the task carried straight on in the same entry: attempts stayed at 1 through the next twenty-nine polls. The page it had been called with refused, as it should: title: Target page, context or browser has been closed.
  • at 640 s the platform ended that second session by itself, and the ordinary path took over: one death, 2.9 s of repeated work, the task re-entered and polling continued to 40/40.

An earlier run of the same test took two deaths, one of them at 193 s, and finished the same way.

Why this has to exist

Solari browser sessions have no timeoutMs, no keep-alive call, no resume() and no option that asks for a longer life. Six measured sessions died at 604–617 s from creation — one at 319 s — and it made no difference whether they were idle, pinged every 25 s, or streaming a CDP screencast at 14 fps. The idle one lived longest. expiresAt sits at creation + 5 h and is never reached. The full measurement is in handraise's measurement 04.

So any agent task longer than about ten minutes dies part-way through, and because the control plane keeps calling the corpse active, it dies quietly.

What survives, exactly

Thing Survives a relaunch
Cookies, including HttpOnly ones
localStorage
The page's URL
A logged-in application session ✅ (measured 3/3)
The DOM, in-page JavaScript state, scroll position
A half-filled form
IndexedDB, service workers, sessionStorage
Local variables inside your task function ❌ — it starts again
Local variables in the scope around your task ✅ — they are yours

Two things about the re-entry itself, because neither is obvious:

Your task is not cancelled when its browser dies — it is abandoned. A promise cannot be interrupted, so the old entry keeps running until its next page call throws. For that window, two entries of your task are running at the same time. Page calls from the abandoned one fail harmlessly and its ctx.checkpoint() is retired, but anything it does outside the browser — a database write, an HTTP POST, a counter — still happens. This is the same constraint as "safe to run again", one layer down.

A death waits deathGraceMs (250 by default) before the replacement is launched. The disconnected event arrives about 1.5 s before a page call would notice, so a task whose last call already succeeded is usually still resolving. Waiting lets it finish instead of throwing the work away. It is a threshold, not a guarantee: a task that spends longer than that parsing or writing after its final page call is re-entered anyway. Correct, because tasks must be re-runnable — but it is the expensive outcome the grace exists to avoid, so checkpoint before a long non-browser tail. It is not the window for ctx.relaunch(), which is longer; the next section says exactly how long.

That last row is how a task keeps a running total across relaunches: keep it in your own closure, not inside the task.

const rows: Row[] = []                       // survives: it is yours
await outlive(solari, async (page, ctx) => {
  for (const url of remaining(rows)) {
    await page.goto(url)
    rows.push(await scrape(page))
    await ctx.checkpoint()
  }
  return rows
})

API

outlive(solari, task, options?)

Runs task and resolves with whatever it returns. outlive owns the browsers: it launches them, replaces them, and closes every one of them before returning. The Solari client stays yours and is never closed.

It throws three ways:

  • your value, unchanged and by identity, if your task rejected with something that was not a session death — outlive does not retry your bugs, and it does not coerce your rejection into an Error either. Reject with an object and you get that object back;
  • OutliveError with code: "gave_up", if the browser died more than maxRelaunches times;
  • OutliveError with code: "invalid_option", before anything is launched, if an option is not a number outlive can use.

ctx.relaunch() can also throw OutliveError with code: "checkpoint_failed" or code: "abandoned", and those reach you only if your task lets them: they are thrown into your code, not out of outlive(). A run that ends because one of them escaped reports outcome: "failed" with that error — outlive hands a task's error back by identity rather than reinterpreting it.

Do not close the browser yourself. outlive owns it, and page.context() .browser().close() is indistinguishable from the platform ending the session: outlive will treat it as a death and relaunch.

task(page, ctx)

ctx
attempt how many times your task has been entered: 1, then 2 after it was re-entered on a fresh browser, … This is the re-entry signal. It counts entries, not browsers: a ctx.relaunch() replaces the browser under a running entry and leaves attempt alone
resumedFrom { url, checkpointAt } when a checkpoint was restored into this browser before the entry started. Absent on the first entry, after a death that happened before the first checkpoint existed, and for every browser ctx.relaunch() brought in — which is why attempt and not this is the signal
checkpoint() capture cookies + localStorage + URL now. Never throws; resolves true if a checkpoint was written, false if it was not
relaunch() replace this browser now and carry on in the same entry; resolves with the new Page. See below
page is a playwright-core Page, already navigated to resumedFrom.url when there is one

page is a playwright-core Page, already navigated to resumedFrom.url when there is one.

options

Option Default
checkpointEveryMs 30_000 automatic capture interval
maxRelaunches 5 ~5 sessions ≈ one hour of task
relaunchBackoffMs 2_000 wait after a launch that failed; a death relaunches at once
launch {} passed to solari.launch() for every browser. { retries: 2, probe: true } is worth considering: one browser in ten came back already dead
viewport 1280×800
startUrl navigated to before the first entry only
navigationTimeoutMs 45_000 cap on the resume goto
deathGraceMs 250 how long a dead browser's task gets to finish before the replacement is launched. Not the ctx.relaunch() window — see below
diagnostics "safe" "full" puts raw URLs and raw error text in the logs and the event. See Security
onEvent called once per run with the wide event
logger quietLogger consoleLogger, noopLogger, or your own sink

The wide event

One per run, on every path including the ones that throw:

{
  "runId": "3f2a91c7-6b40-4a1e-9d2c-8f5e0b71c4aa",
  "outcome": "completed",
  "relaunches": 1,
  "manualRelaunches": 0,
  "checkpointCount": 24,
  "lostWorkMs": 2315,
  "maxLostWorkMs": 2315,
  "totalMs": 751943,
  "attempts": 2,
  "releaseFailed": 0
}

Those counters are run 3 of the outlive arm in benchmarks/survival.json, copied from the file by scripts/sync-readme-event.ts. Only runId is invented, because the bench does not log it.

outcome is completed, failed (your task threw, and you get that error unchanged) or gave_up (out of relaunches — you get an OutliveError with code: "gave_up").

lostWorkMs is defined exactly: at each death, the milliseconds between the last completed checkpoint and the moment the death was detected, summed over the run. Not the moment the browser finished closing, and not including the 250 ms grace. If the newest checkpoint is older than the entry that died, the measurement starts at the entry instead, so no second of wall clock is counted twice. maxLostWorkMs is the worst single death by the same rule. Lower checkpointEveryMs to buy them down — a capture costs ~200 ms.

manualRelaunches counts the browsers ctx.relaunch() actually handed to your task; the rest of relaunches were deaths outlive answered itself and launches that failed. One budget, two stories: a run with relaunches: 2, manualRelaunches: 1 burned two sessions, one of them because the task asked.

relaunches is one unit per launch attempt, not per browser. A death whose first replacement fails to launch spends two: the failed attempt and the one that worked.

releaseFailed counts sessions outlive could not confirm were released after a close() failed. It should always be 0; anything else means a browser is still holding a slot.

It carries no secret outlive itself holds: no API key, no cookie values, no checkpoint, no URL. errorName and errorCode are the classifications you would group by, and error is the message after every URL has lost its path and query, credential-shaped name=value pairs have been blanked, and the whole thing has been clipped to 300 characters.

That last part is a net, not a guarantee — error is text your task produced, and outlive cannot know every shape a secret takes. diagnostics: "full" skips the scan entirely; use it where you trust the sink.

Replacing the browser under a running step

Sometimes the task cannot start again from the top, and re-entry is the wrong answer. ctx.relaunch() is the other one: it replaces the browser without calling your task again.

await outlive(solari, async (page, ctx) => {
  let active = page
  await signIn(active)
  await ctx.checkpoint()

  // The one event that fires when the platform ends the session — about 1.5 s
  // before any page call would notice. This is what a library holding your page
  // listens to, and it is the right place to ask for a replacement.
  let rescue: Promise<Page> | null = null
  active.context().browser()?.once("disconnected", () => {
    rescue = ctx.relaunch()
    // Awaited below, so keep a rejection from becoming an unhandled one while
    // the task is still somewhere else.
    rescue.catch(() => undefined)
  })

  for (const row of rows) {
    try {
      await process(active, row)
    } catch (error) {
      if (rescue === null) throw error      // not a death: your bug, your error
      active = await rescue                 // same entry, new browser
      rescue = null
    }
  }
})

It resolves with a Page from a fresh browser holding the last checkpoint's cookies and localStorage, already at its URL — the same restore a death gets, without leaving the function. It works whether the current browser is already dead or still alive.

How long you have. When the browser dies, outlive waits deathGraceMs (250 ms by default) for your task to finish on its own, and then launches the replacement itself. A relaunch() that arrives at any point before that browser is handed to a new entry joins the launch already in flight: the re-entry is cancelled, and your call resolves with that page. So the window is the grace plus the launch — about 2.4 s measured, not 250 ms — and it is a window you can widen with deathGraceMs. Past it, relaunch() throws abandoned, because by then your task is already running somewhere else.

  • The page you were called with is dead afterwards. outlive closes the browser it belonged to, so every call on the old page rejects with "Browser closed" — and because the current browser is alive, that rejection fails your run instead of being read as a session death. Use the page relaunch() resolves with, and make any Locator or CDPSession again.
  • It spends one of maxRelaunches, exactly as a death does. The budget is one unit per launch attempt, wherever the attempt came from.
  • A live browser is checkpointed first, and if that capture fails nothing is replaced: you get OutliveError with code: "checkpoint_failed" and the browser you had. Resuming from a stale checkpoint would move your task backwards — or to about:blank — without telling you.
  • A browser that died before your first checkpoint has nothing to restore, and then the page you get back is blank and signed out — the log line says restored: false. The platform can end a session 1.5 s after launch() returns, so checkpoint early.
  • Two calls while one is in flight are one relaunch — the second gets the same promise — and a relaunch racing outlive's own death handling produces exactly one new browser, never two.
  • ctx.checkpoint() and the automatic captures follow the new browser, and ctx.attempt does not change: this is a continuation, not a re-entry.
  • It throws OutliveError with code: "gave_up" when the relaunch budget is spent, and code: "abandoned" when the entry is over. Neither closes anything: in both cases the browser you have is still yours.

Why it exists. handraise is handed a Page — it streams the browser to a human's phone and waits for a decision — so when the session ends mid-handoff it cannot replace a session it does not own, and re-entry would cost the human their link and their QR scan; ctx.relaunch is the smallest surface that lets that step keep running on a replacement browser, and the next handraise release passes it straight through as reattach. The reasoning, the contract and the alternatives that were rejected are in ADR 0002.

How death is detected

Three signals, and the control plane is not one of them:

  1. browser.raw.once("disconnected") — fires first, about 1.5 s before anything else notices.
  2. browser.isConnected() — local socket state, free, truthful.
  3. the message substring "Browser closed" — the last resort, for a call that threw before either of the above was checked.

patchright's TargetClosedError has no code and no status; its constructor.name is minified and its name is plain "Error". The substring is the only stable marker, which is why it is third and not first.

Neither signal is reliably first, so both directions get deathGraceMs. A task whose last call succeeded is usually still resolving when the death arrives, so a death waits for it. And a page call with a short timeout of its own can fail before the socket notices — measured at 394 ms in a live run — so a task error waits the same window for a death before it is believed. A genuine failure therefore takes one grace window on its way out, and a genuine death is never reported as your bug.

Choosing checkpointEveryMs

A checkpoint costs ~200 ms on the same connection your task is using, so the default of 30 s spends about 0.7 % of a busy task's time. It is a target interval, not a cap: a capture can fail — the browser is already dying, the round trip times out — and then the next death costs more than one interval. await ctx.checkpoint() returns false when that happens, which is the only way to know. Two rules beat any interval:

  • checkpoint after anything expensive or non-repeatable (a login, a payment, a page that took a minute to reach);
  • keep progress in your own scope, so a re-entry skips what is already done.

Design decisions

Security

See SECURITY.md. In short: a checkpoint contains session cookies. It is held in memory for the length of the run and never written to disk, never logged and never sent anywhere except back into the replacement browser. Log lines and the wide event carry the checkpoint's hostname, never its URL, because a Solari preview URL carries a bearer token in its query string. Any text outlive did not write — your task's error message, an SDK failure — has its URLs reduced to hostnames and its credential-shaped name=value pairs blanked before it is logged. That is a net, not a proof: outlive cannot know every shape a secret takes in your own messages. diagnostics: "full" turns all of it off, deliberately and explicitly. If you persist a checkpoint yourself, treat it as a credential.

Seen by someone else

The ~10-minute lifetime is not particular to this library's workload. An unrelated challenge submission, a computer-use benchmark that drives one browser through many generated app variants, reports in its own scorecard:

The session closed during seed 3 (~10–14 min of continuous use). For a larger matrix, rotate the browser session every ~4 variants (or 1 per variant) to avoid infra aborts.

reports/step-06-scorecard.md, itw-code/solari-cookbook @ ae00916

Rotating by hand every four variants is the workaround outlive replaces: the death is detected on the connection and the task is re-entered, so the matrix does not have to know the platform's clock.

Development

bun install
bun run lint          # biome + oxlint (anti-slop) + the embedded test app
bun run typecheck
bun test src/ test-app/
bun run build && node scripts/dist-smoke.mjs
bun run test:e2e      # live: waits ~14 min for a real session to die
bun run bench         # live: 2 × 5 tasks of 12 minutes

The live scripts need SOLARI_API_KEY in .env and spend plan quota. Check bun --env-file=.env scripts/cleanup-sandboxes.ts before and after: the plan allows two concurrent sandboxes and the bench holds one of them.

MIT © Simon Doba

About

Solari cloud browser sessions die after ~10 minutes. outlive checkpoints the session, notices the death, relaunches, and re-enters your task where it left off. Baseline 0/5, outlive 5/5.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages