From e953789374cc60d91fff0923f4585ab37cbfcbda Mon Sep 17 00:00:00 2001 From: Timothy Ricks Date: Tue, 20 Oct 2026 08:39:24 -0500 Subject: [PATCH] fix: release Studio idle container cleanup in 0.1.61 Expire silent preview connections even when they never send pong, and pause hidden saved editors after pending operations settle. This prevents abandoned browser sessions from keeping hosted containers awake. Publish the same source version through desktop Studio and create-stacki. --- package-lock.json | 4 +- package.json | 2 +- packages/create-stacki/package.json | 2 +- studio/docs/cloudflare.md | 8 +- studio/package-lock.json | 4 +- studio/package.json | 2 +- .../features/collaboration/connectionLease.ts | 24 +++--- studio/src/features/session/activity.ts | 21 ++++- studio/src/features/session/useIdleSession.ts | 25 +++++- studio/src/shared/release.json | 2 +- studio/test/budget.test.ts | 34 +++++++- studio/test/connection-lease.test.ts | 82 ++++++++++++++---- studio/test/idleSession.browser.ts | 86 +++++++++++++++++++ 13 files changed, 253 insertions(+), 43 deletions(-) create mode 100644 studio/test/idleSession.browser.ts diff --git a/package-lock.json b/package-lock.json index 1b7c854f..ae07de9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stacki", - "version": "0.1.60", + "version": "0.1.61", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stacki", - "version": "0.1.60", + "version": "0.1.61", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index d64ec9bb..8f189e8a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stacki", "productName": "Stacki", - "version": "0.1.60", + "version": "0.1.61", "description": "Stacki — a visual Astro builder with live preview and publishing.", "homepage": "https://github.com/flowtricks/stacki", "main": "dist/electron/main.js", diff --git a/packages/create-stacki/package.json b/packages/create-stacki/package.json index 19689c4b..5a1db3a1 100644 --- a/packages/create-stacki/package.json +++ b/packages/create-stacki/package.json @@ -1,6 +1,6 @@ { "name": "create-stacki", - "version": "0.1.60", + "version": "0.1.61", "description": "Add Stacki Studio to an existing Astro project", "type": "module", "bin": { diff --git a/studio/docs/cloudflare.md b/studio/docs/cloudflare.md index b847dbc3..6331c474 100644 --- a/studio/docs/cloudflare.md +++ b/studio/docs/cloudflare.md @@ -10,7 +10,13 @@ source distribution does not create resources or claim that an account is config - Website dependencies are installed into the deployment image and reused when the repository lockfile matches. Changed lockfiles use a cached install fallback. Ordinary cold starts restore drafts and start Astro without reinstalling packages. - One shared basic Container; three-minute sleep timeout after connections close. -- The editor pauses after five minutes without interaction once saves and requests finish. Pausing removes the preview iframe and closes collaboration connections. Resume reconnects the draft and obtains a fresh preview login. +- The editor pauses when its tab is hidden, or after five minutes without interaction, + once saves and requests finish. Pausing removes preview iframes and closes collaboration + connections before background tabs can freeze their timers. Resume reconnects the draft + and obtains a fresh preview login. +- Connections without incoming application traffic expire after 90 seconds, including + previews and clients that never answered a heartbeat. Outgoing heartbeats cannot renew + their own lease and keep an abandoned container running. - GitHub sync checks are sequential, at most once per minute per active editor, and skipped in hidden tabs. - Collaboration broadcasts and local previews update immediately. Durable snapshots batch over two seconds; unchanged snapshots are not uploaded again. Saved is acknowledged only after persistence succeeds. - Reconnection uses exponential backoff up to a minute; edit queues are bounded. diff --git a/studio/package-lock.json b/studio/package-lock.json index d8b1105f..f237c07f 100644 --- a/studio/package-lock.json +++ b/studio/package-lock.json @@ -1,12 +1,12 @@ { "name": "@stacki/studio", - "version": "0.1.60", + "version": "0.1.61", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@stacki/studio", - "version": "0.1.60", + "version": "0.1.61", "license": "MIT", "dependencies": { "@astrojs/compiler": "^4.0.0", diff --git a/studio/package.json b/studio/package.json index fc6f7771..2909cbdf 100644 --- a/studio/package.json +++ b/studio/package.json @@ -1,6 +1,6 @@ { "name": "@stacki/studio", - "version": "0.1.60", + "version": "0.1.61", "type": "module", "license": "MIT", "engines": { diff --git a/studio/src/features/collaboration/connectionLease.ts b/studio/src/features/collaboration/connectionLease.ts index 8b005972..59f82adb 100644 --- a/studio/src/features/collaboration/connectionLease.ts +++ b/studio/src/features/collaboration/connectionLease.ts @@ -1,5 +1,5 @@ import type { IncomingMessage } from 'node:http'; -import { WebSocket, type RawData } from 'ws'; +import { WebSocket } from 'ws'; export function reauthorizeConnection( request: IncomingMessage, @@ -33,8 +33,10 @@ export function maintainConnection( authorize?: () => Promise, options: { readonly checkEvery?: number; readonly pingEvery?: number } = {}, ) { - let alive = true; - let supportsHeartbeat = false; + const pingEveryMs = options.pingEvery ?? 30_000; + // Allow three heartbeat intervals for browser throttling and network jitter. + const silenceMsMax = pingEveryMs * 3; + let receivedAt = Date.now(); let lease: ReturnType | undefined; const check = async () => { try { @@ -52,25 +54,23 @@ export function maintainConnection( if (authorize) { lease = setTimeout(() => void check(), options.checkEvery ?? 270_000); } - const received = (data: RawData) => { - alive = true; - if (String(data) === '{"type":"pong"}') { - supportsHeartbeat = true; - } + const received = () => { + receivedAt = Date.now(); }; // Protocol ping/pong terminates at Cloudflare's proxy. Application frames - // exercise every hop through to the editor and renew proxy activity. + // exercise every hop. Vite sends ping frames instead of replying with pong. + // Never exempt clients that have not replied: an orphaned preview otherwise + // renews the container's activity forever through our outgoing heartbeats. socket.on('message', received); const heartbeat = setInterval(() => { - if (!alive && supportsHeartbeat) { + if (Date.now() - receivedAt >= silenceMsMax) { socket.terminate(); return; } - alive = false; if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: 'ping' })); } - }, options.pingEvery ?? 30_000); + }, pingEveryMs); socket.once('close', () => { clearTimeout(lease); clearInterval(heartbeat); diff --git a/studio/src/features/session/activity.ts b/studio/src/features/session/activity.ts index 9c0ff269..4fb8b4a0 100644 --- a/studio/src/features/session/activity.ts +++ b/studio/src/features/session/activity.ts @@ -1,9 +1,28 @@ // Read by the idle timer so a build, upload, or save is never interrupted. const pending = new Set(); +const settled = new Set<() => void>(); export function beginOperation() { const token = Symbol('operation'); pending.add(token); - return () => pending.delete(token); + return () => { + const removed = pending.delete(token); + if (removed && pending.size === 0) { + for (const listener of settled) { + listener(); + } + } + return removed; + }; +} +export function onOperationsSettled(listener: () => void) { + // Only mounted editor sessions subscribe; cleanup releases each registration. + if (settled.size >= 30) { + throw new Error('Too many idle session listeners'); + } + settled.add(listener); + return () => { + settled.delete(listener); + }; } export function hasPendingOperations() { return pending.size > 0; diff --git a/studio/src/features/session/useIdleSession.ts b/studio/src/features/session/useIdleSession.ts index c2bb04eb..849e150e 100644 --- a/studio/src/features/session/useIdleSession.ts +++ b/studio/src/features/session/useIdleSession.ts @@ -1,11 +1,16 @@ import { useEffect, useRef, useState } from 'react'; -import { hasPendingOperations, mayPause } from './activity'; +import { hasPendingOperations, mayPause, onOperationsSettled } from './activity'; export function useIdleSession(canPause: boolean) { const [paused, setPaused] = useState(false); const lastActivity = useRef(Date.now()); const safe = useRef(canPause); safe.current = canPause; + useEffect(() => { + if (canPause && document.hidden && !hasPendingOperations()) { + setPaused(true); + } + }, [canPause]); useEffect(() => { const activity = () => { lastActivity.current = Date.now(); @@ -14,13 +19,27 @@ export function useIdleSession(canPause: boolean) { for (const event of events) { window.addEventListener(event, activity, { passive: true }); } - const timer = setInterval(() => { + const check = () => { + if (!safe.current || hasPendingOperations()) { + return; + } + // Hidden tabs can freeze their timers while proxy sockets stay alive. + // Release connections before freezing, but only after work is saved. + if (document.hidden) { + setPaused(true); + return; + } if (mayPause(lastActivity.current, Date.now(), !safe.current || hasPendingOperations())) { setPaused(true); } - }, 10_000); + }; + document.addEventListener('visibilitychange', check); + const stopSettled = onOperationsSettled(check); + const timer = setInterval(check, 10_000); return () => { clearInterval(timer); + stopSettled(); + document.removeEventListener('visibilitychange', check); for (const event of events) { window.removeEventListener(event, activity); } diff --git a/studio/src/shared/release.json b/studio/src/shared/release.json index 22d3a816..21930915 100644 --- a/studio/src/shared/release.json +++ b/studio/src/shared/release.json @@ -1,3 +1,3 @@ { - "version": "0.1.60" + "version": "0.1.61" } diff --git a/studio/test/budget.test.ts b/studio/test/budget.test.ts index 93976229..84d5d1d4 100644 --- a/studio/test/budget.test.ts +++ b/studio/test/budget.test.ts @@ -1,7 +1,14 @@ +// Goal: save batching and idle controls bound hosting use without dropping pending work. +// Method: exercise persistence failures, operation completion, and idle-time boundaries. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { BatchedWriter } from '../src/features/storage/batchedWriter'; -import { beginOperation, hasPendingOperations, mayPause } from '../src/features/session/activity'; +import { + beginOperation, + hasPendingOperations, + mayPause, + onOperationsSettled, +} from '../src/features/session/activity'; test('many edits share one durable snapshot and acknowledge only after storage succeeds', async () => { const writes: Uint8Array[] = []; @@ -72,6 +79,31 @@ test('idle sessions pause after five minutes, but pending work prevents pause', assert.equal(hasPendingOperations(), false); }); +test('hidden sessions can pause after the last operation without waiting for a browser timer', () => { + let calls = 0; + const stop = onOperationsSettled(() => { + assert.equal(hasPendingOperations(), false); + calls++; + }); + const first = beginOperation(); + const second = beginOperation(); + try { + first(); + assert.equal(calls, 0, 'the remaining save still prevents pause'); + second(); + assert.equal(calls, 1, 'settling the final operation wakes the idle check'); + second(); + assert.equal(calls, 1, 'repeated cleanup does not emit another completion'); + stop(); + beginOperation()(); + assert.equal(calls, 1, 'unmounted sessions no longer receive completions'); + } finally { + first(); + second(); + stop(); + } +}); + test('asset totals reject uploads before durable storage grows past the allowance', async () => { const { mkdtemp, mkdir, writeFile, truncate, rm } = await import('node:fs/promises'); const { tmpdir } = await import('node:os'); diff --git a/studio/test/connection-lease.test.ts b/studio/test/connection-lease.test.ts index 8210608d..eeff6acc 100644 --- a/studio/test/connection-lease.test.ts +++ b/studio/test/connection-lease.test.ts @@ -1,3 +1,5 @@ +// Goal: live peers remain connected, while revoked and abandoned peers release compute. +// Method: drive real WebSocket pairs with bounded heartbeat intervals and deadlines. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { once } from 'node:events'; @@ -77,21 +79,67 @@ async function connectedPair() { return { server, client, socket }; } -test('a client that stops responding is disconnected even when the transport remains open', async () => { - const { server, client, socket } = await connectedPair(); - client.once('message', () => client.send(JSON.stringify({ type: 'pong' }))); - maintainConnection(socket, undefined, { pingEvery: 15 }); - await once(client, 'close'); - assert.equal(client.readyState, WebSocket.CLOSED); - await new Promise((resolve) => server.close(() => resolve())); -}); +test( + 'a client that stops responding is disconnected even when the transport remains open', + { + timeout: 5000, + }, + async () => { + const { server, client, socket } = await connectedPair(); + client.once('message', () => client.send(JSON.stringify({ type: 'pong' }))); + maintainConnection(socket, undefined, { pingEvery: 15 }); + await once(client, 'close'); + assert.equal(client.readyState, WebSocket.CLOSED); + await new Promise((resolve) => server.close(() => resolve())); + }, +); -test('an editor opened before heartbeat support can remain connected until it refreshes', async () => { - const { server, client, socket } = await connectedPair(); - maintainConnection(socket, undefined, { pingEvery: 10 }); - await delay(60); - assert.equal(client.readyState, WebSocket.OPEN); - client.close(); - await once(client, 'close'); - await new Promise((resolve) => server.close(() => resolve())); -}); +test( + 'a client that never replies cannot keep a container awake indefinitely', + { + timeout: 5000, + }, + async () => { + const { server, client, socket } = await connectedPair(); + let pings = 0; + client.on('message', () => pings++); + try { + maintainConnection(socket, undefined, { pingEvery: 30 }); + await once(client, 'close'); + assert.equal(client.readyState, WebSocket.CLOSED); + assert.ok(pings <= 3, 'outgoing heartbeats cannot renew their own lease'); + } finally { + client.terminate(); + socket.terminate(); + await new Promise((resolve) => server.close(() => resolve())); + } + }, +); + +test( + 'Vite ping messages renew the lease without a document-style pong response', + { + timeout: 5000, + }, + async () => { + const { server, client, socket } = await connectedPair(); + let pings = 0; + client.on('message', () => { + pings++; + client.send(JSON.stringify({ type: 'ping' })); + }); + try { + maintainConnection(socket, undefined, { pingEvery: 100 }); + const deadline = Date.now() + 3000; + while (pings < 5 && Date.now() < deadline) { + await delay(25); + } + assert.ok(pings >= 5, 'preview traffic survives beyond the initial lease'); + assert.equal(client.readyState, WebSocket.OPEN); + } finally { + client.terminate(); + socket.terminate(); + await new Promise((resolve) => server.close(() => resolve())); + } + }, +); diff --git a/studio/test/idleSession.browser.ts b/studio/test/idleSession.browser.ts new file mode 100644 index 00000000..9394ae68 --- /dev/null +++ b/studio/test/idleSession.browser.ts @@ -0,0 +1,86 @@ +// Goal: idle or hidden editors release all sockets without interrupting pending requests. +// Method: run the real draft editor, hold a sync request, and observe socket close events. +import assert from 'node:assert/strict'; +import { chromium, type Page, type WebSocket } from 'playwright'; + +const browser = await chromium.launch({ + executablePath: + process.env['CHROME_PATH'] ?? + (process.platform === 'darwin' + ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' + : chromium.executablePath()), + headless: true, +}); +const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); +page.setDefaultTimeout(20_000); +const sockets = new Set(); +page.on('websocket', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); +}); +let release = () => {}; +try { + await page.clock.install(); + let syncStarted = false; + const pending = new Promise((resolve) => { + release = resolve; + }); + await page.route('**/studio/api/sync', async (route) => { + syncStarted = true; + await pending; + await route.continue(); + }); + await page.goto(process.env['STUDIO_URL'] ?? 'http://localhost:4350/studio', { + waitUntil: 'domcontentloaded', + }); + await ready(page); + assert.ok(sockets.size >= 2, 'the real document and preview sockets are connected'); + for (let attempt = 0; attempt < 100 && !syncStarted; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.ok(syncStarted, 'the real sync request is in flight'); + await visibility(page, 'hidden'); + assert.equal(await page.getByRole('button', { name: 'Resume editing' }).count(), 0); + assert.ok(sockets.size >= 2, 'pending work keeps the connections available'); + release(); + await page.getByRole('button', { name: 'Resume editing' }).waitFor(); + await released(page, sockets); + await visibility(page, 'visible'); + await page.getByRole('button', { name: 'Resume editing' }).click(); + await ready(page); + assert.ok(sockets.size >= 2, 'resuming reconnects document and preview'); + await visibility(page, 'hidden'); + await page.getByRole('button', { name: 'Resume editing' }).waitFor(); + await released(page, sockets); + await visibility(page, 'visible'); + await page.getByRole('button', { name: 'Resume editing' }).click(); + await ready(page); + await page.clock.fastForward('05:20'); + await page.getByRole('button', { name: 'Resume editing' }).waitFor(); + await released(page, sockets); + console.log('Hidden and idle editors release all sockets, defer for pending work, and resume.'); +} finally { + release(); + await browser.close(); +} + +async function ready(page: Page) { + await page.locator('.save-state').filter({ hasText: 'Saved' }).waitFor(); + await page.frameLocator('iframe[title="Site preview"]').locator('h1').waitFor(); +} + +async function visibility(page: Page, state: 'hidden' | 'visible') { + // Headless pages stay visible; deliver the browser's visibility transition explicitly. + await page.evaluate((state) => { + Object.defineProperty(document, 'hidden', { configurable: true, value: state === 'hidden' }); + document.dispatchEvent(new Event('visibilitychange')); + }, state); +} + +async function released(page: Page, sockets: ReadonlySet) { + assert.equal(await page.locator('iframe').count(), 0, 'paused previews are unloaded'); + for (let attempt = 0; attempt < 100 && sockets.size > 0; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.equal(sockets.size, 0, 'all proxied sockets close so the container can sleep'); +}