Skip to content
Open
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
540 changes: 540 additions & 0 deletions docs/manual-cleanup-refactor-guide.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import json
import logging
import time

import httpx
from opensandbox.adapters.converter.event_node import EventNode
Expand Down Expand Up @@ -49,6 +50,22 @@
logger = logging.getLogger(__name__)


def _normalize_sse_event(event_dict: dict) -> dict:
if "type" in event_dict and "timestamp" in event_dict:
return event_dict
if "code" in event_dict and "message" in event_dict:
return {
"type": "error",
"timestamp": int(time.time() * 1000),
"error": {
"ename": str(event_dict["code"]),
"evalue": str(event_dict["message"]),
"traceback": [],
},
}
return event_dict


class CodesAdapter(Codes):
"""
Adapter implementation for code execution service.
Expand Down Expand Up @@ -301,7 +318,7 @@ async def run(
data = data[5:].strip()

try:
event_dict = json.loads(data)
event_dict = _normalize_sse_event(json.loads(data))
event_node = EventNode(**event_dict)
await dispatcher.dispatch(event_node)
except json.JSONDecodeError:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import json
import logging
import time

import httpx
from opensandbox.adapters.converter.event_node import EventNode
Expand All @@ -44,6 +45,22 @@
logger = logging.getLogger(__name__)


def _normalize_sse_event(event_dict: dict) -> dict:
if "type" in event_dict and "timestamp" in event_dict:
return event_dict
if "code" in event_dict and "message" in event_dict:
return {
"type": "error",
"timestamp": int(time.time() * 1000),
"error": {
"ename": str(event_dict["code"]),
"evalue": str(event_dict["message"]),
"traceback": [],
},
}
return event_dict


class CodesAdapterSync(CodesSync):
"""
Synchronous adapter for code execution service.
Expand Down Expand Up @@ -261,7 +278,7 @@ def run(
if data.startswith("data:"):
data = data[5:].strip()
try:
event_dict = json.loads(data)
event_dict = _normalize_sse_event(json.loads(data))
event_node = EventNode(**event_dict)
dispatcher.dispatch(event_node)
except json.JSONDecodeError:
Expand Down
22 changes: 21 additions & 1 deletion sdks/sandbox/csharp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Manage the sandbox lifecycle, including renewal, pausing, and resuming.
var info = await sandbox.GetInfoAsync();
Console.WriteLine($"State: {info.Status.State}");
Console.WriteLine($"Created: {info.CreatedAt}");
Console.WriteLine($"Expires: {info.ExpiresAt}");
Console.WriteLine($"Expires: {info.ExpiresAt}"); // null when manual cleanup mode is used

await sandbox.PauseAsync();

Expand All @@ -79,6 +79,23 @@ var resumed = await sandbox.ResumeAsync();
await resumed.RenewAsync(30 * 60);
```

Create a non-expiring sandbox by setting `ManualCleanup = true`:

```csharp
var manual = await Sandbox.CreateAsync(new SandboxCreateOptions
{
ConnectionConfig = config,
Image = "ubuntu",
ManualCleanup = true,
});
```

Note: unlike the Python, JavaScript, and Kotlin SDKs, the C# SDK uses an explicit
`ManualCleanup` flag instead of `TimeoutSeconds = null`. This is intentional:
`int?` in the current options model cannot reliably distinguish "unset, use the
default TTL" from "explicitly request manual cleanup" without making the default
creation path ambiguous.

### Connect to an Existing Sandbox

Use `ConnectAsync` when you already have a sandbox ID and need a new SDK instance bound to it.
Expand Down Expand Up @@ -293,6 +310,9 @@ var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions
| `ReadyTimeoutSeconds` | Max time to wait for readiness | 30 seconds |
| `HealthCheckPollingInterval` | Poll interval while waiting (milliseconds) | 200 ms |

Note: metadata keys under `opensandbox.io/` are reserved for system-managed
labels and will be rejected by the server.

```csharp
var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions
{
Expand Down
20 changes: 19 additions & 1 deletion sdks/sandbox/csharp/README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ catch (SandboxException ex)
var info = await sandbox.GetInfoAsync();
Console.WriteLine($"状态: {info.Status.State}");
Console.WriteLine($"创建时间: {info.CreatedAt}");
Console.WriteLine($"过期时间: {info.ExpiresAt}");
Console.WriteLine($"过期时间: {info.ExpiresAt}"); // 使用手动清理模式时为 null

await sandbox.PauseAsync();

Expand All @@ -79,6 +79,22 @@ var resumed = await sandbox.ResumeAsync();
await resumed.RenewAsync(30 * 60);
```

通过设置 `ManualCleanup = true` 创建一个不会自动过期的沙箱:

```csharp
var manual = await Sandbox.CreateAsync(new SandboxCreateOptions
{
ConnectionConfig = config,
Image = "ubuntu",
ManualCleanup = true,
});
```

注意:与 Python、JavaScript、Kotlin SDK 不同,C# SDK 使用显式的
`ManualCleanup` 开关,而不是 `TimeoutSeconds = null`。这是有意的设计,
因为在当前的 options 模型里,`int?` 不能稳定地区分“未设置,沿用默认 TTL”
和“显式请求手动清理”。

### 2. 自定义健康检查

定义自定义逻辑来确定沙箱是否就绪/健康。
Expand Down Expand Up @@ -278,6 +294,8 @@ var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions
| `ReadyTimeoutSeconds` | 等待就绪的最大时间 | 30 秒 |
| `HealthCheckPollingInterval` | 等待时的轮询间隔(毫秒) | 200 ms |

注意:`opensandbox.io/` 前缀下的 metadata key 属于系统保留标签,服务端会拒绝用户传入。

```csharp
var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ private static DateTime ParseIsoDate(string fieldName, JsonElement element)
return date.ToUniversalTime();
}

private static DateTime? ParseOptionalIsoDate(string fieldName, JsonElement element)
{
return element.ValueKind == JsonValueKind.Null ? null : ParseIsoDate(fieldName, element);
}

private static SandboxInfo ParseSandboxInfo(JsonElement element)
{
var status = element.GetProperty("status");
Expand All @@ -193,7 +198,9 @@ private static SandboxInfo ParseSandboxInfo(JsonElement element)
Message = status.TryGetProperty("message", out var message) ? message.GetString() : null
},
CreatedAt = ParseIsoDate("createdAt", element.GetProperty("createdAt")),
ExpiresAt = ParseIsoDate("expiresAt", element.GetProperty("expiresAt"))
ExpiresAt = element.TryGetProperty("expiresAt", out var expiresAtElement)
? ParseOptionalIsoDate("expiresAt", expiresAtElement)
: null
};
}

