Skip to content

[WSLC] State-aware lifecycle (PR 1/3): persistent daemon + named-pipe IPC control plane - #745

Open
Soham Das (SohamDas2021) wants to merge 3 commits into
mainfrom
user/sodas/wslc-state-aware-daemon
Open

[WSLC] State-aware lifecycle (PR 1/3): persistent daemon + named-pipe IPC control plane#745
Soham Das (SohamDas2021) wants to merge 3 commits into
mainfrom
user/sodas/wslc-state-aware-daemon

Conversation

@SohamDas2021

@SohamDas2021 Soham Das (SohamDas2021) commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📖 Description

First of three PRs delivering the state-aware WSLc lifecycle. The WSLc SDK (2.9.3) has no cross-process re-attachWslcSession/WslcContainer/WslcProcess handles are in-process only — so a persistent, per-Windows-user daemon
must hold the handles across the separate provision/start/exec/stop/deprovision phase processes. This PR lands that daemon and its IPC control plane. It has no product-visible surface (no wire.rs/schema changes; nothing invokes the daemon yet), so it merges independently.

What's included

  • New wxc-wslc-daemon crate (src/backends/wslc/daemon/):
    • main.rs — bootstrap, bind-before-publish daemon record, dedicated WSLc worker thread/COM apartment, idle watchdog (teardown on no live containers + no in-flight clients).
    • control_server.rs — owner-only-SDDL named pipe, length-prefixed JSON frame dispatch
      (PROVISION/START/EXEC/STOP/DEPROVISION/PING).
    • session_manager.rs — real WslcSession/container/process lifecycle over container_steps, holding the refcounted sandbox_id -> WslcContainer map on a thread-affine worker.
  • wslc_common additions: daemon_protocol.rs (control frames + internal per-phase config structs, deliberately separate from the public wire schema), daemon_record.rs (discovery record, pid+creation-time liveness, owner-only DACL hardening, transition lock), daemon_client.rs (spawn/poll, connect, exec result accumulation — live bidirectional stdio bridge deferred to PR 2).
  • New container_steps.rs: daemon-side WSLc step primitives (create session/container, exec, stop, delete) adapted for a warm keepalive-init session. The one-shot wsl_container_runner.rs is unchanged except two visibility relaxations so the daemon steps can reuse its import_image_from_tar + wslc_prerequisite_error.
  • Build wiring: build.bat stages the daemon exe next to wxc-exec + SDK bin.
  • Tests: Rust integration test drives the daemon directly over the pipe (real container + 2× exec on a warm container); the full-lifecycle test is #[ignore]d (requires a WSL2 host with alpine:latest pre-pulled). Unit tests for framing, refcount, idle-teardown, transition lock, and record trust run by default.

