Skip to content

Add native WSLC state-aware streaming exec - #1252

Open
Branden Bonaby (bbonaby) wants to merge 11 commits into
mainfrom
user/bbonaby/node-native-state-aware-wslc-exec
Open

Branden Bonaby (bbonaby) wants to merge 11 commits into
mainfrom
user/bbonaby/node-native-state-aware-wslc-exec

Conversation

@bbonaby

@bbonaby Branden Bonaby (bbonaby) commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

📖 Description

Goal

Add in-process piped execution for WSLC state-aware sessions so the Rust,
.NET, and Node SDKs can run commands with programmatic stdout and stderr
without invoking an executor process.

The implementation:

  • routes state-aware piped exec through mxc_engine and the existing WSLC
    state-aware backend
  • extends the WSLC daemon protocol to stream stdout, stderr, and the terminal
    execution result
  • exposes the shared native path through the existing Rust, .NET, and Node
    state-aware exec APIs
  • preserves timeout, cancellation, and typed terminal-result behavior
  • keeps control and lifecycle requests available when the daemon is at its
    concurrent exec limit
  • rejects duplicate live exec IDs and safely cleans up completed registrations
  • reports terminal completion without blocking on caller-owned output pipes
  • quarantines sessions when process termination cannot be confirmed
  • fails explicitly instead of falling back to an executor process
flowchart LR
    A[Rust / .NET / Node state-aware API] --> B[mxc-sdk or mxc_ffi]
    B --> C[mxc_engine]
    C --> D[WSLC state-aware backend]
    D --> E[WSLC daemon client]
    E <--> F[WSLC daemon]
    F <--> G[Process in WSL container]

    G -->|stdout / stderr / terminal result| F
    F -->|stream frames| E
    E -->|native pipe handles| A
    A -->|cancel| E
Loading

mxc_engine remains the single backend-routing layer. The SDKs use the shared
native implementation and do not add backend-specific execution logic or an
executor fallback.

Current limitations

  • This adds piped streaming exec; it does not add the missing Node
    attached-terminal API.
  • Windows Sandbox piped exec is not added by this change.
  • A live WSLC workload was not run on the development machine. Host-dependent
    execution should be exercised by PR CI or on a WSLC-capable validation host.

🔗 References

🔍 Validation

  • Rust formatting passed.
  • Targeted WSLC common and daemon tests passed.
  • Targeted WSLC Clippy checks passed with warnings denied.
  • Node TypeScript builds and relevant state-aware unit tests passed.
  • Two adversarial review-and-fix passes completed.
  • git diff --check passed.
  • No live WSLC workload was run locally; host-dependent validation remains for
    PR CI or a WSLC-capable validation host.

✅ Checklist

📋 Issue Type

  • Bug fix
  • Feature
  • Task

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 GitHub Actions build; it runs on merge to main, and Microsoft reviewers
with write access can trigger it on a PR with /azp run. See
docs/pull-requests.md.

If the dependency-feed-check check fails on a new dependency, the crate must
be added to the feed before the PR can pass. See
docs/pull-requests.md
for the steps.

Microsoft Reviewers: Open in CodeFlow

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c
Prevent unread output from blocking completion, preserve control capacity under exec saturation, and make exec cancellation registration identity-safe.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c
Observe cancellation without an exit event, preserve unconfirmed-exit diagnostics, and cover capacity and cancellation dispatch through the request handler.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c
Keep the attached timeout limitation in the canonical WSLC backend guide instead of repeating it in each SDK README.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c
@bbonaby
Branden Bonaby (bbonaby) requested review from a team and a balanced review from Copilot September 23, 2026 21:42
@bbonaby
Branden Bonaby (bbonaby) requested a review from a team as a code owner September 23, 2026 21:42
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The critical unbounded-buffer issue and moderate cancellation, capacity, lifecycle-starvation, and API-regression issues must be resolved.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
What changed in this PR

Adds native WSLC state-aware streaming execution across the Rust, .NET, and Node SDKs.

Changes:

  • Extends the daemon protocol with streaming, cancellation, and typed completion.
  • Bridges WSLC output into native stdout/stderr pipes.
  • Updates SDK APIs, tests, changelogs, and documentation.
