[WSLC] Harden state-aware daemon (PR 2a/3): typed errors, validate-then-admit exec, idle-watchdog - #767
Conversation
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
Hardens the internal WSLC state-aware daemon’s error handling, exec admission, and idle shutdown behavior.
Changes:
- Adds typed worker errors and protocol mappings.
- Adds pre-admission exec validation.
- Tracks connection activity and deduplicates SDK error handling.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/backends/wslc/daemon/src/session_manager.rs |
Adds typed errors and exec validation commands. |
src/backends/wslc/daemon/src/main.rs |
Hardens idle-watchdog activity tracking. |
src/backends/wslc/daemon/src/control_server.rs |
Returns typed errors and validates exec admission. |
src/backends/wslc/common/src/wsl_container_runner.rs |
Reuses the shared SDK error helper. |
| if let Err(e) = session.validate_exec(config.sandbox_id.clone()).await { | ||
| write_frame(&mut pipe, &worker_err_response(e)).await?; | ||
| return Ok(()); | ||
| } | ||
| write_frame(&mut pipe, &DaemonResponse::Ok).await?; |
2336f9a to
884d80b
Compare
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
src/backends/wslc/daemon/src/control_server.rs:306
- This does put
ErrKindon the wire, but the repository'sDaemonClientimmediately convertsDaemonResponse::Err { kind, message }intoanyhow!("daemon error [{kind:?}]: ...")(common/src/daemon_client.rs:297-300). Consequently actual client callers still cannot distinguishNotProvisioned/NotStartedwithout parsing the error string, contrary to this PR's typed-error goal. PreserveErrKindin a concrete client error type (or a typed result) so callers can inspect it directly.
fn worker_err_response(e: WorkerError) -> DaemonResponse {
DaemonResponse::Err {
kind: e.kind(),
message: e.to_string(),
src/backends/wslc/daemon/src/session_manager.rs:370
- The added admission tests cover only the
Nonebranch; no test exercises this newNotStartedclassification, even though distinguishing an unstarted sandbox is one of the core behaviors introduced here. Add an exec-before-start assertion (for example in the WSL-host lifecycle test), or factor the lifecycle state from the SDK handle so this branch can be unit-tested without WSL.
Some(entry) if !entry.started => Err(WorkerError::NotStarted(sandbox_id.to_string())),
| let active = active_clients.load(Ordering::SeqCst); | ||
| let generation = activity.load(Ordering::SeqCst); | ||
| let idle = count == 0 && active == 0 && generation == last_activity; |
884d80b to
ff036ff
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/backends/wslc/daemon/src/main.rs:215
- This still has a final-check race: a pipe can connect after
generationis loaded but beforenotify_one(). Becauseactivityis incremented only when the control server's connect branch runs (control_server.rs:127), the watchdog can publish shutdown while that connection is pending; the select may take shutdown, drop the connected pipe, and fail a phase request. Coordinate the idle decision with the accept loop (for example, pass a generation-tagged shutdown candidate that the server revalidates while prioritizing a ready connect) so accepting a request and committing shutdown have a single ordering point.
let active = active_clients.load(Ordering::SeqCst);
let generation = activity.load(Ordering::SeqCst);
let idle = count == 0 && active == 0 && generation == last_activity;
last_activity = generation;
if idle {
idle_for += IDLE_POLL;
if idle_for >= IDLE_TIMEOUT {
shutdown.notify_one();
| let _ = admit.send(Ok(())); | ||
| let _ = done.send(worker.exec(config)); |
ff036ff to
3466ecb
Compare
| ) -> Result<()> { | ||
| // Await the worker's admission decision before writing anything: a rejected | ||
| // exec is a pre-admission typed error, never a post-admission stream frame. | ||
| let done = match session.exec(config).await { |
There was a problem hiding this comment.
Medium (reliability) - an exec can start before the client receives the admission response.
This ordering is introduced here: session.exec(config).await returns once the worker sends admit, and that worker immediately enters the blocking execution. If the subsequent DaemonResponse::Ok write fails because the client disconnected, the completion receiver is dropped but the command continues with real side effects. A retry can therefore execute a non-idempotent script twice, with no record of the orphaned result.
Fix: Add an acknowledged start phase so execution begins only after the admission frame is written, or provide cancellation plus explicit logging/result capture when that write fails.
There was a problem hiding this comment.
Fixed here- if the DaemonResponse::Ok / completion-channel write fails after admission, the worker now logs the orphaned result instead of silently discarding it.
Deferred and planned in PR 2c: the full acknowledged-start phase- begin execution only after the admission frame is written. In 2a's design admission and run-start are deliberately atomic on the single worker thread, which is what closes the Stop/Deprovision interleave window. Decoupling them to gate on the write reintroduces that window, so a clean fix belongs with the streaming/cancellation rework in 2c where the frame protocol already needs revisiting.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/backends/wslc/daemon/src/main.rs:235
- There is still a shutdown/admission race here. After the watchdog reads
generationat line 224, the accept task can readdraining == false; the watchdog can then setdraining, and that accept task can incrementactivityand provision a sandbox. The watchdog has already committed to returning, so the server drains that request and teardown invalidates the ID it just returned. Make connection admission and the final idle transition atomic (for example, increment activity before checkingdraining, then setdrainingand recheck the generation before committing, or protect both with one lock/state machine).
// Enter the draining state *before* signalling shutdown so the
// accept loop, once it wakes, refuses any client that connected
// after this final sample instead of provisioning it into a
// session that is about to be released.
draining.store(true, Ordering::SeqCst);
src/backends/wslc/daemon/src/control_server.rs:476
- The wire now carries the real kind, but
DaemonClientimmediately converts everyDaemonResponse::Err { kind, message }into a plainanyhow::Errorstring (daemon_client.rs:167, 199, 387-394). Consequently callers still cannot branch onErrKindwithout string-matching, contrary to this PR's typed-error goal and preventing the follow-up backend from reliably mapping these errors. PreserveErrKindin a structured/downcastable client error (or in the client method return type).
fn worker_err_response(e: WorkerError) -> DaemonResponse {
DaemonResponse::Err {
kind: e.kind(),
message: e.to_string(),
src/backends/wslc/daemon/src/control_server.rs:450
- Returning immediately when the admission write fails can still silently lose a completed exec result. A fast exec may have already sent into
donesuccessfully while this write was in progress; droppingdonehere then loses that value, and the worker does not emit its orphan warning because its send succeeded. On this error path, retain/await the completion receiver and log its result (or add an explicit delivery acknowledgement) before returning the transport error.
write_frame(pipe, &DaemonResponse::Ok).await?;
Summary
PR 1/3 of the WSLc state-aware split landed the per-user daemon + owner-only named-pipe IPC. This PR (call it 2a/3) hardens the daemon internals with no public wire or schema surface, so it lands independently.
Four changes:
WorkerError{NotProvisioned, NotStarted, Backend}with akind() -> ErrKindmapping instead of collapsing every worker failure toErrKind::Backend. The control server returns the real classification, so clients can distinguish an unknown/not-started sandbox from a backend fault without string-matching.Displaypreserves the existing"unknown sandbox"message.Execworker command now carries two reply channels (admit+done). The worker callsvalidate_exec()(sandbox exists + started) and writes theadmitdecision before running, so an unknown/not-started sandbox comes back as a pre-admission typedErrrather than a post-admission streamErrorframe. Validation and run share one worker handler with no yield between them, so admission is atomic against concurrentStop/Deprovision.activitycounter bumped on each accepted connection; the watchdog declares idle only when the container count is zero, no request is in flight, and the activity generation is unchanged since the last poll (generation read last, so a connect that starts and finishes between two polls is not missed).container_steps::sdk_errorinstead of a byte-identical private copy.Testing
kind+validate_execassertions) and the#[ignore]d WSL2-host lifecycle tests (in-proc and over the pipe): provision → start → exec → exec → stop → deprovision.wslc_commonsuite; fullrun_wslc_all_tests.ps1corpus 24/24.cargo clippy --workspace --all-targets -- -D warningsandcargo fmt --checkclean.Coming in the pipeline
wslc/common/state_aware.rs(StatefulSandboxBackend, prefixwslc) translating the publicexperimental.wslc.*wire schema into daemon protocol frames;mxc_enginestate-aware arm + config-parser wiring; regenerated dev schema + generated TS wire types; multi-invocation E2E script with warm-reuse + idle-teardown assertions.Wslc*Config/*Resulttypes + brandedSandboxId<'wslc'>and helper prefix wiring, mirroring the LXC state-aware SDK surface.🔗 References
🔍 Validation
✅ Checklist
Cargo.lock, thedependency-feed-checkcheck passes (see docs/pull-requests.md)📋 Issue Type
GitHub Actions runs the PR validation build automatically. The ADO pipeline
(
MXC-PR-Build) is the Azure version of the PR pipeline, kept in parity with the GitHubActions build; it runs on merge to
main, and Microsoft reviewers with write access can trigger iton a PR with
/azp run. See docs/pull-requests.md.If the
dependency-feed-checkcheck fails on a new dependency, the crate must be added tothe feed before the PR can pass. See docs/pull-requests.md
for the steps.
Microsoft Reviewers: Open in CodeFlow