Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ production configs and the dev schema when working on experimental features:
"cpuCount": 4, // CPU count for WSLC session
"memoryMb": 2048, // Memory in MB for WSLC session
"gpu": false, // GPU passthrough
"storagePath": "C:\\wslc-storage" // Image store path
"storagePath": "C:\\wslc-storage", // Image store path
"portMappings": [ // Host<->container port forwarding. TCP only -- the vendored WSLC SDK 2.8.1 runtime returns E_NOTIMPL for UDP, so the parser hard-rejects "udp" entries with a clear message.
{ "windowsPort": 8080, "containerPort": 80, "protocol": "tcp" }
]
},
"seatbelt": { // macOS sandbox settings (macOS only)
"profileOverride": null, // Optional raw TinyScheme profile (escape hatch)
Expand Down
4 changes: 2 additions & 2 deletions docs/wsl/wsl-container-support-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ Container APIs:
- `WslcSetContainerSettingsInitProcess()` β€” attach process settings before creation
- `WslcSetContainerSettingsNetworkingMode()` β€” `NONE` (isolated) or `BRIDGED` (NAT)
- `WslcSetContainerSettingsVolumes()` β€” mount Windows paths into Linux container
- `WslcSetContainerSettingsPortMappings()` β€” host↔container port forwarding (TCP only)
- `WslcSetContainerSettingsPortMappings()` β€” host↔container port forwarding (TCP only; UDP is declared in the header but returns `E_NOTIMPL` in the vendored SDK 2.8.1 runtime, so the MXC parser hard-rejects `"udp"` at config-validation time)
- `WslcSetContainerSettingsFlags()` β€” `AUTO_REMOVE`, `ENABLE_GPU`, `PRIVILEGED`
- `WslcGetContainerInitProcess()` β€” retrieve process handle after start
- `WslcStopContainer()` / `WslcDeleteContainer()` / `WslcReleaseContainer()` β€” teardown
Expand Down Expand Up @@ -309,7 +309,7 @@ Network mapping:
Port mapping (new capability enabled by WSLC SDK):
| Config field | WSLC SDK equivalent |
|---|---|
| `portMappings: [{ windowsPort: 8080, containerPort: 80 }]` | `WslcSetContainerSettingsPortMappings()` with `WslcContainerPortMapping` structs (TCP only) |
| `portMappings: [{ windowsPort: 8080, containerPort: 80, protocol: "tcp" }]` | `WslcSetContainerSettingsPortMappings()` with `WslcContainerPortMapping` structs (TCP only; `protocol` defaults to `"tcp"`. UDP is declared by the SDK header but returns `E_NOTIMPL` at runtime in the vendored SDK 2.8.1 and is rejected by the parser.) |

**WSLC SDK advantage:** The `WslcContainerVolume` struct directly models the Windows↔Linux path mapping with `windowsPath` (PCWSTR) and `containerPath` (PCSTR) fields. The runner applies the deterministic `/mnt/<drive>/...` mapping rule and the SDK's 9P filesystem handles the cross-OS bridging internally.

Expand Down
8 changes: 4 additions & 4 deletions schemas/dev/mxc-config.schema.0.8.0-dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,8 @@
},
"portMappings": {
"type": "array",
"description": "Host-to-container port forwards. Each entry maps a Windows host port to a container port for a given protocol.",
"description": "Host-to-container port forwards. Each entry maps a Windows host port to a container port for a given protocol. Only TCP is currently supported by the vendored WSLC SDK runtime (Microsoft.WSL.Containers 2.8.1); the parser hard-rejects 'udp' because the shipped runtime returns E_NOTIMPL even though the SDK header declares it.",
"default": [],
"items": {
"type": "object",
"properties": {
Expand All @@ -437,11 +438,10 @@
"protocol": {
"type": "string",
"enum": [
"tcp",
"udp"
"tcp"
],
"default": "tcp",
"description": "Transport protocol for the mapping."
"description": "Transport protocol for the mapping. Only 'tcp' is currently supported (see description above)."
}
},
"required": [
Expand Down
21 changes: 17 additions & 4 deletions sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,20 +224,33 @@ export interface WslcConfig {
gpu?: boolean;
/** Path to a local tar file to import as the container image */
imageTarPath?: string;
/** Host↔container port mappings (TCP only) */
/**
* Host↔container port mappings.
*
* Only TCP is currently supported by the vendored WSLC SDK runtime
* (Microsoft.WSL.Containers 2.8.1). UDP is declared in the SDK header
* but the shipped runtime returns `E_NOTIMPL` when UDP is actually
* requested, so the parser hard-rejects `"udp"` with a clear message at
* spawn time. The `protocol` field defaults to `"tcp"` when omitted.
*/
portMappings?: PortMapping[];
}

/**
* Port mapping for host↔container port forwarding
* Port mapping for host↔container port forwarding.
*/
export interface PortMapping {
/** Port on the Windows host */
windowsPort: number;
/** Port inside the Linux container */
containerPort: number;
/** Protocol: "tcp" or "udp" (default: "tcp") */
protocol?: string;
/**
* Transport protocol. Only `"tcp"` is currently supported; `"udp"` is
* rejected by the parser because the vendored WSLC SDK runtime
* (Microsoft.WSL.Containers 2.8.1) returns `E_NOTIMPL` for UDP even
* though the header declares it. Defaults to `"tcp"` when omitted.
*/
protocol?: 'tcp';
}

/**
Expand Down
177 changes: 168 additions & 9 deletions sdk/tests/integration/wslc-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import os from 'os';
import { ChildProcess } from 'child_process';
Expand All @@ -25,17 +26,32 @@ import { sdk } from './test-helpers.js';
// Opt-in via MXC_ENABLE_WSLC_TESTS=1 since most CI agents lack the runtime.
const isWslcAvailable = os.platform() === 'win32' && process.env.MXC_ENABLE_WSLC_TESTS === '1';

// Probe a small range of host ports and return the first one we can
// successfully bind to. Avoids both the fixed-port collision risk (any
// other process on the dev box / runner may already own a hard-coded
// port) AND the TOCTOU race that `listen(0) β†’ close β†’ reuse` would
// introduce. Throws if every candidate in the range is busy.
async function pickAvailableHostPort(start = 40000, end = 40099): Promise<number> {
for (let port = start; port <= end; port++) {
const ok = await new Promise<boolean>((resolve) => {
const srv = net.createServer();
srv.once('error', () => resolve(false));
srv.listen(port, '127.0.0.1', () => srv.close(() => resolve(true)));
});
if (ok) return port;
}
throw new Error(`pickAvailableHostPort: no free port in [${start}, ${end}]`);
}

describe('WSLC SDK E2E β€” createConfigFromPolicy β†’ customize β†’ spawn', {
skip: !isWslcAvailable ? 'WSLC tests require MXC_ENABLE_WSLC_TESTS=1 on Windows with WSL2 and WSLC SDK' : undefined,
}, () => {

it('should run with all WSLC-specific fields set', async () => {
// Create temp directories for volume mount and storage.
it('should run with all WSLC-specific fields set', { timeout: 120_000 }, async () => {
// Create temp directory for the volume mount.
// Use short paths under os.tmpdir() β€” WSLC SDK can fail with very long paths.
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-e2e-'));
const storageDir = path.join(testDir, 'storage');
const mountDir = path.join(testDir, 'mount');
fs.mkdirSync(storageDir);
fs.mkdirSync(mountDir);

try {
Expand All @@ -54,9 +70,13 @@ describe('WSLC SDK E2E β€” createConfigFromPolicy β†’ customize β†’ spawn', {
config.experimental!.wslc!.image = 'python:3.12-alpine';
config.experimental!.wslc!.cpuCount = 2;
config.experimental!.wslc!.memoryMb = 1024;
config.experimental!.wslc!.storagePath = storageDir;
// Intentionally omit `storagePath` so this test reuses the default
// image store where `python:3.12-alpine` has already been pre-pulled
// (the docs require operators to pre-pull). Setting storagePath to a
// fresh temp directory would point WSLC at an empty image store and
// fail with "image not found" β€” MXC does not pull at runtime.

const { stdout, exitCode } = await new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve, reject) => {
const { stdout, stderr, exitCode } = await new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve, reject) => {
const child = sdk.spawnSandboxFromConfig(config, { experimental: true, debug: true, usePty: false }) as ChildProcess;
let stdout = '';
let stderr = '';
Expand All @@ -70,11 +90,150 @@ describe('WSLC SDK E2E β€” createConfigFromPolicy β†’ customize β†’ spawn', {
});
});

assert.strictEqual(exitCode, 0);
assert.ok(stdout.includes('Python 3.12'));
assert.ok(stdout.includes('All fields work'));
assert.strictEqual(exitCode, 0, `exit=${exitCode}\nstdout=${stdout}\nstderr=${stderr}`);
assert.ok(stdout.includes('Python 3.12'), `Python 3.12 not found in stdout=${stdout}`);
assert.ok(stdout.includes('All fields work'), `'All fields work' not found in stdout=${stdout}`);
} finally {
fs.rmSync(testDir, { recursive: true, force: true });
}
});

it('should forward a TCP port from host to container', { timeout: 120_000 }, async () => {
const http = await import('node:http');
// Pick an available host port to avoid collisions on busy dev/CI hosts.
// The container port can stay fixed because the container's network
// namespace is isolated from the host.
const HOST_PORT = await pickAvailableHostPort();
const CONTAINER_PORT = 8080;

Comment thread
SohamDas2021 marked this conversation as resolved.
const policy = {
version: '0.5.0-alpha',
network: { allowOutbound: true },
filesystem: {},
};
const config = sdk.createConfigFromPolicy(policy, 'wslc');
// The container runs `/bin/sh -c "<script_code>"`. We base64-encode the
// Python source and run it via a single-argv `python3 -c "..."` call to
// avoid any embedded-newline / shell-pipeline ambiguity through the WSLC
// FFI. `handle_request()` serves exactly one request then returns, so
// the container exits cleanly once the host probe completes β€” no
// SIGTERM/SIGKILL dance is needed.
//
// We deliberately do NOT wait for an in-container "ready" marker before
// probing: WSLC's stdout pump may delay delivery of bytes from a
// long-running process. The host probe retries on ECONNREFUSED, so the
// retry loop naturally bridges the bind-then-accept window.
const pythonScript = `import http.server, socketserver
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b'PORT_MAPPING_TCP_OK')
def log_message(self, *a, **k):
pass
srv = socketserver.TCPServer(('0.0.0.0', ${CONTAINER_PORT}), H)
srv.handle_request()
`;
const scriptB64 = Buffer.from(pythonScript, 'utf8').toString('base64');
config.process!.commandLine = `python3 -c "import base64; exec(base64.b64decode('${scriptB64}'))"`;
config.experimental!.wslc!.image = 'python:3.12-alpine';
config.experimental!.wslc!.portMappings = [
{ windowsPort: HOST_PORT, containerPort: CONTAINER_PORT, protocol: 'tcp' },
];

const child = sdk.spawnSandboxFromConfig(config, { experimental: true, debug: true, usePty: false }) as ChildProcess;
let stdout = '';
let stderr = '';
child.stdout?.on('data', (d: Buffer) => { stdout += d.toString(); });
child.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
const closed = new Promise<number | null>((resolve) => {
if (child.exitCode !== null) {
resolve(child.exitCode);
} else {
child.on('close', (code: number | null) => resolve(code));
}
});

let body = '';
let lastErr: Error | undefined;
try {
// Probe from the Windows host with poll-retry. Retries cover both the
// container-start window and the NAT-rule settle window. Each attempt
// bails if the child has already exited (avoids 60s of pointless retry
// when the container crashed).
const probeDeadline = Date.now() + 60_000;
while (Date.now() < probeDeadline) {
if (child.exitCode !== null) {
throw new Error(`Container exited before probe could succeed (code=${child.exitCode}). stdout=${stdout} stderr=${stderr}`);
}
try {
body = await new Promise<string>((resolve, reject) => {
const req = http.get({ host: '127.0.0.1', port: HOST_PORT, timeout: 2000 }, (res) => {
const chunks: Buffer[] = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(new Error('http.get timeout')); });
});
break;
} catch (e) {
lastErr = e as Error;
await new Promise((r) => setTimeout(r, 500));
}
}
assert.strictEqual(body, 'PORT_MAPPING_TCP_OK', `host probe failed; lastErr=${lastErr?.message} stdout=${stdout} stderr=${stderr}`);
} finally {
// After one served request, the Python server returns from handle_request
// and the container exits naturally. Wait up to 20s for clean exit, then
// force-kill so a stuck WSLC teardown can never hang the whole suite.
const cleanExit = await Promise.race([
closed,
new Promise<'timeout'>((r) => setTimeout(() => r('timeout'), 20_000)),
]);
if (cleanExit === 'timeout') {
child.kill('SIGKILL');
await Promise.race([
closed,
new Promise((r) => setTimeout(r, 5_000)),
]);
}
}
});

it('should reject UDP port mapping with a clear SDK-limitation message', { timeout: 60_000 }, async () => {
// WSLC SDK 2.8.1 declares WSLC_PORT_PROTOCOL_UDP in its header but its
// runtime returns E_NOTIMPL (0x80004001) when UDP is actually requested.
// The parser rejects UDP up front so SDK consumers get a clear error at
// spawn time rather than a cryptic HRESULT at container-create time. The
// SDK type narrows `protocol` to `'tcp'`, so a cast is required here to
// exercise the parser path that rejects an out-of-type value at runtime.
const policy = {
version: '0.5.0-alpha',
network: { allowOutbound: true },
filesystem: {},
};
const config = sdk.createConfigFromPolicy(policy, 'wslc');
config.process!.commandLine = 'echo unreachable';
config.experimental!.wslc!.image = 'python:3.12-alpine';
config.experimental!.wslc!.portMappings = [
{ windowsPort: 39000, containerPort: 9000, protocol: 'udp' as unknown as 'tcp' },
];

const { exitCode, combined } = await new Promise<{ exitCode: number; combined: string }>((resolve, reject) => {
const child = sdk.spawnSandboxFromConfig(config, { experimental: true, debug: true, usePty: false }) as ChildProcess;
let combined = '';
const onData = (d: Buffer) => { combined += d.toString(); };
child.stdout?.on('data', onData);
child.stderr?.on('data', onData);
child.on('error', reject);
child.on('close', (code: number | null) => resolve({ exitCode: code ?? -1, combined }));
});

assert.notStrictEqual(exitCode, 0, `expected non-zero exit when UDP is requested; output=${combined}`);
assert.ok(
/udp/i.test(combined) && /not supported|not implemented/i.test(combined),
`expected SDK-limitation message mentioning UDP; output=${combined}`,
);
});
});
46 changes: 43 additions & 3 deletions src/backends/wslc/common/src/wsl_container_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -972,9 +972,49 @@ impl WSLContainerRunner {
return sdk_error("WslcInitContainerSettings failed", hr, "");
}

// TODO: Port mappings (WslcConfig.port_mappings) are parsed but not yet applied.
// Requires adding WslcSetContainerSettingsPortMappings and WslcContainerPortMapping
// bindings. See wslcsdk.h lines 120-128, 183-186.
// -- Port mappings (host<->container) --
// Apply before networking mode so the SDK has the complete picture
// when the container is created. Empty list = no forwarding (default).
// The parser rejects `"udp"` up front: the C header declares
// `WSLC_PORT_PROTOCOL_UDP = 1` but the shipped runtime
// (Microsoft.WSL.Containers 2.8.1) returns `E_NOTIMPL` when UDP is
// actually requested. The protocol match below therefore only ever
// sees `"tcp"` today, but the explicit branch is retained so this
// code keeps compiling cleanly if/when the parser starts accepting
// UDP after an SDK update.
if !self.config.port_mappings.is_empty() {
let mappings: Vec<WslcContainerPortMapping> = self
.config
.port_mappings
.iter()
.map(|pm| WslcContainerPortMapping {
windows_port: pm.windows_port,
container_port: pm.container_port,
protocol: if pm.protocol == "udp" {
WslcPortProtocol::Udp
} else {
WslcPortProtocol::Tcp
},
// Default bind address (typically loopback/0.0.0.0 per
// SDK config). Not exposed in the MXC config today.
windows_address: ptr::null(),
})
.collect();

let hr = (sdk.WslcSetContainerSettingsPortMappings)(
&mut container_settings,
mappings.as_ptr(),
mappings.len() as u32,
);
if hr != S_OK {
return sdk_error("WslcSetContainerSettingsPortMappings failed", hr, "");
}
let _ = writeln!(
logger,
"[WSLC] {} port mapping(s) configured",
mappings.len()
);
}
let mounts = match policy_mapping::build_volume_mounts(
&request.policy.readwrite_paths,
&request.policy.readonly_paths,
Expand Down
Loading
Loading