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
11 changes: 11 additions & 0 deletions .changeset/preview-url-survives-restart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@cloudflare/sandbox': patch
---

Keep preview URLs working across container restarts. Previously, `exposePort()`
succeeded but preview requests returned `410 STALE_PREVIEW_URL` after any
container restart (including `exec` calls that woke a stopped container),
because the runtime-scoped activation was cleared on stop and only re-created by
a manual `exposePort()` call. Exposed ports are now automatically reactivated
for the new runtime when the container starts, so a preview URL keeps forwarding
without re-exposing. Ports that were unexposed or destroyed are not resurrected.
39 changes: 34 additions & 5 deletions packages/sandbox/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2876,7 +2876,8 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
override async onStart() {
this.logger.debug('Sandbox started');

await this.currentRuntime.markStarted();
const runtime = await this.currentRuntime.markStarted();
await this.reactivateExposedPortsForRuntime(runtime);

// Fire-and-forget: version check is observability, not load-bearing.
this.checkVersionCompatibility().catch((error) => {
Expand Down Expand Up @@ -2972,6 +2973,34 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
await this.ctx.storage.delete(ACTIVE_PREVIEW_PORTS_STORAGE_KEY);
}

/**
* Re-bind durable preview-port authorizations to the freshly started
* runtime.
*
* Preview-URL auth (`portTokens`) survives container restarts, but the
* runtime-scoped activation is cleared on stop. Without re-deriving it on
* start, every exposed port returns STALE_PREVIEW_URL after any restart —
* including exec/containerFetch calls that wake a stopped container — until
* the caller manually re-exposes the port.
*
* Runs inside onStart so it only reactivates ports for a container that is
* already starting: it never wakes a stopped runtime, and it never
* resurrects unexposed or destroyed ports, which are absent from
* `portTokens`.
*/
private async reactivateExposedPortsForRuntime(
runtime: RuntimeIdentity
): Promise<void> {
await this.ctx.storage.transaction(async (txn) => {
const tokens = await this.readPortTokens(txn);
const activations: PreviewPortActivations = {};
for (const [port, entry] of Object.entries(tokens)) {
activations[port] = runtime.scope({ token: entry.token });
}
await this.writeActivePreviewPorts(activations, txn);
});
}

/**
* Check if the container version matches the SDK version
* Logs a warning if there's a mismatch
Expand Down Expand Up @@ -5017,10 +5046,10 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
/**
* Expose a port and get a preview URL for accessing services running in the sandbox
*
* Preview URL authorization survives transient container restarts, but
* forwarding is active only for the runtime where `exposePort()` was last
* called. Call `exposePort()` again after a restart to reactivate an
* existing URL for the current runtime.
* Preview URL authorization and forwarding survive container restarts:
* exposed ports are automatically reactivated for the new runtime when the
* container starts, so the URL keeps forwarding without re-exposing. Call
* `unexposePort()` to revoke a URL.
*
* @param port - Port number to expose (1024-65535)
* @param options - Configuration options
Expand Down
152 changes: 145 additions & 7 deletions packages/sandbox/tests/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1943,7 +1943,7 @@ describe('Sandbox - Automatic Session Management', () => {
await sandbox.setSandboxName('test-sandbox', false);
});

it('onStart() marks a new current runtime without restoring saved ports', async () => {
it('onStart() marks a new current runtime and reactivates exposed ports for it', async () => {
vi.mocked(mockCtx.storage!.get).mockImplementation(async (key) =>
key === 'portTokens'
? {
Expand All @@ -1954,15 +1954,32 @@ describe('Sandbox - Automatic Session Management', () => {

await (sandbox as any).onStart();

expect(mockCtx.storage.put).toHaveBeenCalledWith(
'currentRuntimeIdentity',
expect.objectContaining({
id: expect.any(String)
})
);
const runtimePut = vi
.mocked(mockCtx.storage.put)
.mock.calls.find(([key]) => key === 'currentRuntimeIdentity');
expect(runtimePut).toBeDefined();
const newRuntimeId = (runtimePut?.[1] as { id: string }).id;
expect(newRuntimeId).toEqual(expect.any(String));

// Durable port auth is re-bound to the freshly started runtime so the
// preview URL keeps forwarding without a manual re-expose.
expect(mockCtx.storage.put).toHaveBeenCalledWith('activePreviewPorts', {
'8080': { runtimeIdentityID: newRuntimeId, token: 'tok8080' }
});
expect(sandbox.client.utils.createSession).not.toHaveBeenCalled();
});

it('onStart() does not write preview activations when no ports are exposed', async () => {
vi.mocked(mockCtx.storage!.get).mockImplementation(async () => null);

await (sandbox as any).onStart();

expect(mockCtx.storage.put).not.toHaveBeenCalledWith(
'activePreviewPorts',
expect.anything()
);
});

it('onStop() preserves durable auth and clears runtime-scoped preview state', async () => {
await (sandbox as any).onStop();

Expand Down Expand Up @@ -2202,6 +2219,127 @@ describe('Sandbox - Automatic Session Management', () => {
});
});

// Regression test for https://github.com/cloudflare/sandbox-sdk/issues/829:
// exposePort() used to succeed while every request to the preview URL
// returned 410 STALE_PREVIEW_URL after a container restart (which rotates
// the runtime identity). Exposed ports are now reactivated for the new
// runtime on start, so forwarding survives restarts.
describe('preview URL survives container restart (issue #829)', () => {
let storage: Map<string, unknown>;

beforeEach(async () => {
storage = new Map<string, unknown>([
['portTokens', {}],
['currentRuntimeIdentity', { id: 'runtime-1' }],
['activePreviewPorts', {}]
]);
mockCtx.storage.get.mockImplementation(
async (key: string) => storage.get(key) ?? null
);
mockCtx.storage.put.mockImplementation(async (key: string, value) => {
storage.set(key, value);
});
mockCtx.storage.delete.mockImplementation(async (key: string) => {
storage.delete(key);
});
await sandbox.setSandboxName('test-sandbox', false);
});

it('forwards preview traffic while the exposing runtime is still current', async () => {
await sandbox.exposePort(8080, {
hostname: 'example.com',
token: PREVIEW_TEST_TOKEN
});

mockCtx.container.running = true;
const tcpFetch = vi
.fn()
.mockResolvedValue(new Response('ok', { status: 200 }));
mockCtx.container.getTcpPort = vi
.fn()
.mockReturnValue({ fetch: tcpFetch });

const response = await sandbox.fetch(createPreviewProxyRequest());

expect(response.status).toBe(200);
expect(tcpFetch).toHaveBeenCalledTimes(1);
});

it('keeps forwarding preview traffic after a container restart without re-exposing', async () => {
await sandbox.exposePort(8080, {
hostname: 'example.com',
token: PREVIEW_TEST_TOKEN
});
expect(
(
storage.get('activePreviewPorts') as Record<
string,
{ runtimeIdentityID: string }
>
)['8080'].runtimeIdentityID
).toBe('runtime-1');

// Simulate a container (re)start. onStart() mints a brand-new runtime
// identity via markStarted() and re-binds the durable exposed-port auth
// to it. This runs on every container start, including when an
// exec/containerFetch call wakes a stopped container.
await (sandbox as Sandbox & { onStart(): Promise<void> }).onStart();

const runtimeAfter = storage.get('currentRuntimeIdentity') as {
id: string;
};
expect(runtimeAfter.id).not.toBe('runtime-1');
// The activation is reactivated for the new runtime.
expect(
(
storage.get('activePreviewPorts') as Record<
string,
{ runtimeIdentityID: string; token: string }
>
)['8080']
).toEqual({
runtimeIdentityID: runtimeAfter.id,
token: PREVIEW_TEST_TOKEN
});

// Container is healthy (mock getState() -> 'healthy') and running.
mockCtx.container.running = true;
const tcpFetch = vi
.fn()
.mockResolvedValue(new Response('ok', { status: 200 }));
mockCtx.container.getTcpPort = vi
.fn()
.mockReturnValue({ fetch: tcpFetch });

const response = await sandbox.fetch(createPreviewProxyRequest());

expect(response.status).toBe(200);
expect(tcpFetch).toHaveBeenCalledTimes(1);
});

it('does not resurrect a port that was unexposed before the restart', async () => {
await sandbox.exposePort(8080, {
hostname: 'example.com',
token: PREVIEW_TEST_TOKEN
});
await sandbox.unexposePort(8080);

await (sandbox as Sandbox & { onStart(): Promise<void> }).onStart();

mockCtx.container.running = true;
const tcpFetch = vi.fn();
mockCtx.container.getTcpPort = vi
.fn()
.mockReturnValue({ fetch: tcpFetch });

const response = await sandbox.fetch(createPreviewProxyRequest());

expect(response.status).toBe(404);
expect(await response.json()).toMatchObject({ code: 'INVALID_TOKEN' });
expect(tcpFetch).not.toHaveBeenCalled();
});
});

describe('tunnels lifecycle storage', () => {
function seedMixedTunnelStorage(): Array<{ key: string; value: unknown }> {
const puts: Array<{ key: string; value: unknown }> = [];
Expand Down
Loading