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'); +}