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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@ file is the human-readable summary of what changed and why.
tarball, packed in by accident whenever a release was retried. Removing it
takes each platform package from ~112MB back to ~55MB.

### Sandbox

- **Sessions in a sandbox survive stopping it** — anything the sandbox had
written in the seconds before it stopped was thrown away: stop a sandbox, or
add a folder to one (which restarts it), or quit the app, and the sessions
you had just started were gone when it came back. Stopping a sandbox cuts
its power, and the sandbox's database was set up to survive a crashed
program rather than a machine that vanishes, so the most recent writes never
reached the disk. A sandbox now commits its work to disk as it goes. Older
sessions were never lost — only the newest ones, and only the ones that had
not been written out yet.
- **A sandbox starts again after the app is force quit** — force quitting the
app (or a crash) left a file behind that the sandbox needed to create on its
next start, so the sandbox failed to start from then on, with `address
already in use` and no way forward short of finding the file in a temporary
directory and deleting it. That leftover is now cleared on the next start.

### Desktop / Web UI

- **A folder on Work is unmounted from its own menu** — the menu on a **Work**
Expand Down
3 changes: 2 additions & 1 deletion DIVERGENCES.md

Large diffs are not rendered by default.

15 changes: 14 additions & 1 deletion packages/codev-sandbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ Electron main process.
full-outbound mode. Extra upstreams are an admin-editable allowlist of named
rules, never a general proxy.
- `boot.ts` — the guest boot contract shared by every backend (credentials,
env, serve args, health polling).
env, serve args, health polling). Two of those env entries exist because a
guest is not a normal machine: `CODEV_SQLITE_SYNCHRONOUS=FULL`, because
stopping a sandbox cuts its power and the server's default WAL +
`synchronous=NORMAL` only survives a crashed process, not a machine that
disappears; and `CODEV_DISABLE_CHANNEL_DB=1`, because a dev build takes its
channel from the host repo's current branch, which otherwise re-keyed the
guest's whole database every time the host changed branch.
- `mounts.ts` — guest paths for extra host folders (`/mnt/<name>` on vz/bwrap,
the wslpath translation on WSL) and the structural validation shared with the
desktop IPC handler.
Expand Down Expand Up @@ -172,6 +178,13 @@ This package is platform code only; the desktop app owns everything user-facing.

## Known gaps

- **Stopping a sandbox is a power cut.** `native/vzhelper` calls
`VZVirtualMachine.stop()`, which Apple documents as pulling the power cable;
the guest never syncs. Durable SQLite commits cover the server's own data,
but anything else the guest wrote to its disk in the last seconds is still
lost. A graceful path — the helper asking the guest to power down, the guest
syncing and powering off — needs a listener in the image and is worth doing
the next time the image is rebuilt.
- **Image upgrades are manual.** Reset re-provisions from the current manifest,
but nothing surfaces that a newer image exists, so guests stay stale until
the user happens to reset.
7 changes: 7 additions & 0 deletions packages/codev-sandbox/src/boot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ test("boot env mirrors the WSL sidecar contract and carries the server address",
CODEV_CLIENT: "desktop",
CODEV_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
XDG_STATE_HOME: "/home/codev/.local/state",
// Without this the guest files its database under the host build's
// channel — a dev build's channel is the host repo's branch — so every
// branch switch hid the sandbox's sessions behind a fresh database.
CODEV_DISABLE_CHANNEL_DB: "1",
// Stopping a sandbox cuts the VM's power, so guest commits have to reach
// the disk rather than the guest's page cache.
CODEV_SQLITE_SYNCHRONOUS: "FULL",
})
})

Expand Down
24 changes: 24 additions & 0 deletions packages/codev-sandbox/src/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,30 @@ export function sandboxBootEnv(
CODEV_CLIENT: "desktop",
CODEV_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
XDG_STATE_HOME: `${guestHome}/.local/state`,
// One database for the sandbox, whatever the host is doing. A dev build
// takes its channel from `git branch --show-current` (packages/script),
// and the server files its database as `opencode-<channel>.db` for any
// channel that is not a release one — so the guest, which runs the host's
// bundle, quietly switched to a different database every time the host
// repo changed branch, and every session in the sandbox vanished from the
// UI. Nothing was lost, but nothing could be reached either. The guest is
// its own machine and has no business tracking the host's branch.
//
// Packaged builds already resolve to the same `opencode.db` (their channel
// is a release one), so this only changes what a dev build does. It also
// means one guest database shared across branches: a branch that adds a
// migration migrates it for the others, which is the trade upstream avoids
// on the host and which is worth making here.
CODEV_DISABLE_CHANNEL_DB: "1",
// Commit to the disk, not to the guest's page cache. Stopping a sandbox
// cuts the VM's power (Virtualization.framework's stop is documented as
// pulling the cable, and the guest gets no chance to sync), so the
// server's default WAL + synchronous=NORMAL — durable across a crashed
// process, not across a lost machine — dropped every session created in
// the last seconds before a stop, a restart to apply a mount, or a force
// quit. Measured: a file written in the guest and left unsynced is gone
// after a plain Stop/Start.
CODEV_SQLITE_SYNCHRONOUS: "FULL",
}
}

