Skip to content

renderer: the app-preview runtime lifecycle is bounded and balanced — timeouts, abort on unmount, an attachment lease per card, retry that restarts, a failure the card can show - #157

Draft
kai-openswarm wants to merge 8 commits into
openswarm-ai:devfrom
kai-openswarm:c10/b-preview

Conversation

@kai-openswarm

Copy link
Copy Markdown

Draft, stacked on #153 (which is stacked on #152 and #147; the extra commits in the diff disappear as those merge). The renderer half of #153's per-attachment runtime ref-counting. Renderer hardening only; no product features.

useRuntimePreviewUrl drives the App Builder preview: it asks the backend to start a workspace runtime when a card mounts, listens for the runtime's URL and logs, and detaches on unmount. Before this change a start that never answered kept the card on its placeholder forever; a card that unmounted before its start landed could leak a runtime (or, on the ref-counted backend, kill the one another card was using); the log-socket reconnect loop re-issued starts; and a failed start had no visible state and no way to retry.

Now
Bounded Every request goes through a small pool (createRuntimeRequestPool) with a per-request timeout and an abortAll() the effect's cleanup calls, so nothing outlives the card. Readiness is settled by the runtime:status frame or a bounded HTTP status probe (1 s interval, 60 s startup budget).
Balanced Each mounted card holds an attachment lease — a fresh attachment_id on its start and its stop (runtimePreviewLease), the renderer half of #153's per-attachment ref-counting and tombstones: a start that may have reached the backend is always balanced by a stop, and a stop that arrives after a remount cannot release someone else's runtime.
Retry that restarts A retry after a possibly-attached start restarts (which does not increment the count) instead of starting again; only a restart that finds nothing running on a new-mode workspace earns one fresh start (planRuntimeRequest / shouldStartAfterAmbiguousRestart, pure). A dropped log socket schedules a probe instead of another start. Safe mode still gates only the mount-time start.
Visible failure A failed attempt surfaces error and retry(); DashboardViewCard renders the message with a Retry button instead of a spinner.

pickPreviewUrl (the render-time URL choice) moves to its own module unchanged.

Tests (node:test): the request pool (a timeout aborts, abortAll cancels, settled requests leave the pool), the retry planner, the lease URL builder (start/stop carry the id, status/restart do not), and pickPreviewUrl. The hook's behavioural tests are React-rendered and stay outside this change (upstream has no DOM test runner). Proof: tsc 0; renderer runner 150/150; upstream's linter no new findings; packaged macOS app with #153's backend and this renderer: upstream smoke 5/5 + the characterization suite (#146) 19/19 (those suites do not open an app preview; the lease semantics themselves are covered by #153's backend tests and the pure tests here).

… a window, so reducer tests run under node:test

Both modules touched window at import time (port/host derivation, the fetch
interceptor install, the debug handle), so any node:test file that imports a
reducer importing API_BASE died with 'window is not defined' before its first
assertion; fetchSessionsStrip.test.ts has been red that way since the
resilience work landed, unnoticed because nothing runs these tests in CI. In a
renderer (window present) nothing changes: same port/host, same interceptor,
same handle. Without one the module answers with the defaults and installs
nothing.
Nothing ran any of them in CI: the 235-file backend pytest suite, the 22
renderer node:test files and the edge suite were run by hand, one file at a
time, so a regression only surfaced when someone happened to run the right
one. Three small workflows, hosted ubuntu, path-filtered, read-only token:

- backend-tests: pytest on Python 3.13 from the locked requirements, plus a
  completion assertion (junit testcase count == collect-only count) so a test
  process that dies mid-run can never read as green
- frontend-tests: tsc --noEmit + node:test via tsx over src/**/*.test.ts(x),
  through frontend/scripts/run-tests.mjs (the runner the tests already name)
- edge-tests: pytest for openswarm-edge

