diff --git a/.changeset/preview-url-survives-restart.md b/.changeset/preview-url-survives-restart.md new file mode 100644 index 000000000..ccc618f2c --- /dev/null +++ b/.changeset/preview-url-survives-restart.md @@ -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. diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index 2d416d7a7..5d34c9340 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -2876,7 +2876,8 @@ export class Sandbox extends Container 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) => { @@ -2972,6 +2973,34 @@ export class Sandbox extends Container 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 { + 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 @@ -5017,10 +5046,10 @@ export class Sandbox extends Container 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 diff --git a/packages/sandbox/tests/sandbox.test.ts b/packages/sandbox/tests/sandbox.test.ts index b2397ef1d..dd9db356f 100644 --- a/packages/sandbox/tests/sandbox.test.ts +++ b/packages/sandbox/tests/sandbox.test.ts @@ -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' ? { @@ -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(); @@ -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; + + beforeEach(async () => { + storage = new Map([ + ['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 }).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 }).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 }> = [];