Coming in the pipeline

  • PR 2/3 — state-aware backend + wire/schema + E2E: wslc/common/state_aware.rs (StatefulSandboxBackend, prefix wslc) translating the public experimental.wslc.* wire schema into daemon protocol frames; mxc_engine state-aware arm + config-parser wiring; regenerated dev schema + generated TS wire types; multi-invocation E2E script with warm-reuse + idle-teardown assertions;
  • PR 3/3 — TypeScript SDK: Wslc*Config/*Result types + branded SandboxId<'wslc'> and helper prefix wiring, mirroring the LXC state-aware SDK surface.

🔗 References

🔍 Validation

✅ 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

Copilot AI balanced review requested due to automatic review settings August 4, 2026 23:35
@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.

Pull request overview

Introduces the persistent per-user WSLc daemon and internal IPC foundation for the upcoming state-aware lifecycle.

Changes:

  • Adds named-pipe protocol, daemon discovery, lifecycle worker, and client.
  • Refactors WSLc operations into reusable container steps.
  • Adds build integration and daemon IPC tests.

Reviewed changes

Copilot reviewed 16 out of 17 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
src/Cargo.toml Registers the daemon crate.
src/Cargo.lock Locks new crate dependencies.
src/backends/wslc/daemon/tests/daemon_ipc.rs Tests pipe and lifecycle IPC.
src/backends/wslc/daemon/src/session_manager.rs Owns WSLc sessions and containers.
src/backends/wslc/daemon/src/main.rs Bootstraps and monitors the daemon.
src/backends/wslc/daemon/src/control_server.rs Implements named-pipe request dispatch.
src/backends/wslc/daemon/Cargo.toml Defines the daemon package.
src/backends/wslc/daemon/build.rs Embeds executable version metadata.
src/backends/wslc/common/src/wslc_bindings.rs Requires process-signalling support.
src/backends/wslc/common/src/wsl_container_runner.rs Uses shared WSLc construction helpers.
src/backends/wslc/common/src/lib.rs Exports daemon support modules.
src/backends/wslc/common/src/daemon_record.rs Manages discovery and transition locking.
src/backends/wslc/common/src/daemon_protocol.rs Defines IPC messages and framing.
src/backends/wslc/common/src/daemon_client.rs Discovers and calls the daemon.
src/backends/wslc/common/src/container_steps.rs Extracts reusable WSLc lifecycle operations.
src/backends/wslc/common/Cargo.toml Adds protocol dependencies.
build.bat Stages the daemon for the Node SDK.

let _ = reply.send(worker.start(config));
}
WorkerCommand::Exec { config, reply } => {
let _ = reply.send(worker.exec(config));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Will be addressed in PR2

fn ok_or_err(result: Result<()>) -> DaemonResponse {
match result {
Ok(()) => DaemonResponse::Ok,
Err(e) => err_response(ErrKind::Backend, e),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Will be addressed in PR2

Comment on lines +257 to +258
write_frame(&mut pipe, &DaemonResponse::Ok).await?;
let terminal = match session.exec(config).await {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Will be addressed in PR2

Comment on lines +249 to +251
/// TODO(fill-in): bidirectional live stdio (client `Stdin` frames -> process,
/// process stdout/stderr -> `Stdout`/`Stderr` frames). The skeleton runs the
/// command to completion and emits only the terminal frame.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

PR description updated. Doc change will be addressed in PR2

Comment thread src/backends/wslc/daemon/src/session_manager.rs Outdated
Comment thread src/backends/wslc/daemon/src/main.rs Outdated
Comment thread src/backends/wslc/common/src/container_steps.rs
Comment thread build.bat
Comment on lines +143 to +145
if exist "!BIN_DIR!\wxc-wslc-daemon.exe" (
copy /Y "!BIN_DIR!\wxc-wslc-daemon.exe" "sdk\node\bin\!SDK_ARCH!\" >nul
echo Copied !SDK_ARCH!\wxc-wslc-daemon.exe

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Will be addressed in PR2

Comment thread src/Cargo.toml
"backends/lxc/common",
"backends/bubblewrap/common",
"backends/wslc/common",
"backends/wslc/daemon",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Will be addressed in PR2

Comment thread src/backends/wslc/daemon/src/session_manager.rs Outdated
Copilot AI review requested due to automatic review settings August 5, 2026 15:45

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.

Pull request overview

Copilot reviewed 16 out of 17 changed files in this pull request and generated no new comments.

Suppressed comments (5)

src/backends/wslc/daemon/src/control_server.rs:260

  • Exec is admitted before checking whether the sandbox exists or is started. Those failures consequently arrive as an untyped StreamFrame::Error after Ok, rather than the documented DaemonResponse::Err with NotProvisioned/NotStarted. Split admission/state validation from execution and send Ok only after admission succeeds.
    write_frame(&mut pipe, &DaemonResponse::Ok).await?;
    let terminal = match session.exec(config).await {
        Ok(code) => StreamFrame::Exit { code },
        Err(e) => StreamFrame::Error {

src/backends/wslc/daemon/src/control_server.rs:273

  • Every start/stop/deprovision failure is classified as Backend, including the explicit unknown-sandbox and invalid-state paths. This makes the stable ErrKind::NotProvisioned/NotStarted contract unusable by the later state-aware adapter. Preserve a typed worker error and map each state failure to its declared protocol kind instead of erasing it into anyhow.
fn ok_or_err(result: Result<()>) -> DaemonResponse {
    match result {
        Ok(()) => DaemonResponse::Ok,
        Err(e) => err_response(ErrKind::Backend, e),
    }

src/backends/wslc/daemon/src/main.rs:133

  • The zero-client check is not coordinated with pipe acceptance, and the discovery record remains ready while shutdown begins. A phase can discover this daemon just after the check, then either lose its listening pipe or be accepted while run() shuts down the worker, causing a transient lifecycle failure. Make shutdown an atomic admission state (retire the record/listener under the transition protocol and drain admitted clients), or make the client rediscover/spawn after a stale-pipe open failure.
            Ok(0) if active_clients.load(Ordering::SeqCst) == 0 => {
                idle_for += IDLE_POLL;
                if idle_for >= IDLE_TIMEOUT {
                    shutdown.notify_waiters();
                    return;

src/backends/wslc/daemon/src/session_manager.rs:331

  • ExecConfig::working_directory is documented as an in-container path (and the protocol test uses /work), but this call reaches ProcessSettings::build, which only accepts Windows drive paths via windows_path_to_container_path. A normal container path therefore gets silently omitted. Add a daemon exec builder/path mode that passes the container cwd through verbatim while retaining Windows-path mapping for the one-shot runner.
                &config.working_directory,

src/backends/wslc/daemon/src/session_manager.rs:344

  • The PR describes an exec stdio bridge and the protocol promises stdout/stderr frames, but the captured buffers are discarded here, so echo hi returns an empty ExecResult. Return the captured outcome through the worker and emit Stdout/Stderr frames before Exit; the IPC lifecycle test should assert the output rather than only the exit code.
        // NOTE: outcome.stdout/stderr are captured but not yet forwarded — live
        // stdio streaming over the control pipe is a later fill-in; the PR1
        // contract returns the exit code only.
        Ok(outcome.exit_code)

Address HIGH + MEDIUM findings from the multi-model review of the WSLc
state-aware daemon (PR 1):

- Fix a use-after-free of the SDK-owned Arc<IoContext>: reclaim the
  reference inside exit_callback (leak-on-kill rather than free while the
  SDK may still call back); drop IoCtxRawGuard.
- Order Worker fields so the SDK/DLL drops last, after handle guards.
- Rewrite deprovision to delete the container before removing bookkeeping,
  leaving the entry retryable on failure.
- Add WslcSignalProcess to the required-symbol check.
- Bind the control pipe before publishing a single ready record.
- Handle pipe-accept errors locally instead of killing the daemon.
- Gate the idle watchdog on no in-flight clients and no live containers.
- Verify record + directory-chain ownership and validate the pipe name on
  the read path; harden every directory component, not just the leaf.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f3ae1af2-7b79-4340-a5ce-a5402e7ede3d
Copilot AI review requested due to automatic review settings August 5, 2026 20:02
@SohamDas2021
Soham Das (SohamDas2021) force-pushed the user/sodas/wslc-state-aware-daemon branch from 945d371 to bd4b3a7 Compare August 5, 2026 20:02

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.

Pull request overview

Copilot reviewed 16 out of 17 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

src/backends/wslc/daemon/src/main.rs:132

  • The zero-client check is not synchronized with the accept loop. A connection can be accepted immediately after this load, after which the watchdog still requests shutdown and run tears down the worker while that provision/exec is in flight. Coordinate shutdown with the server: stop accepting, drain/recheck active requests and container count, then commit teardown atomically.
            Ok(0) if active_clients.load(Ordering::SeqCst) == 0 => {
                idle_for += IDLE_POLL;
                if idle_for >= IDLE_TIMEOUT {
                    shutdown.notify_waiters();

src/backends/wslc/common/src/daemon_protocol.rs:106

  • This contract says callers provide an in-container path, but ProcessSettings::build_inner passes the value through windows_path_to_container_path; /work is therefore ignored and the process runs in its default directory. The state-aware request also carries the existing host-side working_directory, so document the actual Windows-host-path contract.
    /// Working directory inside the container (empty = container default).
    #[serde(default)]
    pub working_directory: String,

Comment on lines +851 to +855
if !*exited {
let _ = writeln!(
logger,
"[WSLC][daemon] Warning: exit callback did not fire within 30s"
);
@azure-pipelines

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

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.

2 participants