All three are green on the current tree: 2951 backend tests, 143 frontend
tests across 22 files, 14 edge tests.
Two of five hosted runs of the backend suite stalled at 99% until the job cap
with no summary and no junit: one test blocked forever on a bare
ws.receive_json() (fixed on its own in a separate change). A CI lane should
never depend on every test being unable to hang, so add pytest-timeout to the
dev requirements and run the suite with --timeout=300. On Linux the default
signal method fails just the offending test and the run continues, so the
report and the "every collected test ran" assertion stay meaningful.
test_ws_endpoint_streams_a_full_turn_end_to_end read the socket with a bare
ws.receive_json() in a 40-iteration loop and broke only on the assistant reply.
When the loop ends early for any reason (fewer than 40 events, no reply), the
next receive blocks forever and the whole pytest run stalls at 99% until the
job cap. On hosted runners it does exactly that intermittently, on Linux and
Windows alike: the turn path's configure_provider_env decides whether 9Router
needs reviving from provider evidence earlier tests may leave behind, and that
revival spawns/installs the router behind a module-level asyncio.Lock; the
background turn-label aux call reaches the same machinery. Neither is part of
this test's contract ("SDK and WS auth mocked, everything else real").

Pin both out with monkeypatch, bound every receive at 5s (a regression now
fails this test instead of hanging the runner), and wait for the turn's
completed status before asserting on session.messages so the assertion cannot
race the loop's tail.
Same class as the config/backendConnection change: safeMode.ts read `window`
at import, and dashboardLayoutSlice imports it, so any reducer test that
imports the slice died under node:test before it ran. Guard the read; in a
renderer nothing changes.
…e routes now ask

The agent manager, its routes, and the service/workflows/settings apps, with the
hardening a hosted deployment forced us to build but that stands on its own:

- Containment (R0): a session's browser commands, tool results and approvals are
  bound to their session and owner (ws_manager: send/resolve carry owner and
  claimant, connections carry an optional identity with expiry and revocation);
  the sessions list, history and prompt prediction never read across owners.
- Redaction (F1): settings leave the process redacted (a {configured, last4}
  marker instead of the secret) on the analytics sync and the /settings routes'
  consumers; the redaction helper is shared.
