Shared State & PowerShell Runspaces
Kestrun executes PowerShell route code inside isolated runspaces drawn from a managed pool. Each incoming request gets a fresh runspace (with pre-loaded variables like $Context) which is disposed (or returned) after the route finishes. This isolation prevents accidental cross-request variable leakage while still letting you share explicit state safely via the Shared State subsystem.
1. Runspace Isolation Model
- Per-request runspace: A route script runs in its own PowerShell
Runspacedrawn from a pool. - Clean scope: Local variables defined in one request are not visible in another.
- Pre-injected context:
$Context(HTTP request/response wrapper) and any globally registered objects are injected on creation. - Return semantics: Output written to the pipeline is captured and formatted into HTTP responses by helper cmdlets (e.g.
Write-KrJsonResponse).
Without a deliberate sharing mechanism, concurrency can lead to race conditions or stale data if you try to reuse global variables. Kestrun solves this via Shared State.
2. Shared State Fundamentals
Shared State is a host-level key/value store exposed to every runspace at request start. Keys are registered with Set-KrSharedState and materialize as variables you can read or mutate, typically Hashtable-based.
# Register a shared counter table
Set-KrSharedState -Name 'Visits' -Value @{ Count = 0 }
# Later in any route
$Visits.Count # => current accumulated count
Why reference types?
Hashtable (and other reference types) allow atomic or synchronized mutations. Pure value types (e.g. a raw [int]) are copied into the runspace; writing back would require an explicit reset. Wrapping primitives inside a table/object keeps a shared reference.
If you truly need direct value types, wrap them (e.g. @{ Time = Get-Date }) or use any supported option that allows value types.
2.1 Implicit Shared Variables & Functions (Pre-Configuration Injection)
Set-KrSharedState is not the only way to surface data across runspaces. Any variable or function you define before calling Enable-KrConfiguration is captured and injected into each runspace created for requests.
Example (see Tutorial 4.1):
$Visits = [System.Collections.Concurrent.ConcurrentDictionary[string,int]]::new()
function Get-VisitsCount { $Visits["Count"] }
Enable-KrConfiguration # After this point new runspaces receive $Visits and Get-VisitsCount
Characteristics:
- Automatically visible to route scripts (no explicit registration needed).
- Discoverable via
Get-KrSharedState -Name 'Visits'(Kestrun surfaces them in the unified view). - Functions defined early become globally available helpers (avoid re-defining per request).
Recommended uses:
- Static singleton utilities (e.g. caches, lookup dictionaries, configuration snapshots).
- Helper functions that wrap logging, serialization, or composite operations.
Avoid for:
- Dynamically created per-feature objects (use
Set-KrSharedStatefor clarity and overwrite semantics). - Objects whose lifecycle you must explicitly manage (creation/removal at runtime).
Deletion / Mutation:
- To remove an implicitly injected variable: set it to
$nulland optionally callRemove-Variablebefore re-enabling configuration (or expose a management route that clears it). - Functions can be replaced by redefining them (new definition appears in subsequent runspaces). Existing in-flight requests keep their original copy.
2.2 Explicit vs Implicit Comparison
| Aspect | Implicit (pre-Enable-KrConfiguration) | Explicit (Set-KrSharedState) |
|---|---|---|
| Creation timing | Before Enable-KrConfiguration | Any time (startup or runtime) |
| Registration verb | None (just assign/define) | Set-KrSharedState cmdlet |
| Overwrite semantics | Reassignment mutates existing reference | Re-invocation replaces value (loggable) |
| Discovery | Appears in Get-KrSharedState | Appears in Get-KrSharedState |
| Removal | Manual nulling / variable removal | Remove-KrSharedState -Name |
| Ideal use cases | Static caches, helper functions | Counters, mutable structured state, dynamic features |
| Lifecycle control | Implicit / ad-hoc | Explicit API (clear intent) |
Guidance: prefer implicit for stable, rarely changed utilities and helper functions; prefer explicit for mutable data whose lifecycle, atomicity, or observability matters (e.g. metrics, shared queues, dynamic feature toggles).
2.3 Host-Scoped vs Global Shared State Lifecycle
Kestrun exposes two scopes for shared state:
| Scope | Lifetime | Visibility | Typical Use | Cleanup |
|---|---|---|---|---|
| Host (default) | Until host disposal (KestrunHost stopped/disposed) | All runspaces of that host only | Per-application counters, config, caches | Automatic when host is destroyed |
Global (-Global) | Until PowerShell session ends (process exit) | All runspaces of all hosts in the same session | Cross-host metrics, coordination, process-wide feature flags | Manual via Remove-KrSharedState -Global or session end |
Host-Scoped
- Stored on the
KestrunHost.SharedStateinstance. - Disposed when the host is shut down; references become invalid.
- Ideal for state that should not leak across multiple server instances (e.g. staging vs admin API).
- Restarting a host recreates a fresh, empty store (unless you re-seed values).
Global
- Stored in the static
GlobalStore(Kestrun.SharedState.GlobalStore). - Survives creation and destruction of any number of hosts within the same PowerShell session.
- Accessible from any runspace: retrieval does not require a server parameter (
Get-KrSharedState -Global). - Use for process-wide coordination (aggregate request counts, shared connection pools, multi-host locks).
- Destroyed only when the PowerShell session terminates (or explicitly cleared with
Clear()/ removal calls).
Choosing a Scope
| Question | Pick Host | Pick Global |
|---|---|---|
| Need isolation per server? | ✔ | ✘ |
| Want values to persist across host restarts? | ✘ | ✔ |
| Managing per-environment differences (dev vs prod host)? | ✔ | ✘ |
| Aggregating metrics for all hosts in one session? | ✘ | ✔ |
| Minimizing lifetime to avoid stale data? | ✔ | ✘ |
Access Patterns
Host scoped:
Set-KrSharedState -Name 'LocalCache' -Value @{ hits = 0 }
$cache = Get-KrSharedState -Name 'LocalCache'
Global scoped:
Set-KrSharedState -Global -Name 'ProcessMetrics' -Value @{ TotalRequests = 0 }
$metrics = Get-KrSharedState -Global -Name 'ProcessMetrics'
Cleanup & Memory Considerations
- Host destruction frees host-scoped entries automatically (GC collects them if no other references).
- Global entries remain reachable; remove large or temporary data explicitly to avoid memory growth:
Remove-KrSharedState -Global -Name 'ProcessMetrics'
Concurrency Notes
Both scopes use the same underlying synchronization primitives. Atomic helpers (Update-KrSynchronizedCounter) work identically. Scope selection does not change thread-safety; it only changes lifetime and visibility.
3. Concurrency & Atomic Operations
A naive increment like:
$Visits.Count = [int]$Visits.Count + 1
is not atomic: two runspaces can read the same Count and both write back N+1, losing one increment.
Use the atomic helper:
Update-KrSynchronizedCounter -Table $Visits -Key 'Count' -By 1
This delegates to underlying .NET atomic primitives (e.g., Interlocked) or synchronized sections ensuring each request contributes exactly one increment.
Pattern comparison
| Pattern | Safe Under Load | Notes |
|---|---|---|
$Visits.Count++ | No | Lost updates possible |
Update-KrSynchronizedCounter | Yes | Scales for high RPS counters |
4. Lifecycle & Visibility
- Creation:
Set-KrSharedStateearly (startup script or first route); subsequent calls to the same name overwrite by design. - Access: Appears as variable (e.g.
$Visits) in every runspace during its lifetime. - Removal:
Remove-KrSharedState -Name 'Visits'deletes it; new runspaces will not see the variable. - Enumeration:
Get-KrSharedState -Name '*'can list multiple entries.
5. Request Workflow Example
New-KrServer -Name demo |
Add-KrEndpoint -Port 5000 |
Set-KrSharedState -Name 'Visits' -Value @{ Count = 0 } |
Enable-KrConfiguration | Out-Null
Add-KrMapRoute -Pattern '/visit' -HttpVerb POST -Code @'
Update-KrSynchronizedCounter -Table $Visits -Key Count -By 1
Write-KrJsonResponse @{ visits = $Visits.Count }
'@
Add-KrMapRoute -Pattern '/state/{name}' -HttpVerb GET -Code @'
$name = Get-KrRequestRouteParam -Name 'name'
$value = Get-KrSharedState -Name $name
if ($null -eq $value) { Write-KrJsonResponse @{ error = 'not found' } -StatusCode 404; return }
Write-KrJsonResponse @{ name = $name; value = $value }
'@
Start-KrServer | Out-Null
6. C# Parity
C# routes can manipulate shared state via equivalent helpers or direct host APIs (example conceptually; actual API surface may vary):
host.SharedState.Set("Visits", new Hashtable { { "Count", 0 } });
host.AddMapRoute("/visit", HttpVerb.Post, ctx =>
{
var visits = (Hashtable)host.SharedState.Get("Visits");
host.SharedState.AtomicIncrement(visits, "Count", 1); // hypothetical helper
ctx.Response.WriteJsonResponse(new { visits = (int)visits["Count"] });
});
7. Best Practices
- Use Hashtables / custom objects for mutable shared data.
- Keep payloads small: Large objects increase GC pressure and lock contention.
- Prefer atomic helpers for counters or metrics.
- Wrap value types: Avoid direct value-type sharing unless supported.
- Avoid per-request re-registration: Set once; reuse.
- Validate existence: Gracefully handle missing entries (404 or default).
- Consider sessions for per-user state: Shared State is global across all requests.
8. Common Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Lost counter increments | Non-atomic ++ | Use Update-KrSynchronizedCounter |
| Stale date/time | Cached value type copy | Wrap in reference (@{ Time = Get-Date }) |
| Variable missing in route | Not registered before request | Register at startup |
| Race during complex updates | Multiple field changes without guard | Perform grouped mutations inside synchronized helper or design for immutability |
9. Performance Considerations
- Atomic increments are O(1); contention shows only at extreme RPS.
- Minimize high-frequency writes to multi-field tables; favor single counters plus periodic aggregation.
- Remove unused state early to free references.
10. Testing Shared State
Leverage Pester tests that fire parallel requests (see Tutorial 4.2) to confirm final counts and absence of race conditions.
Example assertion snippet:
$before = $Visits.Count
Invoke-ConcurrentRequests -Uri 'http://localhost:5000/visit' -Count 25
$Visits.Count -eq ($before + 25) | Should -BeTrue
11. Troubleshooting Cheatsheet
| Issue | Explanation | Action |
|---|---|---|
| Final count lower than requests | Lost updates | Switch to atomic helper |
| Variable null mid-request | Removed concurrently | Re-create or guard deletes |
| Serialization oddities | Complex object not serializable | Return simplified DTO |
| High CPU in contention | Too many simultaneous writers | Batch or shard counters |
Return to the Guides index.
Related Tutorials:
| Tutorial | Purpose |
|---|---|
| 4.1 Shared Variables | Implicit variable/function injection pre-configuration |
| 4.2 Shared State | Explicit cmdlet-based shared state & atomic counters |