Expand Down
21 changes: 20 additions & 1 deletion packages/codev-sandbox/src/broker.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { dirname, join } from "node:path"
import { createBrokerServer, forwardableRequestHeaders, parseBrokerRules, resolveBrokerTarget } from "./broker"
Expand Down Expand Up @@ -176,3 +176,22 @@ test("reports upstream failures as 502 instead of hanging", async () => {
expect(body.error).toBe("sandbox egress upstream failed")
await broker.close()
})

test("binds over a socket a crash left behind, but not one still in use", async () => {
const dir = await mkdtemp(join(tmpdir(), "codev-broker-"))
const path = join(dir, "broker.sock")
await writeFile(path, "")

const broker = createBrokerServer({ routes: [] })
await broker.listenUnix(path)
await broker.close()

// A socket with a live listener belongs to another instance: leave it alone
// and let the bind fail rather than taking the sandbox out from under it.
const live = createBrokerServer({ routes: [] })
await live.listenUnix(path)
const second = createBrokerServer({ routes: [] })
await expect(second.listenUnix(path)).rejects.toThrow()
await live.close()
await rm(dir, { recursive: true, force: true })
})
24 changes: 24 additions & 0 deletions packages/codev-sandbox/src/broker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { createServer } from "node:http"
import { rm } from "node:fs/promises"
import { connect } from "node:net"
import { Readable } from "node:stream"

// The broker is the sandbox's only egress path. The guest has no network
Expand Down Expand Up @@ -161,6 +163,15 @@ export function createBrokerServer(options: { routes: BrokerRoute[]; fetchImpl?:
// For bwrap guests the broker listens on a unix socket bind-mounted into
// the sandbox; unix sockets cross the guest's network namespace.
async listenUnix(path: string) {
// A socket file outlives the process that bound it. Node unlinks it on a
// clean close, so what is left here is the residue of a crash or a force
// quit — and binding onto it fails EADDRINUSE, which left the sandbox
// unable to start at all until someone deleted a file out of the temp
// directory. (The vz helper already unlinks its own; this one is bound
// here.) Only remove it when nothing answers: a socket with a listener
// behind it belongs to another instance of this sandbox, and failing
// loudly is the right outcome there.
if (await socketIsStale(path)) await rm(path, { force: true })
await new Promise<void>((resolve, reject) => {
server.once("error", reject)
server.listen(path, () => resolve())
Expand All @@ -171,3 +182,16 @@ export function createBrokerServer(options: { routes: BrokerRoute[]; fetchImpl?:
},
}
}

function socketIsStale(path: string) {
return new Promise<boolean>((resolve) => {
const probe = connect(path)
const settle = (stale: boolean) => {
probe.destroy()
resolve(stale)
}
probe.once("connect", () => settle(false))
// Refused (nothing listening) or absent — either way binding is safe.
probe.once("error", () => settle(true))
})
}
13 changes: 12 additions & 1 deletion packages/core/src/database/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const layer = Layer.effect(
const db = yield* makeDatabase

yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run(`PRAGMA synchronous = ${synchronous()}`)
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA foreign_keys = ON")
Expand All @@ -40,6 +40,17 @@ export function layerFromPath(filename: string) {
return layer.pipe(Layer.provide(sqliteLayer({ filename })))
}

// WAL + NORMAL survives a process crash but not the machine going away: those
// commits are only in the page cache until the next checkpoint. That is the
// normal case for a sandbox guest, whose stop cuts the VM's power, so those
// guests ask for FULL through this. Whitelisted because it lands in a PRAGMA.
const SYNCHRONOUS = new Set(["OFF", "NORMAL", "FULL", "EXTRA"])

function synchronous() {
const requested = Flag.CODEV_SQLITE_SYNCHRONOUS?.toUpperCase()
return requested && SYNCHRONOUS.has(requested) ? requested : "NORMAL"
}

export function path() {
if (Flag.CODEV_DB) {
if (Flag.CODEV_DB === ":memory:" || isAbsolute(Flag.CODEV_DB)) return Flag.CODEV_DB
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const Flag = {
CODEV_MODELS_URL: process.env["CODEV_MODELS_URL"],
CODEV_MODELS_PATH: process.env["CODEV_MODELS_PATH"],
CODEV_DB: process.env["CODEV_DB"],
CODEV_SQLITE_SYNCHRONOUS: process.env["CODEV_SQLITE_SYNCHRONOUS"],

CODEV_WORKSPACE_ID: process.env["CODEV_WORKSPACE_ID"],
CODEV_EXPERIMENTAL_WORKSPACES: enabledByExperimental("CODEV_EXPERIMENTAL_WORKSPACES"),
Expand Down
Loading