Expand All @@ -214,7 +221,9 @@ private static CreateSandboxResponse ParseCreateSandboxResponse(JsonElement elem
? metadata.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString() ?? string.Empty)
: null,
CreatedAt = ParseIsoDate("createdAt", element.GetProperty("createdAt")),
ExpiresAt = ParseIsoDate("expiresAt", element.GetProperty("expiresAt")),
ExpiresAt = element.TryGetProperty("expiresAt", out var expiresAtElement)
? ParseOptionalIsoDate("expiresAt", expiresAtElement)
: null,
Entrypoint = element.GetProperty("entrypoint").EnumerateArray().Select(e => e.GetString() ?? string.Empty).ToList()
};
}
Expand Down
6 changes: 3 additions & 3 deletions sdks/sandbox/csharp/src/OpenSandbox/Models/Sandboxes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ public class SandboxInfo
/// Gets or sets the sandbox expiration time.
/// </summary>
[JsonPropertyName("expiresAt")]
public required DateTime ExpiresAt { get; set; }
public DateTime? ExpiresAt { get; set; }
}

/// <summary>
Expand All @@ -273,7 +273,7 @@ public class CreateSandboxRequest
/// Gets or sets the timeout in seconds.
/// </summary>
[JsonPropertyName("timeout")]
public required int Timeout { get; set; }
public int? Timeout { get; set; }

/// <summary>
/// Gets or sets the resource limits.
Expand Down Expand Up @@ -339,7 +339,7 @@ public class CreateSandboxResponse
/// Gets or sets the sandbox expiration time.
/// </summary>
[JsonPropertyName("expiresAt")]
public required DateTime ExpiresAt { get; set; }
public DateTime? ExpiresAt { get; set; }

/// <summary>
/// Gets or sets the sandbox creation time.
Expand Down
5 changes: 5 additions & 0 deletions sdks/sandbox/csharp/src/OpenSandbox/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ public class SandboxCreateOptions
/// </summary>
public int? TimeoutSeconds { get; set; }

/// <summary>
/// Gets or sets whether the sandbox should disable automatic expiration and require explicit cleanup.
/// </summary>
public bool ManualCleanup { get; set; }

/// <summary>
/// Gets or sets whether to skip health checks during creation.
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ public static async Task<Sandbox> CreateAsync(
Auth = options.ImageAuth
},
Entrypoint = options.Entrypoint ?? Constants.DefaultEntrypoint,
Timeout = options.TimeoutSeconds ?? Constants.DefaultTimeoutSeconds,
Timeout = options.ManualCleanup ? null : options.TimeoutSeconds ?? Constants.DefaultTimeoutSeconds,
ResourceLimits = options.Resource ?? Constants.DefaultResourceLimits,
Env = options.Env,
Metadata = options.Metadata,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
using FluentAssertions;
using OpenSandbox.Adapters;
using OpenSandbox.Internal;
using OpenSandbox.Models;
using Xunit;