File Review
src/​core/​mxc-sdk/​src/​lib.rs Updates Rust API documentation.
src/​core/​mxc-sdk/​README.md Documents WSLC streaming support.
src/​backends/​wslc/​daemon/​tests/​daemon_ipc.rs Adds execution identifiers to IPC tests.
src/​backends/​wslc/​daemon/​src/​session_manager.rs Adds cancellation and registration tracking. Moderate (1 vote): FIFO exec queuing can starve lifecycle operations.
src/​backends/​wslc/​daemon/​src/​control_server.rs Adds capacity limits and terminal outcomes. Three moderate findings (1 vote each): queued execs can block lifecycle work; disconnects can release permits while executions continue; exec clients can consume the control reserve.
src/​backends/​wslc/​common/​src/​stream_buffer.rs Closes streams when writers are dropped.
src/​backends/​wslc/​common/​src/​state_aware.rs Implements piped WSLC execution. Critical (3 votes): unbounded output buffering can exhaust SDK process memory.
src/​backends/​wslc/​common/​src/​sandbox.rs Exposes native output-pipe preparation.
src/​backends/​wslc/​common/​src/​daemon_protocol.rs Extends streaming protocol and terminal outcomes.
src/​backends/​wslc/​common/​src/​daemon_client.rs Adds streaming outcomes and cancellation.
src/​backends/​wslc/​common/​src/​container_steps.rs Adds cancellation and timeout handling. Moderate (1 vote): already-cancelled queued executions can still start and cause side effects.
sdk/​node/​tests/​unit/​state-aware.test.ts Updates backend execution tests.
sdk/​node/​src/​state-aware.ts Enables native WSLC execution. Moderate (1 vote): backend validation breaks existing Windows Sandbox dry-run validation.
sdk/​node/​README.md Documents Node backend support.
sdk/​node/​CHANGELOG.md Records Node API changes.
sdk/​dotnet/​README.md Documents .NET WSLC streaming support.
sdk/​dotnet/​Microsoft.Mxc.Sdk/​MxcLifecycle.cs Updates lifecycle API documentation.
sdk/​dotnet/​CHANGELOG.md Records .NET WSLC support.
docs/​wsl/​wslc-state-aware.md Documents WSLC streaming semantics.
docs/​state-aware-lifecycle/​mxc-state-aware-sandbox-api.md Updates state-aware API contracts.
docs/​state-aware-lifecycle/​mxc-state-aware-sandbox-api-overview.md Updates lifecycle overview and backend support.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/backends/wslc/common/src/state_aware.rs
Cap the nonblocking daemon-to-pipe bridge, report truncation explicitly, and update the streaming capability test so ARM64 does not execute WSLC as a non-piped backend.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c
Copilot AI review requested due to automatic review settings September 23, 2026 22:06
exec requests. Promise-returning operations accept `SandboxSpawnOptions`,
including `signal?: AbortSignal`.
metadata only. For IsolationSession and WSLC, `execInSandbox` returns an
`MxcSandboxProcess` for live output, waiting, termination, and disposal;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't they all?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, stdio support is missing :/

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved critical and moderate issues affect portability, cancellation safety, concurrency, error propagation, and API behavior.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 2 High severity

Open (2)
Resolved since last review (1)

Comment thread src/backends/wslc/common/src/state_aware.rs
Comment thread src/backends/wslc/daemon/src/session_manager.rs
/// Default image when a provision request omits `wslc.provision.image`.
const DEFAULT_IMAGE: &str = "alpine:latest";

/// Per-stream ceiling between daemon frames and the synthesized native pipe.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

synthesized native pipe

I don't get it. Why does it need to be synthesized on the client side? can't it be entirely synthesized on the daemon side?

@jsidewhite

Copy link
Copy Markdown
Member

Consider creating an Issue to remove this "packet framing" code in the daemon+daemon_client, and rather expose native stdio handles from daemon process.

Keep exec IDs reserved through terminal frame delivery and gate the native piped bridge on Windows so non-Windows builds fail closed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c
Copilot AI review requested due to automatic review settings September 23, 2026 22:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Seven unresolved issues—two critical and five moderate—affect cancellation safety, quarantine, control availability, and timeout classification.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 2 High severity

