Lifecycle Scopes
One primitive that owns lifetime for every disposable in your app
A silvery app acquires resources: child processes, file watchers, signal handlers, timers, network sockets, terminal-protocol subscriptions. Every one of them needs to be released — when its component unmounts, when the user hits Ctrl+C, when the app exits, or when an AbortSignal fires upstream.
Without a single primitive, every component invents its own teardown: useEffect returns a cleanup, process.on("SIGINT", …) is wired by hand, an AbortController is plumbed three layers down. Each path is correct in isolation; together they leak.
Scope is silvery's answer. It's a tree of resources tied to lifetimes — a thin AsyncDisposableStack subclass with an AbortSignal and a child cascade. Mount a component, get a scope. Register a resource on the scope. The scope disposes when the component unmounts, when its signal aborts, or when the app exits — whichever comes first.
This guide is the migration path. If you have useDispose, process.on("SIGINT", …), hand-rolled AbortControllers, or setTimeout without cleanup, this is how to convert.
What is Scope?
Scope extends AsyncDisposableStack from TC39's explicit resource management proposal. It adds two things on top:
- An
AbortSignalthat aborts on disposal — and links to a parent's signal so child scopes cascade. - A
child(name?)method that creates child scopes which the parent disposes before its own user disposers.
Everything else — LIFO ordering, await using, SuppressedError aggregation on multi-throw, idempotent dispose, post-dispose ReferenceError — comes from AsyncDisposableStack directly.
import { createScope, disposable } from "@silvery/scope"
await using scope = createScope("app")
// Register a child process. `disposable` wraps it with a Symbol.dispose
// hook so scope.use() can claim ownership.
const proc = scope.use(disposable(
child_process.spawn("claude"),
(p) => p.kill("SIGTERM"),
))
// Register a cleanup callback directly.
scope.defer(() => console.log("scope disposing"))
// Pass scope.signal anywhere AbortSignal is expected.
await fetch(url, { signal: scope.signal })When the await using block exits, scope[Symbol.asyncDispose]() runs:
- children are disposed first, most-recent first;
- then the user disposer stack runs LIFO (so
scope.deferandscope.useregistrations unwind in reverse); - the scope's
signalaborts (via a final deferred call), so any in-flightfetch, listener, orsignal.addEventListener("abort", …)consumer wakes up; - errors thrown during disposal are collected; one error rethrows directly, multiple errors aggregate into a
SuppressedErrorchain.
A Scope is disposed in three situations:
- Component unmount —
useScopeEffectowns a child scope; React's effect cleanup disposes it. - Signal cascade — the parent scope's signal aborts, child signals inherit, in-flight work stops.
- App exit — the
withScope()plugin disposes the root scope from the app's exit handler (and from SIGINT/SIGTERM when composed afterwithTerminal).
That's the whole model. Three patterns build everything else on top.
The three patterns
There are three places resources get acquired in a silvery app. Each has a canonical pattern.
1. using / await using — block-scoped lifetimes
Inside a function or method, when a resource lives only for the duration of a block, use using (sync dispose) or await using (async dispose). This is the standard TC39 form — no silvery API required.
import { createScope } from "@silvery/scope"
async function importVault(path: string): Promise<Manifest> {
await using scope = createScope("import")
const watcher = scope.use(disposable(
fs.watch(path),
(w) => w.close(),
))
// ... do work, watcher is alive
return manifest
// scope disposes here — watcher.close() runs automatically
}Use this form when the resource's lifetime is the function body. There is no React component, no app exit hook, no signal handler — just a block scope. The compiler enforces disposal: forgetting using is a syntax error if the value is typed as Disposable.
2. useScopeEffect — component-scoped lifetimes
Inside a React component, when a resource lives as long as the component is mounted, use useScopeEffect. The hook lazily allocates a child scope after commit and disposes it on dep change or unmount.
import { useScopeEffect } from "@silvery/ag-react"
function FileWatcher({ path }: { path: string }) {
useScopeEffect((scope) => {
const watcher = scope.use(disposable(
fs.watch(path),
(w) => w.close(),
))
watcher.on("change", () => console.log("file changed"))
}, [path])
return <Text>watching {path}</Text>
}useScopeEffect((scope) => …, deps) runs the setup function after commit, passing in a fresh child scope. When deps change or the component unmounts, the scope is disposed (fire-and-forget via reportDisposeError for any rejection).
If you need a synchronous cleanup before the scope tears down — e.g. to act on still-live handles — return a function from setup:
useScopeEffect((scope) => {
const sub = scope.use(eventBus.subscribe(handle))
return () => {
sub.notifyShuttingDown() // sync hook before scope disposes the sub
}
}, [])The returned cleanup runs before the scope disposes — same ordering React's useEffect already uses.
3. scope.use(disposable(value, cleanup)) — explicit registration
When you have a value that isn't already Disposable and you want to register it on a scope, wrap it with disposable(value, cleanup). This is the gate: every resource shape — sync or async, native or custom — passes through scope.use() after being wrapped.
import { disposable } from "@silvery/scope"
// Sync cleanup
const proc = scope.use(disposable(
child_process.spawn("claude"),
(p) => p.kill("SIGTERM"),
))
// Async cleanup — TS picks the async overload from the return type
const conn = scope.use(disposable(
await db.connect(),
async (c) => await c.close(),
))disposable attaches both Symbol.dispose and Symbol.asyncDispose so the value can be claimed by using, await using, or scope.use(...) interchangeably. The static type narrows based on the cleanup signature: a (v) => void cleanup yields T & Disposable; a (v) => Promise<void> cleanup yields T & AsyncDisposable.
If a value already implements Symbol.dispose / Symbol.asyncDispose — many silvery APIs do, including term.signals.on(...) — pass it directly:
// term.signals.on returns Disposable & AsyncDisposable already
const sub = scope.use(term.signals.on("SIGINT", onSigint))Migration recipes
This section is the porting table. Each row shows a pre-Scope pattern (Tarnished) and the Scope-native equivalent (Shiny).
useDispose → useScopeEffect
🩶 Tarnished
import { useDispose } from "@silvery/ag-react"
function Search() {
const controller = useMemo(() => new SearchController(), [])
useDispose(() => controller.killAll())
return <Input value={query} onChange={controller.search} />
}useDispose runs a single cleanup at unmount. It can't compose — if you need three resources, you stack three useDispose calls and hope the order is right. There's no signal to plumb, no child cascade, no way to unwind a partially-constructed init.
✨ Shiny
import { useScopeEffect } from "@silvery/ag-react"
function Search() {
const [controller, setController] = useState<SearchController>()
useScopeEffect((scope) => {
const c = new SearchController()
scope.defer(() => c.killAll())
setController(c)
}, [])
return <Input value={query} onChange={controller?.search} />
}scope.defer(() => c.killAll()) registers cleanup at the same point you construct the resource. Add a second resource, register a second cleanup — they unwind in reverse order automatically. Errors during teardown aggregate via SuppressedError. The component's scope cascades into the controller's AbortController if you hand it scope.signal.
process.on("SIGINT", …) → withScope() plugin
🩶 Tarnished
const cleanup = async () => {
await db.close()
await server.stop()
process.exit(0)
}
process.on("SIGINT", cleanup)
process.on("SIGTERM", cleanup)
process.on("exit", cleanup)Three handlers, three exit paths, no guarantee teardown completes before process.exit(0) cuts it off. Adding a fourth resource means editing three places. Tests can't intercept any of it.
✨ Shiny
import { pipe, createApp } from "@silvery/create"
import { withScope } from "@silvery/scope"
import { withTerminal } from "@silvery/create"
const app = pipe(
createApp(store),
withTerminal(process), // provides term.signals
withScope("app"), // wires SIGINT/SIGTERM → root-scope dispose
// ...
)
await app.run()withScope() adds a root Scope to the app. Because it's composed after withTerminal, it auto-wires SIGINT and SIGTERM through term.signals so the signal flows into root-scope disposal. The root scope also disposes on the app's normal exit hook. Disposal failures from any of these paths flow through reportDisposeError with the originating phase ("signal", "app-exit").
Resources you registered anywhere in the tree — useScopeEffect children, terminal sub-owners, the db and server from above (registered on app.scope) — all dispose in the right order from a single trigger.
The plugin also works without a terminal. Compose it without withTerminal and you get the root-scope-on-exit behavior; SIGINT/SIGTERM wiring is silently skipped because there's no term.signals to bind to. Web-host cancellation (pagehide, beforeunload) lives in the web runtime, not in withScope.
term.signals.on(...) + manual off() → using or scope.use
🩶 Tarnished
function startWatch() {
const off = term.signals.on("SIGINT", onSigint)
// ... later
off()
}The off() callback is correct in isolation but easy to forget on an early return, a thrown error, or a re-entrant call. Tests have to mock term.signals and assert off() was called.
✨ Shiny
// Block-scoped: using
function startWatch() {
using sub = term.signals.on("SIGINT", onSigint)
// ... sub disposed at function exit, even on throw
}
// Component-scoped: scope.use
function App() {
useScopeEffect((scope) => {
scope.use(term.signals.on("SIGINT", onSigint))
}, [])
// ...
}term.signals.on(...) returns a Disposable & AsyncDisposable. Both forms claim it: using ties it to the function body; scope.use ties it to the component instance. There is no off() to call.
setTimeout / setInterval → scope.defer(() => clearTimeout(id))
🩶 Tarnished
function Toast({ message }: { message: string }) {
useEffect(() => {
const id = setTimeout(() => dismiss(), 3000)
return () => clearTimeout(id)
}, [])
return <Text>{message}</Text>
}This is correct, but the cleanup is its own callback — you have to remember to write it, and there's no signal to plumb if the timer should be cancelled by an upstream abort.
✨ Shiny
function Toast({ message }: { message: string }) {
useScopeEffect((scope) => {
const id = setTimeout(() => dismiss(), 3000)
scope.defer(() => clearTimeout(id))
}, [])
return <Text>{message}</Text>
}The cleanup sits next to the construction. If the parent scope's signal aborts (Ctrl+C, app exit, ancestor unmount), the timer is cleared. For setInterval, the same shape — scope.defer(() => clearInterval(id)).
For modern code that takes an AbortSignal, just hand it scope.signal:
const reply = await fetch(url, { signal: scope.signal })No defer needed — the fetch self-cancels when the scope disposes.
child_process.spawn + manual kill → scope.use(disposable(spawn(...), …))
🩶 Tarnished
const proc = child_process.spawn("claude", args)
proc.on("exit", () => { /* ... */ })
process.on("SIGINT", () => proc.kill("SIGTERM"))
process.on("exit", () => proc.kill("SIGKILL"))The kill is wired in two places, the parent-process abort path is hand-built, and there's no guarantee SIGTERM completes before SIGKILL fires.
✨ Shiny
const proc = scope.use(disposable(
child_process.spawn("claude", args),
(p) => p.kill("SIGTERM"),
))The disposer fires when the scope disposes — from unmount, from signal, from app exit. If the scope is the root app scope (via withScope), SIGINT and SIGTERM both flow into this same disposer. If the scope is a useScopeEffect child, unmounting the component kills the process.
For escalation (SIGTERM → SIGKILL after a timeout), build it inside the disposer:
const proc = scope.use(disposable(
child_process.spawn("claude", args),
async (p) => {
p.kill("SIGTERM")
await new Promise((resolve) => setTimeout(resolve, 5000))
if (!p.killed) p.kill("SIGKILL")
},
))The async overload of disposable accepts a Promise<void> cleanup; scope[Symbol.asyncDispose]() awaits it.
fs.watch + manual close → scope.use(disposable(watcher, w => w.close()))
🩶 Tarnished
const watcher = fs.watch(path)
watcher.on("change", onChange)
// Somewhere else, eventually:
watcher.close()The close() call is far from the watch() call, hard to find on review, and easy to skip on an error path.
✨ Shiny
const watcher = scope.use(disposable(
fs.watch(path),
(w) => w.close(),
))
watcher.on("change", onChange)Construction and cleanup are adjacent. The disposable wrapper is invisible at the use site — watcher.on(...) works exactly as before because Object.assign attached Symbol.dispose without changing the value's shape.
Raw new AbortController() → scope.signal
🩶 Tarnished
function Search({ query }: { query: string }) {
useEffect(() => {
const controller = new AbortController()
fetch(`/search?q=${query}`, { signal: controller.signal })
.then(handleResults)
return () => controller.abort()
}, [query])
}You're hand-managing a controller that exists exclusively to abort on cleanup. Every component that fetches anything has the same five lines.
✨ Shiny
function Search({ query }: { query: string }) {
useScopeEffect((scope) => {
fetch(`/search?q=${query}`, { signal: scope.signal })
.then(handleResults)
}, [query])
}scope.signal is the scope's own AbortSignal. It aborts when the scope disposes — which is exactly when you'd have called controller.abort(). The five lines collapse to two and you stop having to remember the cleanup.
scope.signal cascades: a component-level scope's signal aborts when the parent app scope's signal aborts (Ctrl+C, exit), so an in-flight fetch cancels even if the component is still mounted.
The withScope() plugin
withScope(name?) is the host-level wiring that puts a root Scope on the app object. Compose it via pipe():
import { pipe, createApp, withTerminal, withReact } from "@silvery/create"
import { withScope } from "@silvery/scope"
const app = pipe(
createApp(store),
withTerminal(process),
withReact(<App />),
withScope("app"),
)What it does:
- Creates a root scope named
"app"(or whatever you pass). - Disposes on app exit — registers an
app.defer(...)callback that callsscope[Symbol.asyncDispose](). Disposal errors flow throughreportDisposeError({ phase: "app-exit", scope }). - Wires SIGINT and SIGTERM if the app already has a
term.signalssource (i.e. it's composed afterwithTerminal). Both signals trigger root-scope dispose; failures flow throughreportDisposeError({ phase: "signal", scope }). - Adds
app.scope— the root scope is now available on the app object asapp.scope.<ScopeProvider>(rendered automatically by the React-bridge runtime) makes it available to descendants viauseScope()anduseAppScope().
Compose order matters. withScope() looks for app.term?.signals at compose time — if withTerminal hasn't run yet, signal wiring is silently skipped (the scope still disposes on normal exit). Put withTerminal before withScope if you want signal teardown.
Web-host cancellation (pagehide / beforeunload) lives in the web runtime package, not in withScope. @silvery/scope is platform-neutral — it knows about AbortSignal and AsyncDisposableStack, nothing else.
Reading the scope from React
Inside React, three hooks read the scope from context:
useScope()— the nearest enclosing scope. Walks the React fiber chain viauseContext(ScopeContext); falls back to the app-root scope if there's no nested provider; throws if neither is present.useAppScope()— always the root scope, regardless of nested providers. Use this only for whole-app shutdown paths (hot-swap a global, route a custom signal into the root).useScopeEffect((scope) => …, deps)— allocates a child ofuseScope()'s scope after commit, disposes on dep change or unmount.
import { useScope, useAppScope, useScopeEffect } from "@silvery/ag-react"
function Component() {
const scope = useScope() // nearest — child of app, or of an enclosing provider
const app = useAppScope() // root — always app.scope from withScope()
useScopeEffect((own) => {
// `own` is a child of `scope`, owned by this effect.
own.use(disposable(somethingExpensive(), (x) => x.dispose()))
}, [])
return <Text>...</Text>
}Nesting scopes manually
If you need to expose a different enclosing scope to a subtree — typically for tests or for a feature that owns a sub-lifetime — wrap with <ScopeProvider>:
import { ScopeProvider } from "@silvery/ag-react"
function Feature() {
const featureScope = useMemo(() => createScope("feature"), [])
// ... arrange disposal via useScopeEffect or app teardown
return (
<ScopeProvider scope={featureScope}>
<Subtree />
</ScopeProvider>
)
}Inside <Subtree />, useScope() returns featureScope. useAppScope() is unchanged.
Debugging leaks
@silvery/scope ships an opt-in leak detector behind the SILVERY_SCOPE_TRACE environment variable. It records every createScope() and disposable() call with its creation stack, removes entries on dispose, and prints a report at process exit listing anything that wasn't disposed.
Zero overhead when the env var isn't set — every trace function early-returns.
SILVERY_SCOPE_TRACE=1 bun run testSample output at exit when something leaked:
[silvery:scope:trace] 2 undisposed handle(s):
- scope(feature)
Error: (creation stack)
at _trackCreate (packages/scope/src/trace.ts:69:18)
at new Scope (packages/scope/src/index.ts:42:5)
at createScope (packages/scope/src/index.ts:124:10)
at Feature (src/feature.tsx:18:24)
...
- disposable
Error: (creation stack)
at _trackCreate (packages/scope/src/trace.ts:69:18)
at disposable (packages/scope/src/index.ts:151:3)
at startWatcher (src/watcher.ts:34:9)
...The stack trace in createdAt points to the construction site, so you can see which createScope("feature") call leaked without sprinkling logging by hand.
In-test assertions
For tighter feedback than at-exit logging, call getTraceSnapshot() after the unit-under-test should have torn down:
import { getTraceSnapshot } from "@silvery/scope"
test("dispose cascades", async () => {
const app = await createTestApp()
await app.dispose()
expect(getTraceSnapshot()).toHaveLength(0)
})getTraceSnapshot() returns a readonly array of TraceEntry ({ kind, name?, createdAt }). Empty when tracing is off, so the assertion is a no-op without SILVERY_SCOPE_TRACE=1 — write tests that pass either way and run them in trace mode in CI.
To force the at-exit report manually (useful for one-off diagnostics):
import { reportTraceLeaks } from "@silvery/scope"
reportTraceLeaks() // logs and returns the countRouting dispose errors
Disposal in fire-and-forget paths — React unmount, signal handlers, app-exit hooks — can't propagate errors to a caller. @silvery/scope routes them through a sink:
import { setDisposeErrorSink } from "@silvery/scope"
setDisposeErrorSink((error, ctx) => {
// ctx.phase: "react-unmount" | "signal" | "app-exit" | "manual"
// ctx.scope: the Scope being disposed (if known)
logger.error("dispose failed", { phase: ctx.phase, scope: ctx.scope?.name, error })
})The default sink prints to console.error. Override it in tests to fail fast on any disposal error:
setDisposeErrorSink((error) => {
throw error
})The sink is global. It's safe to call from anywhere; reportDisposeError swallows sink errors so a buggy sink can't take down the teardown path.
Common pitfalls
No scope ops during render
Calling scope.use(...), scope.defer(...), scope.child(...), or scope[Symbol.asyncDispose]() from a component body is forbidden. React renders are pure — they can re-run, abort, and replay; doing scope work during render means a re-render registers the same resource twice, and an aborted render leaks the resource it half-acquired.
🩶 Tarnished
function App() {
const scope = useScope()
const proc = scope.use(disposable(spawn("claude"), (p) => p.kill())) // ← during render
return <Text>pid {proc.pid}</Text>
}Every render allocates a new proc and registers it on the scope. None of them ever dispose until the app exits.
✨ Shiny
function App() {
const [proc, setProc] = useState<ChildProcess>()
useScopeEffect((scope) => {
const p = scope.use(disposable(spawn("claude"), (p) => p.kill()))
setProc(p)
}, [])
return <Text>pid {proc?.pid}</Text>
}The acquisition runs after commit. The scope is owned by the effect, so re-running the effect (deps change, unmount) disposes the previous proc first.
useScope() itself is fine during render — it's a pure context read. scope.signal is fine to read and pass to APIs (it's just a property access). What's forbidden is acquiring into the scope during render.
Scope.move() throws
AsyncDisposableStack.move() returns a fresh stack containing all the inherited disposers, leaving the original empty. On a plain stack that's a useful "transfer ownership" primitive. On Scope it would silently lose the signal, name, and child registry — the new stack is not a Scope. Rather than corrupt invariants, Scope.move() throws.
scope.move() // TypeError: Scope.move() is not supported — create a new scope and re-register resources explicitlyIf you need to relocate ownership, create a new scope and register resources on it explicitly. The use case is rare — you almost always want a child scope instead.
Child cascade is automatic — don't dispose children manually
Scope[Symbol.asyncDispose]() disposes children before the user disposer stack. You don't need to track them or call dispose on them by hand — and doing so risks double-dispose, which is a no-op semantically but a hint that the ownership tree is unclear.
🩶 Tarnished
const child = parent.child("worker")
parent.defer(async () => {
await child[Symbol.asyncDispose]() // ← parent already disposes children first
await someOtherCleanup()
})Two disposes happen — the manual one and the cascade. The second is a no-op (idempotent), but the code reads as if there's something special about this child. There isn't.
✨ Shiny
const child = parent.child("worker")
parent.defer(async () => {
await someOtherCleanup()
})
// `child` will be disposed first by the cascade, before `someOtherCleanup` runs.If you need a specific ordering — e.g. flush a buffer to the child before the child closes — register the flush on the child, not the parent:
const child = parent.child("worker")
child.defer(async () => await flushBuffer())
// flushBuffer runs as part of child disposal, before parent's user disposers.Disposing a child early (before its parent disposes) is fine — the child detaches itself from the parent's child set on completion. That's the use case for nested await using inside a longer-lived parent scope.
SuppressedError aggregates multi-throw
When multiple disposers throw during a single [Symbol.asyncDispose]() call, the errors aggregate via SuppressedError — a TC39 standard error type with .error (the most recent) and .suppressed (the previous, possibly itself a SuppressedError). One thrown error rethrows directly; many chain.
try {
await scope[Symbol.asyncDispose]()
} catch (err) {
if (err instanceof SuppressedError) {
// err.error: the latest dispose error
// err.suppressed: the previous error (or another SuppressedError)
walkSuppressed(err)
} else {
// single error
handle(err)
}
}For app-level paths (react-unmount, signal, app-exit), errors flow through reportDisposeError — your sink sees each SuppressedError whole. Walking the chain is the sink's job if you need per-error diagnostics.
Putting it together
A complete pattern, end to end:
import { pipe, createApp, withTerminal, withReact } from "@silvery/create"
import { withScope, disposable } from "@silvery/scope"
import { useScope, useScopeEffect } from "@silvery/ag-react"
// --- App composition ---
const app = pipe(
createApp(store),
withTerminal(process),
withReact(<App />),
withScope("app"), // root scope; SIGINT/SIGTERM/exit → root.dispose()
)
await app.run()
// --- Component: spawn a process, watch a file, time out a fetch ---
function Workspace({ path }: { path: string }) {
const [data, setData] = useState<Data>()
useScopeEffect((scope) => {
// 1. Spawn a worker; killed on dispose.
const proc = scope.use(disposable(
child_process.spawn("worker", [path]),
(p) => p.kill("SIGTERM"),
))
// 2. Watch the file; closed on dispose.
const watcher = scope.use(disposable(
fs.watch(path),
(w) => w.close(),
))
watcher.on("change", () => proc.send({ type: "reload" }))
// 3. Cancel the fetch if the scope aborts (Ctrl+C, unmount, signal).
fetch(`/data?path=${path}`, { signal: scope.signal })
.then((r) => r.json())
.then(setData)
.catch((e) => {
if (e.name !== "AbortError") throw e
})
// 4. Clear the timeout via defer.
const timeoutId = setTimeout(() => setData({ kind: "timeout" }), 30_000)
scope.defer(() => clearTimeout(timeoutId))
}, [path])
return <Text>workspace {path} — {data?.summary ?? "loading"}</Text>
}What happens at teardown:
- User unmounts
Workspace— the scope disposes. Children (none here) first, then user disposers LIFO:clearTimeoutruns, theAbortControllerbehindscope.signalaborts (canceling the in-flight fetch),watcher.close()runs,proc.kill("SIGTERM")runs. - User hits Ctrl+C —
term.signalsfires SIGINT.withScopedisposes the root scope. The cascade flows down to everyuseScopeEffectchild including thisWorkspace's scope. Same teardown order as above. Errors flow throughreportDisposeError({ phase: "signal" }). - Path prop changes — the effect re-runs. Old scope disposes (cleanup of previous resources), new scope created with new
path, fresh resources acquired. - Some disposer throws —
SuppressedErroraggregates. Other disposers still run. The error surfaces viareportDisposeErrorwith the originating phase.
One primitive. Three patterns. Every disposable in the app has a clear owner.
See also
@silvery/scope—packages/scope/src/index.ts(Scope,createScope,disposable,withScope,reportDisposeError,setDisposeErrorSink)@silvery/scope/trace—packages/scope/src/trace.ts(getTraceSnapshot,reportTraceLeaks,isTraceEnabled)@silvery/ag-react—packages/ag-react/src/hooks/{useScope,useAppScope,useScopeEffect}.tsandpackages/ag-react/src/ScopeProvider.tsx- TC39 explicit resource management — the underlying
using/await usingproposal - The Silvery Way — the broader principles
Scopefalls out of - Term I/O umbrella —
term.signals,term.input, and the rest of the terminal sub-owners thatScopecomposes with