namespace OpenSandbox.Tests;
Expand Down Expand Up @@ -58,6 +59,56 @@ public async Task GetSandboxEndpointAsync_ShouldDefaultUseServerProxyToFalse()
handler.LastRequestUri!.Query.Should().Contain("use_server_proxy=false");
}

[Fact]
public async Task GetSandboxAsync_ShouldTreatMissingExpiresAtAsNull()
{
var payload = """
{
"id": "sbx-1",
"image": { "uri": "python:3.11" },
"entrypoint": ["python"],
"status": { "state": "Running" },
"createdAt": "2026-03-14T12:00:00Z"
}
""";
var adapter = CreateAdapterWithJsonResponse(payload);

SandboxInfo sandbox = await adapter.GetSandboxAsync("sbx-1");

sandbox.ExpiresAt.Should().BeNull();
}

[Fact]
public async Task CreateSandboxAsync_ShouldTreatMissingExpiresAtAsNull()
{
var payload = """
{
"id": "sbx-2",
"status": { "state": "Pending" },
"createdAt": "2026-03-14T12:00:00Z",
"entrypoint": ["python"]
}
""";
var adapter = CreateAdapterWithJsonResponse(payload);

CreateSandboxResponse response = await adapter.CreateSandboxAsync(new CreateSandboxRequest
{
Image = new ImageSpec { Uri = "python:3.11" },
ResourceLimits = new Dictionary<string, string>(),
Entrypoint = new List<string> { "python" }
});

response.ExpiresAt.Should().BeNull();
}

private static SandboxesAdapter CreateAdapterWithJsonResponse(string payload)
{
var handler = new StaticJsonHandler(payload);
var client = new HttpClient(handler);
var wrapper = new HttpClientWrapper(client, "http://localhost:8080/v1");
return new SandboxesAdapter(wrapper);
}

private sealed class CaptureHandler : HttpMessageHandler
{
public Uri? LastRequestUri { get; private set; }
Expand All @@ -73,4 +124,16 @@ protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage reques
return Task.FromResult(response);
}
}

private sealed class StaticJsonHandler(string payload) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json")
};
return Task.FromResult(response);
}
}
}
15 changes: 14 additions & 1 deletion sdks/sandbox/javascript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Manage the sandbox lifecycle, including renewal, pausing, and resuming.
const info = await sandbox.getInfo();
console.log("State:", info.status.state);
console.log("Created:", info.createdAt);
console.log("Expires:", info.expiresAt);
console.log("Expires:", info.expiresAt); // null when manual cleanup mode is used

await sandbox.pause();

Expand All @@ -85,6 +85,16 @@ const resumed = await sandbox.resume();
await resumed.renew(30 * 60);
```

Create a non-expiring sandbox by passing `timeoutSeconds: null`:

```ts
const manual = await Sandbox.create({
connectionConfig: config,
image: "ubuntu",
timeoutSeconds: null,
});
```

### 2. Custom Health Check

Define custom logic to determine whether the sandbox is ready/healthy. This overrides the default ping check used during readiness checks.
Expand Down Expand Up @@ -228,6 +238,9 @@ const config2 = new ConnectionConfig({
| `readyTimeoutSeconds` | Max time to wait for readiness | 30 seconds |
| `healthCheckPollingInterval` | Poll interval while waiting (milliseconds) | 200 ms |

Note: metadata keys under `opensandbox.io/` are reserved for system-managed
labels and will be rejected by the server.

```ts
const sandbox = await Sandbox.create({
connectionConfig: config,
Expand Down
17 changes: 17 additions & 0 deletions sdks/sandbox/javascript/README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,21 @@ const resumed = await sandbox.resume();

// renew:expiresAt = now + timeoutSeconds
await resumed.renew(30 * 60);

// 获取当前状态
const info = await resumed.getInfo();
console.log("状态:", info.status.state);
console.log("过期时间:", info.expiresAt); // 使用手动清理模式时为 null
```

通过传入 `timeoutSeconds: null` 创建一个不会自动过期的沙箱:

```ts
const manual = await Sandbox.create({
connectionConfig: config,
image: "ubuntu",
timeoutSeconds: null,
});
```

### 2. 自定义健康检查
Expand Down Expand Up @@ -223,6 +238,8 @@ const config2 = new ConnectionConfig({
| `readyTimeoutSeconds` | 等待就绪最大时间 | 30 秒 |
| `healthCheckPollingInterval` | 就绪轮询间隔(毫秒) | 200 ms |

注意:`opensandbox.io/` 前缀下的 metadata key 属于系统保留标签,服务端会拒绝用户传入。

```ts
const sandbox = await Sandbox.create({
connectionConfig: config,
Expand Down
Loading