- Launch stamps the session at birth (see also openswarm-ai#145) and the launch route runs
  the first turn only after admission says so; concurrent root turns are capped
  (TurnAdmission, OSW_MAX_CONCURRENT_TURNS) so firing thirty agents does not
  spawn thirty CLIs at once.
- SSRF guard: v4-mapped and transition encodings are judged as network targets;
  loopback is allowed on the desktop (previews) and only a hosting policy can
  turn it into a blocked target.
- Service: the shutdown fuse disarms last on a clean shutdown; the runtime port
  and settings gateway are injected boundaries; the analytics client redacts.
- Workflows: the scheduler claim gate is off-means-off; a deterministic
  at-least-once scheduler contract with its reference model; lifecycle events.
- Settings store: the in-memory mirror is keyed by path+stat, and the atomic
  writer stages its temp file beside the settings file.
- Agent manager: session store, event sink (a Null sink by default) and provider
  runtime are injected, which is what the delivered tests build on.

The hosting seam (backend/apps/hosting/policy.py): routes take
`scope: RequestScope = REQUEST_SCOPE` and managers ask `hosting_policy()`; both
answer with the desktop defaults here (nobody owns anything, everything is
allowed, no per-owner workspace roots, no tool denials, loopback allowed). A build
that hosts supplies its own provider; nothing else in the app knows which build
it is. REQUEST_SCOPE is a FastAPI dependency that is also the desktop scope, so
routes called directly (as several tests do) keep working.

Not in this change: the outputs app (separate change), dashboards (mixed with a
card-family feature; separate change), anything hosted or multi-tenant.

Proof: this tree with Python 3.13 from requirements.lock — 3074 passed / 15
skipped (upstream's suite plus the delivered tests); upstream's linter reports
no new findings beyond pre-existing debt (two grandfather entries added, both
noted in linter/config/config.json).
The App Builder / outputs app's hardening:

- Execution contract. Generated code runs through an ExecutionRequest →
  SubprocessExecutor path with an explicit result status (success / rejected /
  timeout / error), a strict environment (only what the run needs; provider and
  auth secret families are scrubbed from the child), and one shared sandbox
  policy (backend/sandbox_policy.py: allowlist, blocked builtins, timeout, the
  wrapping preamble/hardening/postamble). Unapproved runs carry the runtime
  hardening; an explicit Run Anyway drops it. code_safety.py keeps its own copy of
  the lists because openswarm-edge mirrors that file verbatim; a test asserts the
  two stay equal.
- Path security. Workspace and output file routes resolve through one
  contained-path helper: ids are validated (including Windows device names),
  symlink escapes and traversal are refused, and the tests cover the write,
  serve and delete routes.
- Restore staging. A version blob is decompressed in bounded chunks to a
  staging file outside the destination workspace, size-capped while reading and
  hashed incrementally, and only a fully verified blob is moved into place; a
  truncated, oversized, trailing-bytes or wrong-digest blob restores nothing.
- Publish gate. The security scan blocks publish outright; force skips
  capability findings only. Exports never carry stale inline files once a
  workspace has diverged from the record.
- Runtime. Ready/error/console reports are per instance; ghost runtimes are
  reaped; a workspace's runtime status payload is stable.
- Owner scoping goes through the hosting seam (`scope: RequestScope =
  REQUEST_SCOPE`); on the desktop it allows everything and lists everything.

Not in this change: the shared Python venv identity/cache for generated apps'
backends and the template runtime guard (they pin the embedded interpreter's
version and follow the Python 3.14 promotion), the workspace backend migration,
and the hosted target surface.

Proof: this tree with Python 3.13 from requirements.lock — 3185 passed / 10
skipped (upstream's suite plus the delivered tests).
… timeouts, abort on unmount, an attachment lease per card, retry that restarts instead of stacking, and a failure the card can show

useRuntimePreviewUrl drives the App Builder preview: it asks the backend to
start a workspace runtime when a card mounts, listens for the runtime's URL and
logs, and detaches on unmount. Before this change a start that never answered
kept the card on its placeholder forever, a card that unmounted before its
start landed could leak a runtime (or, on the ref-counted backend, kill the one
another card was using), the log-socket reconnect loop re-issued starts, and a
failed start had no visible state and no way to retry.

Now:
- every request goes through a small pool with a per-request timeout and an
  abortAll() the effect's cleanup calls, so nothing outlives the card;
- each mounted card holds an attachment lease (a fresh attachment_id on its
  start and stop), the renderer half of the backend's per-attachment
  ref-counting/tombstones (openswarm-ai#153): a start that may have reached the backend is
  always balanced by a stop, and a stop that arrives after a remount cannot
  release someone else's runtime;
- a retry after a possibly-attached start restarts (which does not increment
  the count) instead of starting again; only a restart that finds nothing
  running on a new-mode workspace earns one fresh start (planRuntimeRequest /
  shouldStartAfterAmbiguousRestart, pure);
- readiness is settled by the runtime:status frame or a bounded HTTP status
  probe (1 s interval, 60 s startup budget); a dropped log socket schedules a
  probe instead of another start; safe mode still gates only the mount-time
  start;
- a failed attempt surfaces `error` and `retry()`; the dashboard view card
  renders the message with a Retry button instead of a spinner.

pickPreviewUrl (the render-time URL choice) moves to its own module unchanged.
Tests (node:test): the request pool (timeout aborts, abortAll cancels, settled
requests leave the pool), the retry planner, the lease URL builder, and
pickPreviewUrl. The hook's behavioural tests are React-rendered and stay
outside this change (upstream has no DOM test runner).
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.

1 participant