Skip to content

Commit cc29be8

Browse files
committed
fix: serialize sandbox registry writes
1 parent 8278903 commit cc29be8

3 files changed

Lines changed: 342 additions & 54 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9+
### Fixes
10+
11+
- Sandbox/Registry: serialize container and browser registry writes with shared file locks and atomic replacement to prevent lost updates and delete rollback races from desyncing `sandbox list`, `prune`, and `recreate --all`. Thanks @kexinoh.
12+
13+
## 2026.2.17
14+
15+
### Changes
16+
917
- Agents/Anthropic: add opt-in 1M context beta header support for Opus/Sonnet via model `params.context1m: true` (maps to `anthropic-beta: context-1m-2025-08-07`).
1018
- Agents/Models: support Anthropic Sonnet 4.6 (`anthropic/claude-sonnet-4-6`) across aliases/defaults with forward-compat fallback when upstream catalogs still only expose Sonnet 4.5.
1119
- Commands/Subagents: add `/subagents spawn` for deterministic subagent activation from chat commands. (#18218) Thanks @JoshuaLelon.
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { mkdtempSync } from "node:fs";
2+
import fs from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import path from "node:path";
5+
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+
7+
const TEST_STATE_DIR = mkdtempSync(path.join(tmpdir(), "openclaw-sandbox-registry-"));
8+
const SANDBOX_REGISTRY_PATH = path.join(TEST_STATE_DIR, "containers.json");
9+
const SANDBOX_BROWSER_REGISTRY_PATH = path.join(TEST_STATE_DIR, "browsers.json");
10+
11+
vi.mock("./constants.js", () => ({
12+
SANDBOX_STATE_DIR: TEST_STATE_DIR,
13+
SANDBOX_REGISTRY_PATH,
14+
SANDBOX_BROWSER_REGISTRY_PATH,
15+
}));
16+
17+
import type { SandboxBrowserRegistryEntry, SandboxRegistryEntry } from "./registry.js";
18+
import {
19+
readBrowserRegistry,
20+
readRegistry,
21+
removeBrowserRegistryEntry,
22+
removeRegistryEntry,
23+
updateBrowserRegistry,
24+
updateRegistry,
25+
} from "./registry.js";
26+
27+
type WriteDelayConfig = {
28+
containerName: string;
29+
browserName: string;
30+
containerDelayMs: number;
31+
browserDelayMs: number;
32+
};
33+
34+
let writeDelayConfig: WriteDelayConfig = {
35+
containerName: "container-a",
36+
browserName: "browser-a",
37+
containerDelayMs: 0,
38+
browserDelayMs: 0,
39+
};
40+
41+
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
42+
const realFsWriteFile = fs.writeFile;
43+
44+
function writeText(content: Parameters<typeof fs.writeFile>[1]): string {
45+
if (typeof content === "string") {
46+
return content;
47+
}
48+
if (content instanceof ArrayBuffer) {
49+
return Buffer.from(content).toString("utf-8");
50+
}
51+
if (ArrayBuffer.isView(content)) {
52+
return Buffer.from(content.buffer, content.byteOffset, content.byteLength).toString("utf-8");
53+
}
54+
return "";
55+
}
56+
57+
beforeEach(() => {
58+
writeDelayConfig = {
59+
containerName: "container-a",
60+
browserName: "browser-a",
61+
containerDelayMs: 0,
62+
browserDelayMs: 0,
63+
};
64+
vi.spyOn(fs, "writeFile").mockImplementation(async (...args) => {
65+
const [target, content] = args;
66+
if (typeof target !== "string") {
67+
return realFsWriteFile(...args);
68+
}
69+
70+
const payload = writeText(content);
71+
if (
72+
target.includes("containers.json") &&
73+
payload.includes(`"containerName":"${writeDelayConfig.containerName}"`) &&
74+
writeDelayConfig.containerDelayMs > 0
75+
) {
76+
await delay(writeDelayConfig.containerDelayMs);
77+
}
78+
79+
if (
80+
target.includes("browsers.json") &&
81+
payload.includes(`"containerName":"${writeDelayConfig.browserName}"`) &&
82+
writeDelayConfig.browserDelayMs > 0
83+
) {
84+
await delay(writeDelayConfig.browserDelayMs);
85+
}
86+
return realFsWriteFile(...args);
87+
});
88+
});
89+
90+
afterEach(async () => {
91+
vi.restoreAllMocks();
92+
await fs.rm(TEST_STATE_DIR, { recursive: true, force: true });
93+
await fs.mkdir(TEST_STATE_DIR, { recursive: true });
94+
});
95+
96+
afterAll(async () => {
97+
await fs.rm(TEST_STATE_DIR, { recursive: true, force: true });
98+
});
99+
100+
function browserEntry(
101+
overrides: Partial<SandboxBrowserRegistryEntry> = {},
102+
): SandboxBrowserRegistryEntry {
103+
return {
104+
containerName: "browser-a",
105+
sessionKey: "agent:main",
106+
createdAtMs: 1,
107+
lastUsedAtMs: 1,
108+
image: "openclaw-browser:test",
109+
cdpPort: 9222,
110+
...overrides,
111+
};
112+
}
113+
114+
function containerEntry(overrides: Partial<SandboxRegistryEntry> = {}): SandboxRegistryEntry {
115+
return {
116+
containerName: "container-a",
117+
sessionKey: "agent:main",
118+
createdAtMs: 1,
119+
lastUsedAtMs: 1,
120+
image: "openclaw-sandbox:test",
121+
...overrides,
122+
};
123+
}
124+
125+
async function seedContainerRegistry(entries: SandboxRegistryEntry[]) {
126+
await fs.writeFile(SANDBOX_REGISTRY_PATH, `${JSON.stringify({ entries }, null, 2)}\n`, "utf-8");
127+
}
128+
129+
async function seedBrowserRegistry(entries: SandboxBrowserRegistryEntry[]) {
130+
await fs.writeFile(
131+
SANDBOX_BROWSER_REGISTRY_PATH,
132+
`${JSON.stringify({ entries }, null, 2)}\n`,
133+
"utf-8",
134+
);
135+
}
136+
137+
describe("registry race safety", () => {
138+
it("keeps both container updates under concurrent writes", async () => {
139+
writeDelayConfig = {
140+
containerName: "container-a",
141+
browserName: "browser-a",
142+
containerDelayMs: 80,
143+
browserDelayMs: 0,
144+
};
145+
146+
await Promise.all([
147+
updateRegistry(containerEntry({ containerName: "container-a" })),
148+
updateRegistry(containerEntry({ containerName: "container-b" })),
149+
]);
150+
151+
const registry = await readRegistry();
152+
expect(registry.entries).toHaveLength(2);
153+
expect(
154+
registry.entries
155+
.map((entry) => entry.containerName)
156+
.slice()
157+
.toSorted(),
158+
).toEqual(["container-a", "container-b"]);
159+
});
160+
161+
it("prevents concurrent container remove/update from resurrecting deleted entries", async () => {
162+
await seedContainerRegistry([containerEntry({ containerName: "container-x" })]);
163+
writeDelayConfig = {
164+
containerName: "container-x",
165+
browserName: "browser-a",
166+
containerDelayMs: 80,
167+
browserDelayMs: 0,
168+
};
169+
170+
await Promise.all([
171+
removeRegistryEntry("container-x"),
172+
updateRegistry(containerEntry({ containerName: "container-x", configHash: "updated" })),
173+
]);
174+
175+
const registry = await readRegistry();
176+
expect(registry.entries).toHaveLength(0);
177+
});
178+
179+
it("keeps both browser updates under concurrent writes", async () => {
180+
writeDelayConfig = {
181+
containerName: "container-a",
182+
browserName: "browser-a",
183+
containerDelayMs: 0,
184+
browserDelayMs: 80,
185+
};
186+
187+
await Promise.all([
188+
updateBrowserRegistry(browserEntry({ containerName: "browser-a" })),
189+
updateBrowserRegistry(browserEntry({ containerName: "browser-b", cdpPort: 9223 })),
190+
]);
191+
192+
const registry = await readBrowserRegistry();
193+
expect(registry.entries).toHaveLength(2);
194+
expect(
195+
registry.entries
196+
.map((entry) => entry.containerName)
197+
.slice()
198+
.toSorted(),
199+
).toEqual(["browser-a", "browser-b"]);
200+
});
201+
202+
it("prevents concurrent browser remove/update from resurrecting deleted entries", async () => {
203+
await seedBrowserRegistry([browserEntry({ containerName: "browser-x" })]);
204+
writeDelayConfig = {
205+
containerName: "container-a",
206+
browserName: "browser-x",
207+
containerDelayMs: 0,
208+
browserDelayMs: 80,
209+
};
210+
211+
await Promise.all([
212+
removeBrowserRegistryEntry("browser-x"),
213+
updateBrowserRegistry(browserEntry({ containerName: "browser-x", configHash: "updated" })),
214+
]);
215+
216+
const registry = await readBrowserRegistry();
217+
expect(registry.entries).toHaveLength(0);
218+
});
219+
});

0 commit comments

Comments
 (0)