outlive
v0.1.0
Published
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.
Maintainers
Readme
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.
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 outliveimport { 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.
- Liveness comes from the connection, never from the API.
GET /sessions/:idreportedactivefor a session that had been dead for five minutes. outlive uses thedisconnectedevent,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-coreis a peer dependency and is used for types only — outlive imports no value from it. Note that the SDK actually returnspatchright-core'sPage: 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 thePagethe types describe. handraise makes the same choice, so the series is consistent; the cost is that a consumer who has no Playwright installed getsplaywright-corepulled in for its.d.tsfiles.
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. Latest run — 40/40
polls, one relaunch, one TOTP typed, 27 checkpoints, 2.1 s of work repeated,
the session ending at 608 s. 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 250 ms before it becomes the outcome. 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.
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
Erroreither. Reject with an object and you get that object back; OutliveErrorwithcode: "gave_up", if the browser died more thanmaxRelaunchestimes;OutliveErrorwithcode: "invalid_option", before anything is launched, if an option is not a number outlive can use.
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 | 1 on the first entry, 2 after the first relaunch, … This is the re-entry signal. |
| resumedFrom | { url, checkpointAt } when a checkpoint was restored into this browser. Absent on the first entry — and also after a death that happened before the first checkpoint existed, 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 |
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 |
| 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,
"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.
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.
How death is detected
Three signals, and the control plane is not one of them:
browser.raw.once("disconnected")— fires first, about 1.5 s before anything else notices.browser.isConnected()— local socket state, free, truthful.- 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.
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
- ADR 0001 — re-entry instead of a transparent Page proxy. The proxy was built, it passes every lint rule, and it was rejected anyway: it submits a form with the first field missing and logs nothing.
- Measurement 01 — what a relaunch costs.
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.
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 minutesThe 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
