Native process isolation¶
The native runtime launches each app as a plain OS process. By default that process runs with the full filesystem reach of the ShinyHub service user. The isolation dial narrows that reach without requiring the Docker runtime, using Landlock - the Linux kernel's unprivileged, self-imposed access-control mechanism.
It is a blast-radius boundary, not a defense against determined hostile code.
If your threat model is genuinely untrusted app code in a multi-tenant setting,
run the Docker runtime instead, which adds process and
user-namespace isolation, and can isolate apps from each other on the network:
each app runs in its own container, and all app containers share a dedicated
bridge network (shinyhub-apps) created with inter-container communication
(ICC) disabled. The proxy always reaches apps over the loopback ports published
on the host, so app-to-app traffic on that network is what ICC blocks.
Enforcement depends on the daemon. Whether ICC-disabled actually blocks
container-to-container traffic is enforced by the Docker Engine's netfilter
(iptables/nftables) rules, which is a Linux Docker Engine feature. This is
verified on a stock Linux Engine (Docker 29.x): a container on the ICC-disabled
network cannot open a connection to a sibling container, while on the default
bridge it can. Some developer daemons - notably OrbStack on macOS -
flat-route all containers for convenience and do not enforce Docker network
isolation at all (containers reach each other even across separate networks), so
this isolation is a property of a Linux Docker Engine deployment, not of every
daemon; confirm it on yours before relying on it for untrusted multi-tenant
code. Setting runtime.docker.network_mode: host opts out entirely (the
container shares the host network stack); use it only when apps are trusted.
The dial¶
Or via environment: SHINYHUB_RUNTIME_NATIVE_ISOLATION=standard.
| Level | Effect |
|---|---|
standard (default) |
Filesystem confinement (below) plus NO_NEW_PRIVS (blocks setuid privilege escalation). |
off |
No isolation. Use only for a trusted app that requires writes outside its own bundle/data directories. |
strict (tighter reads and network restriction) is reserved for a later release
and is rejected at load until then, rather than silently treated as standard.
What standard does¶
The app process is left free to read the whole filesystem (so interpreters and shared libraries load normally) but may write only to:
- its own deployment directory,
- its persistent per-app data directory (
storage.app_data_dir), if configured, /tmpand/dev(scratch and device nodes such as/dev/null,/dev/urandom- dangerous device nodes stay protected by ordinary file permissions).
Everything else - other apps' bundles, the control-plane database, system
directories - is read-only to the app. TMPDIR is pointed at a private
directory inside the app's own tree so a well-behaved app gets an isolated
scratch area while /tmp remains available as a fallback.
To keep cache-writing launchers working under the read-only root, TMPDIR,
UV_CACHE_DIR, and XDG_CACHE_HOME are pointed at writable subdirectories of
the app's own tree. (uv run initializes a cache even with --frozen
--no-sync; without this redirect it would be denied and the app would fail to
start.)
Both the long-running app process and one-shot runs (scheduled jobs) are confined the same way.
Enforcement is applied by a small re-exec step: ShinyHub launches the app through
a hidden __sandbox subcommand of its own binary, which imposes the Landlock
rules on itself and then executes the real app command. The app never sees the
sandbox policy in its environment.
Dependency builds and post-deploy hooks¶
The dependency-build step (uv sync / renv::restore) and manifest [[hooks]]
commands execute deployer-controlled code (build backends, package configure
scripts), so on any kernel with Landlock they run under the same standard
confinement as the app process - independent of the isolation dial. Their
writable set is:
- the version's bundle directory (where
.venvand the redirectedUV_CACHE_DIR/XDG_CACHE_HOME/RENV_PATHS_ROOTcaches live), - the app's managed-Python store (below),
/tmpand/dev.
Their environment is the same allow-listed subset of the service environment
that app processes receive (see Environment): locale,
proxies, TLS trust, tool cache dirs, and package-index configuration
(UV_EXTRA_INDEX_URL and friends) pass through; everything else - including
any credential in the service environment - is scrubbed, and
SHINYHUB_APP_ENV_ALLOW extends the list. The app's own stored env vars
(including decrypted secrets) are layered on top, so builds and hooks see what
the app sees at start. The sandbox does not restrict network egress (Landlock
is filesystem-only), so dependency downloads reach PyPI/CRAN or a configured
private index normally.
Managed Python interpreters¶
When no system Python satisfies a bundle's requires-python, uv downloads a
managed interpreter at build time. uv keeps that store in a data directory
(UV_PYTHON_INSTALL_DIR), not in its cache, so the build sandbox gives it a
dedicated writable location: <apps_dir>/<slug>/uv-python, a sibling of the
app's versions/ directory. One download serves every version of the app,
survives version-retention pruning, and is removed with the app. The store is
deliberately per-app rather than shared: a shared writable store would let one
app's build backend tamper with an interpreter that another app executes.
Operators who prefer a single shared store (for example, to download each
interpreter once per host) can set UV_PYTHON_INSTALL_DIR in the service
environment. The build sandbox honors it: the directory becomes the writable
managed-Python store for every build and hook, and the variable passes through
to app launches. This trades cross-app interpreter isolation for a shared
download; choose it only when all deployers are trusted.
A build step that is still denied a write (a tool writing outside the set above) fails with the sandbox's writable paths named in the error, rather than only the tool's raw "Permission denied".
Requirements and graceful degradation¶
Isolation is Linux-only and best-effort:
- On a kernel with Landlock (roughly 5.13+, with the feature compiled in and active - e.g. Ubuntu's stock kernels), the confinement above is enforced.
- On an older kernel, or one without Landlock (some minimal/microVM kernels), the dial degrades to a no-op: the app still starts, just without confinement.
- On non-Linux builds there is no enforcement backend; configuring isolation logs a startup warning and runs without it.
Because Landlock only downgrades and never blocks startup, turning the dial on is safe to roll out; where the kernel supports it, it takes effect, and where it does not, apps keep running.
Choosing native+isolation vs Docker¶
Native + standard |
Docker runtime | |
|---|---|---|
| Filesystem write confinement | yes (Landlock) | yes |
Privilege-escalation block (NO_NEW_PRIVS) |
yes | yes |
| Process / PID isolation | no | yes |
| Network isolation | no | yes (namespace) |
| Separate user boundary | no (same UID) | yes |
| Needs a container runtime | no | yes |
Reach for native isolation when you want meaningful hardening of the lightweight native runtime; reach for Docker when you need full multi-tenant isolation.
Worker isolation (session isolation dial)¶
The worker isolation dial controls how many browser sessions share a single R/Python worker process. Changing this dial trades resource consumption for performance isolation between concurrent users.
Modes at a glance¶
| Mode | Clients per worker | HOL blocking | RAM scaling | Performance isolation | Governed by |
|---|---|---|---|---|---|
multiplex (default) |
Unbounded (all sessions share one process) | Yes - a heavy session stalls others | One process per replica; scales with replicas |
None within a replica | replicas, max_sessions_per_replica, autoscale |
grouped |
Up to grouped_size clients per worker |
Reduced (only within the group) | One process per group, plus optional pristine warm_spares, up to max_workers |
Partial - groups are isolated from each other | grouped_size, max_workers, warm_spares, max_session_lifetime_secs |
per_session |
1 client per worker (one process per browser client) | Eliminated | One process per active client, plus optional pristine warm_spares, up to max_workers |
Full - each client runs in its own process | max_workers, warm_spares, max_session_lifetime_secs |
multiplex is the historical mode and is unchanged by this feature. grouped
and per_session are elastic: workers are started on demand and
terminated when their clients disconnect. Both elastic modes require a
single-node deployment (see caveats below).
Config surface¶
Per-app dial¶
Via CLI:
# Switch to per_session isolation with a ceiling of 30 workers
shinyhub apps set <slug> \
--isolation per_session \
--max-workers 30 \
--warm-spares 2
# grouped: up to 5 clients per worker, ceiling 20 workers
shinyhub apps set <slug> \
--isolation grouped \
--grouped-size 5 \
--max-workers 20
# Absolute worker lifetime backstop: terminate after 3600 s regardless of activity
shinyhub apps set <slug> --max-session-lifetime 3600
# Revert to multiplex (drops the elastic pool)
shinyhub apps set <slug> --isolation multiplex
Verified CLI flags (shinyhub apps set --help):
| Flag | Description |
|---|---|
--isolation multiplex\|grouped\|per_session |
Session isolation mode |
--grouped-size N |
Clients per worker when using grouped (>= 1) |
--max-workers N |
Demand-driven worker ceiling for grouped/per_session (>= 1) |
--warm-spares N |
Healthy, unused elastic workers kept ready (0..max-workers; default 0) |
--max-session-lifetime SECS |
Absolute worker lifetime in seconds; 0 = unlimited |
Via PATCH API (for tooling):
curl -X PATCH https://shinyhub.example.com/api/apps/<slug> \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"worker_isolation": "per_session",
"worker_max_workers": 30,
"worker_warm_spares": 2,
"worker_max_session_lifetime_secs": 3600
}'
PATCH keys: worker_isolation, worker_grouped_size, worker_max_workers,
worker_warm_spares, worker_max_session_lifetime_secs.
Via manifest (travels with the bundle)¶
Declare the policy in shinyhub.toml so it is reconciled on every deploy:
[app.worker]
isolation = "per_session"
max_workers = 30
warm_spares = 2
max_session_lifetime_secs = 3600
For grouped mode, also set:
Manifest fields (WorkerManifest, internal/deploy/hooks.go):
isolation, grouped_size, max_workers, warm_spares,
max_session_lifetime_secs.
When the [app.worker] block is present, all five columns are reconciled on
every deploy. Omitting the block leaves any previously-set value unchanged
(same semantics as the [app.autoscale] block).
Fleet defaults (server-wide)¶
# shinyhub.yaml
runtime:
default_worker_isolation: multiplex # multiplex | grouped | per_session
server:
host_budget_mb: 0 # 0 = no host-level guard; > 0 = MiB limit
min_available_memory_mb: 256 # unset = 256 (default floor); 0 = disable; > 0 = MiB floor
Env vars: SHINYHUB_RUNTIME_DEFAULT_WORKER_ISOLATION,
SHINYHUB_SERVER_HOST_BUDGET_MB, SHINYHUB_SERVER_MIN_AVAILABLE_MEMORY_MB.
default_worker_isolation is the fallback applied when an app's
worker_isolation is empty. The default is multiplex, preserving backward
compatibility for every existing app.
An app payload therefore carries both values: worker_isolation as stored
(empty means "inherit") and effective_worker_isolation, the resolved mode,
which is never empty. Tooling deciding what an app's pool supports must read the
effective one, since an inheriting app on an elastic fleet runs an elastic pool
while its stored column says nothing.
host_budget_mb enables the host-capacity guard (see below). Leave it at 0
to disable the guard.
Caveats and limitations¶
per_session isolates the browser CLIENT, not the tab. A "client" is a
cookie identity. Multiple tabs in the same browser profile share a single
cookie jar and therefore share the same worker process. True per-tab
isolation is not available in Phase 1.
Single-node only. grouped and per_session require a single-node
deployment. Setting either mode when the server is configured with a Postgres
(clustered) DSN is rejected by the PATCH API and the deploy pipeline - the
server itself boots normally; the error surfaces when you save the worker
settings or deploy a manifest that sets the mode. multiplex retains full HA
behavior and is unaffected.
Warm spares remove the process boot from the request path. With
warm_spares > 0, ShinyHub continuously maintains that many healthy workers
which have never served a client. The first new client atomically consumes a
spare, and replenishment begins immediately, subject to max_workers and the
runtime memory floor. With warm_spares = 0 (the default), the first request
still cold-starts a worker and sees the loading page while it boots.
When runtime.snapshot.enabled is on and the selected runtime can reclaim the
worker's cgroup memory, a ready spare is frozen and its resident memory is
reclaimed to swap. Demand resumes that same process, checks readiness, and only
then routes the client. If snapshot support is disabled or unavailable, the
same policy degrades to a healthy running spare. This is deliberately not
copy-on-write cloning: every spare is independently booted, and resume thaws
the same PID/container. Post-initialization fork or CRIU-style cloning would
need a much stricter application/runtime contract and is not implied by this
setting.
Warm spares count toward max_workers. A target of 2 with max_workers = 30
leaves room for at most 28 simultaneously assigned workers while both spares
are pristine. A spare is never recycled after serving an identity; it follows
the normal disconnect/lifetime termination path. The absolute session lifetime
timer starts after the consumed spare successfully resumes, not while it waits
unused.
Cold bursts pack onto provisioning workers. In grouped mode a new
client is placed on the fullest worker that still has room under
grouped_size - including workers that are still booting. Clients placed on
a booting worker wait on the loading page and connect when it registers. A
burst of simultaneous cold arrivals therefore provisions
ceil(clients / grouped_size) workers and sheds nobody until the full
max_workers x grouped_size ceiling is genuinely reached, instead of
reserving one worker per arrival and shedding at max_workers clients.
WebSocket upgrades are parked during boot. An upgrade whose pinned worker is still booting is held - up to 30 seconds, released immediately if the client disconnects - and forwarded once the worker registers, instead of being answered with the loading page (a non-101 that hard-fails WS clients; browsers re-enter via the splash's reload loop, scripted clients cannot). If the boot exceeds the window the upgrade falls back to the loading page.
Per-worker capacity view. shinyhub apps show <slug> renders the live
worker table for elastic apps - slot, routing status (booting/suspending/
suspended/resuming/running/draining), warm-spare marker, bound sessions against
grouped_size, pid, port - plus the
admission-ceiling arithmetic, and shinyhub apps metrics <slug> adds
per-worker CPU and RSS. The same data rides the app envelope (worker_pool)
and the metrics poll consumed by the dashboard. Tune grouped_size and
max_workers against this real session distribution instead of counting
worker processes by hand.
Per-worker cgroup limits. Each elastic worker receives the FULL per-app
memory_limit_mb and cpu_quota_percent, NOT a fraction. With max_workers = 30
and memory_limit_mb = 512, the worst-case host RAM for that one app is
30 * (512 + 150) = ~19 GiB (150 MiB base overhead per worker). Size the
host accordingly.
Host-capacity guard. When server.host_budget_mb is set, the API and
deploy pipeline reject any combination where
max_workers * (memory_limit_mb + 150 MiB) exceeds the budget. The check
runs when worker settings are saved (via the API or a manifest deploy) and
when an app's memory_limit_mb changes, not on server startup and not on
each incoming request. Set the budget to catch misconfigured limits early. The guard is inert unless the app also has an
effective memory limit (per-app or tier default); saving elastic worker
settings with NO active memory guard succeeds but returns an
X-ShinyHub-Warning header, which shinyhub apps set prints to stderr.
Runtime memory floor (on by default). server.min_available_memory_mb
is the runtime companion to the static budget: while the host's available
memory (MemAvailable) is below the floor, requests that would allocate a
NEW worker are shed with 503 (Retry-After: 5,
X-Shinyhub-Reject: memory-pressure) instead of spawning. Sessions already
bound to a worker keep routing, and the floor releases as soon as memory
recovers - no restart needed. When the key is not set at all, a default
floor of 256 MiB applies: an elastic OOM takes out a whole worker plus every
session bound to it, so the unset state fails safe, while 256 MiB is low
enough that a host with genuine headroom never sheds. Set an explicit 0
(or a negative value) to disable the floor entirely - doing so without
arming the static budget guard triggers the unguarded-isolation warning.
Shedding one incoming session is deliberate: without the floor the backstop
is the kernel OOM killer, which kills a live worker together with every
session on it. The memory-pressure reject reason is distinct from
pool-saturated so autoscaling does not read memory pressure as a scale-up
signal. If the memory reading is unavailable, the floor fails open
(admission proceeds). The floor is consulted only on elastic worker
allocation; multiplex deployments never probe it.
max_workers x grouped_size is a hard ceiling; overflow yields 503. A
new client is shed with 503 Service Unavailable and a Retry-After: 5
header only when every worker (running or booting) is at its per-worker cap
AND max_workers slots are occupied - max_workers x grouped_size clients
in grouped mode, max_workers in per_session mode. The shed client is
not queued.
max_session_lifetime_secs is an absolute backstop. When set (> 0), a
worker is terminated after that many seconds regardless of activity. Clients
whose worker is terminated will be reallocated to a new worker on their next
request (another cold start). Set this to reclaim long-lived workers from
abandoned sessions.
Not-yet-connected clients are reclaimed after a grace period. When a new
client is placed on a cold-starting worker (loading page), capacity on that
worker is held for it. If the client never opens a real connection after the
worker becomes ready (e.g. the tab was closed), its claim is automatically
released 15 seconds after the worker finishes booting; a worker whose last
claim is released terminates. This prevents abandoned cold-start flows from
leaking worker capacity. A client that does connect within those 15 seconds
cancels the reclaim and proceeds normally. max_session_lifetime_secs is
therefore not the only reclaim path; the 15-second grace window handles the
"seen the loading page, then disappeared" case independently.
Assigned elastic workers are ephemeral. They are NOT re-adopted on a server restart. Connected clients need a new worker on their next request. The configured pristine spare target is reconciled again during recovery, so a restarted host begins prebooting new spares without waiting for client demand.
Elastic apps skip fixed-replica booting at deploy. For grouped and
per_session apps, the deploy pipeline boots no fixed replicas. The app is
marked running, then any configured warm spares are provisioned asynchronously.
Warm-spare boot errors are logged and replenishment retries automatically with
backoff capped at one minute; they do not turn an otherwise prepared deployment
into a failed deploy. With no usable spare, boot errors still surface at
first-session time.
Recovering a previous bundle does not re-run preparation. After a failed deploy, ShinyHub restores the previously-good bundle. That is an activation of a deployment that already served, not a promotion, so it skips the post-deploy hooks (they ran when that bundle was first deployed, and they are app-controlled) and skips the dependency build for any deployment recorded as prepared. A deployment whose preparation state predates that record - including an elastic bundle deployed before elastic apps were prepared at all - gets a best-effort build whose failure is logged but never aborts the recovery. The restore path is an unattended safety net; it must not be able to fail and leave the app down.
Restart and rollback are activations too. Both bring back a bundle that
already served, so neither re-runs its post-deploy hooks. They differ from the
restore path only in the fallback: a deployment whose preparation state predates
the record is prepared for real and a failure surfaces, because someone is
waiting on the result. Skipping is never blind - if the built environment is
actually missing (a wiped .venv, an apps dir that did not survive a reboot, or
a deployment prepared under a container runtime and later moved to the native
runtime), it is rebuilt rather than launched against nothing.
A restart is therefore not a rebuild. If you want a bundle's dependencies and hooks re-run from scratch, deploy it again; changing an app's env vars or a worker dial also triggers a full redeploy.
Elastic apps are still prepared at deploy. The dependency build
(uv sync / renv::restore) and the manifest's [[hook]] on = "post-deploy"
blocks run once per deploy, before any worker can serve a request, exactly as
they do for a multiplex pool. A failure in either fails the deploy. Preparation
cannot be deferred to the workers: they launch with uv run --frozen
--no-sync, which performs no dependency work of its own, and a worker spawn
happens long after the deploy has reported its result, so a failure there would
be unattributable. The same runtime rule applies as everywhere else: under a
container runtime the host does not prepare deps, so hooks are skipped and the
skipped count is reported back to the developer.
Assigned-worker compute-idle reclaim is not implemented. Only pristine warm
spares are eligible for freeze/reclaim. Once a worker has been assigned, it
holds its resource slice until the client disconnects (or
max_session_lifetime_secs expires); it is never returned to the spare pool.
How to enable¶
- Set the dial via CLI:
The new routing policy is applied immediately, and any currently-connected sessions cold-start in their new worker on their next request.
On a running app, changing the isolation, group size, worker ceiling,
or lifetime triggers a redeploy in the background, which tears down and
re-registers the pool. Treat those changes as a pool restart: current
sessions are dropped. Changing only warm_spares is hot and preserves
assigned workers; the controller provisions or retires only pristine
spares. On a stopped or hibernated app every worker change is metadata-only
until the app starts. A settings-triggered redeploy does not re-run the
dependency build or manifest post-deploy hooks, because the bundle and its
environment are unchanged.
- Or declare it in
shinyhub.toml(recommended for reproducible fleets):
The block is reconciled on every subsequent deploy, so the policy survives host rebuilds.
-
Note on isolation changes. Switching from
multiplextogroupedorper_session(or between elastic modes) clears the current pool and drops all active sessions. Plan the change for a low-traffic window if session continuity matters. -
Verify. After enabling, check
shinyhub apps show <slug>(or the Configuration tab in the UI) to confirm the dial is set as expected.
HOL-elimination acceptance (k6)¶
Worker isolation at per_session eliminates head-of-line (HOL) blocking: a
CPU-heavy session cannot starve other sessions because each session runs in its
own worker process.
Mechanism¶
Under the default multiplex mode all sessions share one R/Python worker
process. A long-running computation monopolises that process's event loop; other
sessions stall until it yields. Observed degradation at N=50 with one CPU-heavy
session: render latency rises from ~0.8 s (p50 single-session baseline) to ~12 s
(p50 under HOL pressure).
Under per_session the heavy session runs in an isolated process and cannot
block others. Non-heavy sessions' latency stays near the single-session baseline.
Setup¶
Deploy the same app twice under different slugs and set the worker isolation mode via the API or the CLI:
# Deploy once as multiplex (default)
shinyhub deploy /path/to/heavy-app --slug demo-mux
# worker_isolation defaults to multiplex; no extra step needed
# Deploy again as per_session
shinyhub deploy /path/to/heavy-app --slug demo-iso
shinyhub apps set demo-iso \
--isolation per_session \
--max-workers 60
The "heavy app" must perform sustained CPU-intensive work that stays busy for
at least LT_HOLD seconds (default 60) - long enough to keep the multiplex
event loop occupied throughout the entire light-VU measurement window. A
one-shot computation that finishes in a few seconds lets both multiplex and
per_session phases show low latency and the p(95) < 3000 threshold passes
vacuously with no diagnostic value.
Use a continuously-busy app design: a tight compute loop driven by
reactiveTimer/invalidateLater (R/Shiny), or a long-running matrix or numeric
computation that iterates for the full session duration. The goal is that the
multiplex phase actually degrades (latency rises toward the ~12 s figure
described above) so the gate is meaningful.
k6 just holds the heavy VU's WebSocket open while measuring latency for the other VUs.
Command¶
# Side-by-side run: records both multiplex and per_session in one k6 invocation
make load-test-isolation \
LT_SLUG_MUX=demo-mux \
LT_SLUG_ISO=demo-iso \
ASSERT=1
# per_session only (threshold still checked):
make load-test-isolation LT_SLUG_ISO=demo-iso ASSERT=1
Full parameter reference (all optional except at least one slug):
| Variable | Default | Description |
|---|---|---|
LT_SLUG_MUX |
(empty) | Slug of the multiplex-mode app (the "before" evidence) |
LT_SLUG_ISO |
(empty) | Slug of the per_session-mode app (the "after" evidence) |
LT_HOST |
http://127.0.0.1:8080 |
ShinyHub server base URL |
LT_SESSIONS |
50 |
Total concurrent VUs per phase (1 heavy + N-1 light) |
LT_RAMP |
30s |
Ramp duration to reach the target VU count |
LT_HOLD |
60 |
Seconds the heavy VU keeps its WebSocket open |
LT_WS_PATH |
/websocket/ |
WebSocket endpoint beneath the app root |
LT_FIRST_MSG_TIMEOUT |
5 |
Seconds to wait for the first server frame |
LT_AUTH_COOKIE |
(empty) | Cookie header value for private apps |
ASSERT |
0 |
Set to 1 to enable k6 thresholds (fails CI if HOL not eliminated) |
Threshold (ASSERT=1)¶
The 3 s threshold is 3x the ~800 ms single-session baseline and well below the
~12 s multiplex-degraded figure. The run FAILS if per_session does not
eliminate HOL blocking. The multiplex phase carries no threshold - it is
expected to degrade and its numbers serve as the "before" evidence.
Results¶
Run the command above and record the headline output from k6:
HOL ELIMINATION RESULTS:
multiplex (demo-mux): established X/49 (Y%) light_ms p50=?? p95=??
per_session (demo-iso): established X/49 (Y%) light_ms p50=?? p95=??
Fill in the recorded numbers below after the live acceptance run.
| Metric | multiplex (before) | per_session (after) | Pass? |
|---|---|---|---|
| light sessions established | pending | pending | - |
| light_ms p50 | pending | pending | - |
| light_ms p95 | pending | pending | p95 < 3 s required |
| threshold result | n/a (no threshold) | pending | pending |
Acceptance criterion G2 is met when the per_session p95 row shows a value
below 3 s and the k6 run exits 0 with ASSERT=1.