Open (2)
Resolved since last review (2)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Handle post-admission cancellation replay failures

src/​backends/​wslc/​common/​src/​state_aware.rs:362

The replay is the cancellation attempt that runs after admission when kill() raced with admit_exec, but its error is discarded. The initial pre-admission CancelExec can return Ok for an unknown ID; if this replay connection then fails, the daemon runs the command uncancelled and read_to_completion can block indefinitely for a no-timeout command. Preserve and handle this post-admission failure so a successful early cancel cannot make teardown assume the exec was stopped.

Medium severity Reserve control capacity for cancellation and lifecycle traffic

src/​backends/​wslc/​daemon/​src/​control_server.rs:59

CONTROL_CLIENT_RESERVE is not actually reserved for cancellation/lifecycle traffic: all accepted control handlers use the same client_limiter as execs. If eight long-running Stop/Deprovision requests are queued behind the single worker while exec slots are occupied, the next control connection (including CancelExec) is dropped at accept, so callers cannot cancel the running execs. Use a separate or prioritized control budget that blocked lifecycle handlers cannot consume.

Comment on lines +263 to +267
if active_execs
.get(&self.exec_id)
.is_some_and(|current| current.ptr_eq(&Arc::downgrade(&self.cancellation)))
{
active_execs.remove(&self.exec_id);
Comment on lines +622 to +623
match outcome.completion {
container_steps::ProcessCompletion::TerminationUnconfirmed => {
Comment on lines 771 to +773
Ok(container) if admit.send(Ok(())).is_ok() => {
let sandbox_id = config.sandbox_id.clone();
let outcome = worker.exec(config, container, sink);
let outcome = worker.exec(config, container, sink, &cancellation);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new MAX_CONCURRENT_EXECS = 128 / CONTROL_CLIENT_RESERVE = 8 advertise concurrency the serialized worker can't deliver - the permits admit one running exec plus 127 waiters?

@@ -679,7 +770,7 @@ pub fn spawn() -> Result<SessionHandle> {
// every other lifecycle command for its full timeout.
Ok(container) if admit.send(Ok(())).is_ok() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check cancellation before process creation, with a test that a cancelled queued command never writes its marker file?

container_steps::delete_daemon_container(sdk, container, &mut self.logger)
};
}
self.containers.remove(&config.sandbox_id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The delete result is discarded but the container is removed from the map regardless, so a failed quarantine deletion leaves no handle to retry with and a possibly-running workload goes untracked?

///
/// Keeping admission separate from stream consumption lets cancellable
/// callers replay a cancellation that raced ahead of daemon admission.
pub(crate) fn admit_exec(&self, config: ExecConfig) -> DaemonResult<AdmittedExec> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

admit_exec reads its response with a bare read_frame instead of read_frame_with_deadline/call_timeout(), so a concurrent admission against the busy worker waits indefinitely with no client-side deadline?


pub(crate) fn register_exec(
active_execs: &ActiveExecs,
exec_id: &str,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new exec_id is deserialized from an ~16 MiB frame and retained several times (ExecConfig, registry key, Arc<ExecRegistration>), shall we bound it at deserialize?

A **successful** `exec` streams the script's raw stdout (relayed from the daemon-captured buffers)
and exits with the script's own exit code — it does **not** wrap the result in an envelope. Callers
discriminate via the exit code + whether stdout parses as an envelope.
For attached execution, a **successful** `exec` relays the script's raw stdout/stderr live from

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new exec semantics are documented but four operator-visible behaviors are missing: quarantine on unconfirmed termination, the 128-exec ceiling plus 8 reserved control slots, duplicate exec-ID rejection, and bounded-output truncation. Do we need to document this?


/// Terminal state of a daemon container process.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcessCompletion {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProcessCompletion is hand-remapped into ExecCompletion and DaemonExecOutcome - three names for one shape, with TerminationUnconfirmed the only real addition and it never leaves the worker.

A shared ExecTerminal in daemon_protocol plus a daemon-local wrapper would collapse three mapping sites that must stay aligned?

This branch has not been deployed

No deployments
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.

4 participants