From 9e7bf172ad45190fa5dd1111ea9291a8d6e2755c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 21 Jul 2026 12:32:03 +0000 Subject: [PATCH] fix: bound Playground custom archive cache --- README.md | 2 + package.json | 1 + packages/runtime-playground/src/index.ts | 1 + .../src/playground-cli-runner.ts | 83 ++--- .../src/playground-wordpress-archive-cache.ts | 346 ++++++++++++++++++ tests/playground-custom-archive-cache.test.ts | 134 +++++++ 6 files changed, 519 insertions(+), 48 deletions(-) create mode 100644 packages/runtime-playground/src/playground-wordpress-archive-cache.ts create mode 100644 tests/playground-custom-archive-cache.test.ts diff --git a/README.md b/README.md index 7b1679d7..883d503c 100644 --- a/README.md +++ b/README.md @@ -519,6 +519,8 @@ kimaki tunnel -- sh -c 'npm run wp-codebox -- run \ When a caller exposes the local Playground through a tunnel or proxy, pass `--preview-public-url ` to report that public URL in `artifacts.preview.url`, `metadata.json`, and `files/review.json`. WP Codebox also passes the same URL to Playground as `site-url` and defines `WP_HOME` / `WP_SITEURL` in the sandbox config, so WordPress-generated links and canonical redirects align with the public preview URL. The local proxy URL remains recorded as `preview.localUrl`. If the fixed port is already occupied, WP Codebox fails clearly with `EADDRINUSE` and the requested `--preview-port` value. +Playground's content-addressed `custom-*.zip` WordPress archives are retained separately from stable release archives and cleaned after runtime use. The custom cache defaults to a seven-day age limit, 1 GiB, and 20 archives. Configure those bounds with `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_MAX_AGE_MS`, `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_MAX_BYTES`, and `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_MAX_COUNT`; configure crashed legacy lock recovery with `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_STALE_LOCK_MS`. All maintenance uses `WP_CODEBOX_PLAYGROUND_WORDPRESS_CACHE_DIR` when set. The Playground runtime package exports `maintainPlaygroundCustomArchiveCache()` for deterministic `diagnose`, `dry-run`, and `apply` evidence. + Remote-host previews can opt into `--preview-bind ` with `--preview-port`. The flag changes the WP Codebox preview proxy bind address only; the upstream runtime server remains loopback-bound until backend bind-host control is available. The default stays `127.0.0.1`. Use `--preview-bind 0.0.0.0` only behind trusted firewall, tunnel, or reverse-proxy controls because the sandbox preview is reachable for the hold duration. ## Runtime Episodes diff --git a/package.json b/package.json index ad3a52ed..a43b07fd 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "test:playground-readonly-mounts-integration": "tsx tests/playground-readonly-mounts-integration.test.ts", "test:playground-phpunit-readonly-cache-integration": "tsx tests/playground-phpunit-readonly-cache.integration.test.ts", "test:playground-phpunit-bootstrap-failure-integration": "tsx tests/playground-phpunit-bootstrap-failure.integration.test.ts", + "test:playground-custom-archive-cache": "tsx tests/playground-custom-archive-cache.test.ts", "test:phpunit-runtime-failure-diagnostics": "tsx tests/phpunit-runtime-failure-diagnostics.test.ts", "test:php-wasm-extension-manifests": "tsx tests/php-wasm-extension-manifests.test.ts", "test:browser-task-builder": "tsx tests/browser-task-builder.test.ts", diff --git a/packages/runtime-playground/src/index.ts b/packages/runtime-playground/src/index.ts index bf9b8995..bd703e8b 100644 --- a/packages/runtime-playground/src/index.ts +++ b/packages/runtime-playground/src/index.ts @@ -11,6 +11,7 @@ export { runBrowserMultiActorScenarioCommand } from "./browser-multi-actor-scena export { browserStorageStateCookieHostSummary, browserStorageStateFromWordPressAuthCookies, normalizeBrowserStorageStatePayload, wordpressFixtureUserStorageStatePhpCode, type BrowserAuthStorageState, type BrowserStorageStateCookie, type BrowserStorageStateImportResult, type BrowserStorageStateImportSummary, type WordPressFixtureUserSpec, type WordPressFixtureUserStorageStateEnvelope } from "./browser-auth-storage-state.js" export { createHostCommandTool, type HostCommandToolConfig } from "./host-command-tool.js" export { PlaygroundRuntimeBackend, createPlaygroundRuntimeBackend, playgroundRuntimeBackendProvider } from "./playground-runtime.js" +export { maintainPlaygroundCustomArchiveCache, playgroundWordPressArchiveCacheDirectory, type PlaygroundCustomArchiveCacheMaintenance, type PlaygroundCustomArchiveCacheMaintenanceOptions, type PlaygroundCustomArchiveCachePolicy } from "./playground-wordpress-archive-cache.js" export { collectBrowserArtifactMetrics, collectWordPressEpisodeArtifacts, collectWordPressRuntimeArtifacts, createWordPressEpisode, createWordPressRuntime, runWordPressEpisodeActions, type WordPressEpisodeSpec, type WordPressRuntimeActionHooks, type WordPressRuntimeSpec } from "./public.js" export { preflightPhpWasmRuntimeAssets, PhpWasmRuntimeAssetIntegrityError, type PhpWasmRuntimeAssetPreflight, type PhpWasmRuntimeAssetPreflightOptions } from "./php-wasm-preflight.js" export { browserPreviewAuthCookieUrls, browserPreviewNetworkPolicySummary, browserPreviewReadinessError, browserPreviewRouting, browserPreviewSecureContextError, browserPreviewTopology, browserPreviewOrigins, resolveBrowserPreviewUrl, type BrowserPreviewNetworkPolicy, type BrowserPreviewTopology } from "./browser-preview-routing.js" diff --git a/packages/runtime-playground/src/playground-cli-runner.ts b/packages/runtime-playground/src/playground-cli-runner.ts index 3e90f9e9..47b77fa1 100644 --- a/packages/runtime-playground/src/playground-cli-runner.ts +++ b/packages/runtime-playground/src/playground-cli-runner.ts @@ -6,14 +6,14 @@ import { normalizeLiveProgressEvent, previewLease, type BrowserStartupProgressEv import { randomInt } from "node:crypto" import { existsSync } from "node:fs" import { createServer as createHttpServer, type Server as HttpServer } from "node:http" -import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises" -import { homedir } from "node:os" +import { mkdir, readFile, rename, rm, stat, unlink, utimes, writeFile } from "node:fs/promises" import { basename, dirname, join } from "node:path" import { createServer as createNetServer } from "node:net" import * as PlaygroundStorage from "@wp-playground/storage" import { resolveWordPressRelease } from "@wp-playground/wordpress" import { phpEnvAssignments, phpWpConfigDefineAssignments } from "./php-snippets.js" import { stageReadonlyPlaygroundMounts, type ReadonlyMountStaging } from "./mount-materialization.js" +import { acquirePlaygroundArchiveReference, isCustomPlaygroundWordPressArchive, maintainPlaygroundCustomArchiveCache, playgroundWordPressArchiveCacheDirectory, withPlaygroundArchiveCacheLock, type PlaygroundArchiveReference, type PlaygroundCustomArchiveCacheMaintenance } from "./playground-wordpress-archive-cache.js" const DEFAULT_RUNTIME_PHP_INI_ENTRIES = { memory_limit: "512M" } @@ -38,8 +38,6 @@ export interface PlaygroundCliModule { }): Promise } -const PLAYGROUND_WORDPRESS_CACHE_DIRECTORY_ENV = "WP_CODEBOX_PLAYGROUND_WORDPRESS_CACHE_DIR" - export interface PlaygroundCliStartupOptions { onProgress?: (event: BrowserStartupProgressEvent) => void | Promise cliModule?: PlaygroundCliModule @@ -66,6 +64,9 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: }) emitProgress("preview:loading-client", "running", "Loading preview") let readonlyMountStaging: ReadonlyMountStaging | undefined + let archiveReference: PlaygroundArchiveReference | undefined + let cacheMaintenance: PlaygroundCustomArchiveCacheMaintenance | undefined + let usesArchiveCache = false try { if (spec.preview?.port) { await assertPreviewPortAvailable(spec.preview.port) @@ -78,15 +79,23 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: const wordpressInstallMode = spec.environment.wordpressInstallMode ?? "install-from-existing-files" const bootstrapIniEntries = runtimeBootstrapPhpIniEntries(spec) const useProgrammaticRunner = shouldUseProgrammaticPlaygroundRunner(spec, options) + usesArchiveCache = !wordpressDirectory && !spec.environment.assets?.wordpressZip readonlyMountStaging = await stageReadonlyPlaygroundMounts(mounts) const stagedMounts = readonlyMountStaging.mounts const wordpressStartupAsset = wordpressDirectory ? undefined : await resolvePlaygroundWordPressStartupAsset(spec.environment.version, spec.environment.assets?.wordpressZip) + archiveReference = wordpressStartupAsset?.archiveReference + if (usesArchiveCache) { + cacheMaintenance = await maintainPlaygroundCustomArchiveCache().catch(() => undefined) + } const cacheValidation = wordpressStartupAsset?.cacheValidation ?? { version: spec.environment.version ?? "mounted-wordpress-source", sourceUrl: wordpressDirectory ?? "", source: "pre-resolved" as const, invalidArchives: [], } + if (cacheMaintenance) { + cacheValidation.retention = cacheMaintenance + } const blueprintSummary = summarizeBlueprint(spec.environment.blueprint) if (blueprintSummary.steps > 0) { emitProgress("preview:applying-blueprint", "running", "Applying site setup", blueprintSummary) @@ -168,6 +177,10 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: try { await proxiedServer[Symbol.asyncDispose]() } finally { + await archiveReference?.release().catch(() => undefined) + if (usesArchiveCache) { + await maintainPlaygroundCustomArchiveCache().catch(() => undefined) + } await readonlyMountStaging?.[Symbol.asyncDispose]() } }, @@ -177,6 +190,10 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: error: errorDetail(error), }) + await archiveReference?.release().catch(() => undefined) + if (usesArchiveCache) { + await maintainPlaygroundCustomArchiveCache().catch(() => undefined) + } await readonlyMountStaging?.[Symbol.asyncDispose]() if (spec.preview?.port && errorHasCode(error, "EADDRINUSE")) { throw new PlaygroundPreviewPortUnavailableError(spec.preview.port, error) @@ -545,13 +562,14 @@ export interface PlaygroundWordPressArchiveCacheValidation { reason: string deleted: boolean }> + retention?: PlaygroundCustomArchiveCacheMaintenance } export interface PlaygroundWordPressArchiveCacheValidationOptions { deleteInvalid?: boolean } -export async function validatePlaygroundWordPressArchiveCache(versionQuery: string | undefined, cacheDirectory = defaultPlaygroundWordPressArchiveCacheDirectory(), options: PlaygroundWordPressArchiveCacheValidationOptions = { deleteInvalid: true }): Promise { +export async function validatePlaygroundWordPressArchiveCache(versionQuery: string | undefined, cacheDirectory = playgroundWordPressArchiveCacheDirectory(), options: PlaygroundWordPressArchiveCacheValidationOptions = { deleteInvalid: true }): Promise { const release = await resolveWordPressReleaseForStartup(versionQuery) const version = release.version const sourceUrl = release.releaseUrl @@ -593,6 +611,7 @@ interface PlaygroundWordPressStartupAsset { wp: string | undefined localPath?: string cacheValidation: PlaygroundWordPressArchiveCacheValidation + archiveReference?: PlaygroundArchiveReference } interface ResolvedWordPressReleaseForStartup { @@ -603,7 +622,7 @@ interface ResolvedWordPressReleaseForStartup { type WordPressReleaseResolver = typeof resolveWordPressRelease -export async function resolvePlaygroundWordPressStartupAsset(versionQuery: string | undefined, wordpressZip?: string, cacheDirectory = defaultPlaygroundWordPressArchiveCacheDirectory()): Promise { +export async function resolvePlaygroundWordPressStartupAsset(versionQuery: string | undefined, wordpressZip?: string, cacheDirectory = playgroundWordPressArchiveCacheDirectory()): Promise { if (wordpressZip) { const version = startupAssetVersion(versionQuery, wordpressZip) const cacheValidation = await validateWordPressArchivePaths(version, wordpressZip, isHttpUrl(wordpressZip) ? [] : [wordpressZip], { deleteInvalid: false }) @@ -616,6 +635,11 @@ export async function resolvePlaygroundWordPressStartupAsset(versionQuery: strin const archivePaths = [cachedArchivePath, join(cacheDirectory, `prebuilt-wp-content-for-wp-${release.version}.zip`)] const cacheValidation = await validateWordPressArchivePaths(release.version, release.releaseUrl, archivePaths, { deleteInvalid: true }) if (existsSync(cachedArchivePath)) { + if (isCustomPlaygroundWordPressArchive(cachedArchivePath)) { + const accessedAt = new Date() + await utimes(cachedArchivePath, accessedAt, accessedAt) + } + const archiveReference = isCustomPlaygroundWordPressArchive(cachedArchivePath) ? await acquirePlaygroundArchiveReference(cachedArchivePath) : undefined return { wp: undefined, localPath: cachedArchivePath, @@ -624,6 +648,7 @@ export async function resolvePlaygroundWordPressStartupAsset(versionQuery: strin source: "cache", cache: { status: "hit", archivePath: cachedArchivePath, lockPath: lock.path, waitedMs: lock.waitedMs }, }, + archiveReference, } } @@ -634,6 +659,7 @@ export async function resolvePlaygroundWordPressStartupAsset(versionQuery: strin throw new PlaygroundStartupAssetError("wordpress-archive-cache", release.releaseUrl, versionQuery ?? "latest", new Error(`Downloaded WordPress archive is invalid: ${invalidDownloadedArchive.reason}`)) } + const archiveReference = isCustomPlaygroundWordPressArchive(cachedArchivePath) ? await acquirePlaygroundArchiveReference(cachedArchivePath) : undefined return { wp: undefined, localPath: cachedArchivePath, @@ -643,48 +669,9 @@ export async function resolvePlaygroundWordPressStartupAsset(versionQuery: strin source: release.source, cache: { status: "downloaded", archivePath: cachedArchivePath, lockPath: lock.path, waitedMs: lock.waitedMs }, }, + archiveReference, } - }) -} - -function defaultPlaygroundWordPressArchiveCacheDirectory(): string { - return process.env[PLAYGROUND_WORDPRESS_CACHE_DIRECTORY_ENV] || join(homedir(), ".wordpress-playground") -} - -interface PlaygroundArchiveCacheLock { - path: string - waitedMs: number -} - -async function withPlaygroundArchiveCacheLock(cacheDirectory: string, version: string, callback: (lock: PlaygroundArchiveCacheLock) => Promise): Promise { - await mkdir(cacheDirectory, { recursive: true }) - const lockPath = join(cacheDirectory, `${version}.zip.lock`) - const startedAt = Date.now() - - for (;;) { - try { - await mkdir(lockPath) - break - } catch (error) { - if (!errorHasCode(error, "EEXIST")) { - throw error - } - if (Date.now() - startedAt > 120_000) { - throw new PlaygroundStartupAssetError("wordpress-archive-cache-lock", lockPath, version, new Error("Timed out waiting for WordPress archive cache lock")) - } - await delay(100) - } - } - - try { - return await callback({ path: lockPath, waitedMs: Date.now() - startedAt }) - } finally { - await rm(lockPath, { recursive: true, force: true }) - } -} - -async function delay(ms: number): Promise { - await new Promise((resolveDelay) => setTimeout(resolveDelay, ms)) + }, (lockPath) => new PlaygroundStartupAssetError("wordpress-archive-cache-lock", lockPath, release.version, new Error("Timed out waiting for WordPress archive cache lock"))) } async function downloadWordPressArchiveToCache(sourceUrl: string, archivePath: string): Promise { @@ -707,7 +694,7 @@ async function downloadWordPressArchiveToCache(sourceUrl: string, archivePath: s } } -export async function resolveWordPressReleaseForStartup(versionQuery: string | undefined, cacheDirectory = defaultPlaygroundWordPressArchiveCacheDirectory(), resolver: WordPressReleaseResolver = resolveWordPressRelease): Promise { +export async function resolveWordPressReleaseForStartup(versionQuery: string | undefined, cacheDirectory = playgroundWordPressArchiveCacheDirectory(), resolver: WordPressReleaseResolver = resolveWordPressRelease): Promise { const exactVersion = exactWordPressVersion(versionQuery) if (exactVersion) { return { diff --git a/packages/runtime-playground/src/playground-wordpress-archive-cache.ts b/packages/runtime-playground/src/playground-wordpress-archive-cache.ts new file mode 100644 index 00000000..c892169a --- /dev/null +++ b/packages/runtime-playground/src/playground-wordpress-archive-cache.ts @@ -0,0 +1,346 @@ +import { randomUUID } from "node:crypto" +import { existsSync } from "node:fs" +import { mkdir, readFile, readdir, rm, stat, unlink, writeFile } from "node:fs/promises" +import { homedir } from "node:os" +import { basename, join } from "node:path" + +const CUSTOM_ARCHIVE_PATTERN = /^custom-[A-Za-z0-9._-]+\.zip$/ +const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000 +const DEFAULT_MAX_BYTES = 1024 * 1024 * 1024 +const DEFAULT_MAX_COUNT = 20 +const DEFAULT_STALE_LOCK_MS = 10 * 60 * 1_000 + +const CACHE_DIRECTORY_ENV = "WP_CODEBOX_PLAYGROUND_WORDPRESS_CACHE_DIR" +const CACHE_MAX_AGE_MS_ENV = "WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_MAX_AGE_MS" +const CACHE_MAX_BYTES_ENV = "WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_MAX_BYTES" +const CACHE_MAX_COUNT_ENV = "WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_MAX_COUNT" +const CACHE_STALE_LOCK_MS_ENV = "WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_STALE_LOCK_MS" + +interface CacheOwnerRecord { + pid: number + createdAt: string +} + +export interface PlaygroundArchiveCacheLock { + path: string + waitedMs: number +} + +export interface PlaygroundArchiveReference { + archivePath: string + path: string + release(): Promise +} + +export interface PlaygroundCustomArchiveCachePolicy { + maxAgeMs: number + maxBytes: number + maxCount: number + staleLockMs: number +} + +export interface PlaygroundCustomArchiveCacheMaintenanceOptions extends Partial { + mode?: "diagnose" | "dry-run" | "apply" + now?: number +} + +export interface PlaygroundCustomArchiveCacheMaintenance { + schema: "wp-codebox/playground-custom-archive-cache-maintenance/v1" + mode: "diagnose" | "dry-run" | "apply" + cacheDirectory: string + policy: PlaygroundCustomArchiveCachePolicy + customArchiveCount: number + customArchiveBytes: number + candidateCount: number + candidateBytes: number + activeProtection: { + count: number + bytes: number + lockCount: number + referenceCount: number + paths: string[] + } + removedCount: number + removedBytes: number + verifiedReclaimedBytes: number + retainedCount: number + retainedBytes: number +} + +interface CustomArchiveEntry { + path: string + size: number + mtimeMs: number + lockPath: string + activeLock: boolean + activeReferences: number +} + +export async function withPlaygroundArchiveCacheLock(cacheDirectory: string, version: string, callback: (lock: PlaygroundArchiveCacheLock) => Promise, timeoutError?: (lockPath: string) => Error): Promise { + await mkdir(cacheDirectory, { recursive: true }) + const lockPath = join(cacheDirectory, `${version}.zip.lock`) + const startedAt = Date.now() + + for (;;) { + if (await tryAcquireCacheLock(lockPath)) { + break + } + if (!await cacheLockIsActive(lockPath, Date.now(), customArchiveCachePolicy({}).staleLockMs, true)) { + continue + } + if (Date.now() - startedAt > 120_000) { + throw timeoutError?.(lockPath) ?? new Error(`Timed out waiting for WordPress archive cache lock: ${lockPath}`) + } + await delay(100) + } + + try { + return await callback({ path: lockPath, waitedMs: Date.now() - startedAt }) + } finally { + await rm(lockPath, { recursive: true, force: true }) + } +} + +export async function acquirePlaygroundArchiveReference(archivePath: string): Promise { + const referencesDirectory = `${archivePath}.refs` + await mkdir(referencesDirectory, { recursive: true }) + const referencePath = join(referencesDirectory, `${process.pid}-${randomUUID()}.json`) + await writeFile(referencePath, `${JSON.stringify(ownerRecord())}\n`, { flag: "wx" }) + let released = false + + return { + archivePath, + path: referencePath, + async release() { + if (released) { + return + } + released = true + await rm(referencePath, { force: true }) + }, + } +} + +export function playgroundWordPressArchiveCacheDirectory(): string { + return process.env[CACHE_DIRECTORY_ENV] || join(homedir(), ".wordpress-playground") +} + +export async function maintainPlaygroundCustomArchiveCache(cacheDirectory = playgroundWordPressArchiveCacheDirectory(), options: PlaygroundCustomArchiveCacheMaintenanceOptions = {}): Promise { + const mode = options.mode ?? "apply" + const now = options.now ?? Date.now() + const policy = customArchiveCachePolicy(options) + await mkdir(cacheDirectory, { recursive: true }) + + const archiveNames = (await readdir(cacheDirectory)).filter((name) => CUSTOM_ARCHIVE_PATTERN.test(name)).sort() + const entries: CustomArchiveEntry[] = [] + for (const name of archiveNames) { + const archivePath = join(cacheDirectory, name) + try { + const archiveStat = await stat(archivePath) + if (!archiveStat.isFile()) { + continue + } + const lockPath = `${archivePath}.lock` + const activeLock = await cacheLockIsActive(lockPath, now, policy.staleLockMs, mode === "apply") + const activeReferences = await activeArchiveReferenceCount(archivePath, mode === "apply") + entries.push({ path: archivePath, size: archiveStat.size, mtimeMs: archiveStat.mtimeMs, lockPath, activeLock, activeReferences }) + } catch (error) { + if (!errorHasCode(error, "ENOENT")) { + throw error + } + } + } + + const protectedEntries = entries.filter((entry) => entry.activeLock || entry.activeReferences > 0) + let retainedCount = protectedEntries.length + let retainedBytes = protectedEntries.reduce((total, entry) => total + entry.size, 0) + const candidates: CustomArchiveEntry[] = [] + const eligible = entries.filter((entry) => !entry.activeLock && entry.activeReferences === 0).sort(newestFirst) + + for (const entry of eligible) { + const stale = now - entry.mtimeMs > policy.maxAgeMs + const exceedsCount = retainedCount + 1 > policy.maxCount + const exceedsBytes = retainedBytes + entry.size > policy.maxBytes + if (stale || exceedsCount || exceedsBytes) { + candidates.push(entry) + } else { + retainedCount += 1 + retainedBytes += entry.size + } + } + + let removedCount = 0 + let removedBytes = 0 + let verifiedReclaimedBytes = 0 + if (mode === "apply") { + for (const candidate of candidates.sort(oldestFirst)) { + if (!await tryAcquireCacheLock(candidate.lockPath)) { + continue + } + try { + if (await activeArchiveReferenceCount(candidate.path, true) > 0) { + continue + } + let beforeSize: number + try { + beforeSize = (await stat(candidate.path)).size + } catch (error) { + if (errorHasCode(error, "ENOENT")) { + continue + } + throw error + } + await unlink(candidate.path) + if (!existsSync(candidate.path)) { + removedCount += 1 + removedBytes += beforeSize + verifiedReclaimedBytes += beforeSize + await rm(`${candidate.path}.refs`, { recursive: true, force: true }) + } + } finally { + await rm(candidate.lockPath, { recursive: true, force: true }) + } + } + } + + return { + schema: "wp-codebox/playground-custom-archive-cache-maintenance/v1", + mode, + cacheDirectory, + policy, + customArchiveCount: entries.length, + customArchiveBytes: entries.reduce((total, entry) => total + entry.size, 0), + candidateCount: candidates.length, + candidateBytes: candidates.reduce((total, entry) => total + entry.size, 0), + activeProtection: { + count: protectedEntries.length, + bytes: protectedEntries.reduce((total, entry) => total + entry.size, 0), + lockCount: protectedEntries.filter((entry) => entry.activeLock).length, + referenceCount: protectedEntries.reduce((total, entry) => total + entry.activeReferences, 0), + paths: protectedEntries.map((entry) => entry.path).sort(), + }, + removedCount, + removedBytes, + verifiedReclaimedBytes, + retainedCount: entries.length - removedCount, + retainedBytes: entries.reduce((total, entry) => total + entry.size, 0) - removedBytes, + } +} + +function customArchiveCachePolicy(options: PlaygroundCustomArchiveCacheMaintenanceOptions): PlaygroundCustomArchiveCachePolicy { + return { + maxAgeMs: boundedNumber(options.maxAgeMs, CACHE_MAX_AGE_MS_ENV, DEFAULT_MAX_AGE_MS), + maxBytes: boundedNumber(options.maxBytes, CACHE_MAX_BYTES_ENV, DEFAULT_MAX_BYTES), + maxCount: boundedNumber(options.maxCount, CACHE_MAX_COUNT_ENV, DEFAULT_MAX_COUNT), + staleLockMs: boundedNumber(options.staleLockMs, CACHE_STALE_LOCK_MS_ENV, DEFAULT_STALE_LOCK_MS), + } +} + +async function tryAcquireCacheLock(lockPath: string): Promise { + try { + await mkdir(lockPath) + await writeFile(join(lockPath, "owner.json"), `${JSON.stringify(ownerRecord())}\n`) + return true + } catch (error) { + if (errorHasCode(error, "EEXIST")) { + return false + } + await rm(lockPath, { recursive: true, force: true }) + throw error + } +} + +async function cacheLockIsActive(lockPath: string, now: number, staleLockMs: number, removeStale: boolean): Promise { + try { + const lockStat = await stat(lockPath) + const owner = await readOwnerRecord(join(lockPath, "owner.json")) + const active = owner ? processIsAlive(owner.pid) : now - lockStat.mtimeMs <= staleLockMs + if (!active && removeStale) { + await rm(lockPath, { recursive: true, force: true }) + } + return active + } catch (error) { + if (errorHasCode(error, "ENOENT")) { + return false + } + throw error + } +} + +async function activeArchiveReferenceCount(archivePath: string, removeStale: boolean): Promise { + const referencesDirectory = `${archivePath}.refs` + let names: string[] + try { + names = await readdir(referencesDirectory) + } catch (error) { + if (errorHasCode(error, "ENOENT")) { + return 0 + } + throw error + } + + let active = 0 + for (const name of names.sort()) { + const path = join(referencesDirectory, name) + const owner = await readOwnerRecord(path) + if (owner && processIsAlive(owner.pid)) { + active += 1 + } else if (removeStale) { + await rm(path, { recursive: true, force: true }) + } + } + return active +} + +async function readOwnerRecord(path: string): Promise { + try { + const value = JSON.parse(await readFile(path, "utf8")) as Partial + return Number.isInteger(value.pid) && typeof value.createdAt === "string" ? value as CacheOwnerRecord : undefined + } catch (error) { + if (errorHasCode(error, "ENOENT") || errorHasCode(error, "EISDIR") || error instanceof SyntaxError) { + return undefined + } + throw error + } +} + +function ownerRecord(): CacheOwnerRecord { + return { pid: process.pid, createdAt: new Date().toISOString() } +} + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return errorHasCode(error, "EPERM") + } +} + +function boundedNumber(explicit: number | undefined, environmentName: string, fallback: number): number { + const candidate = explicit ?? (process.env[environmentName] === undefined ? fallback : Number(process.env[environmentName])) + if (!Number.isFinite(candidate) || candidate < 0) { + throw new Error(`${environmentName} must be a non-negative finite number`) + } + return Math.floor(candidate) +} + +function newestFirst(left: CustomArchiveEntry, right: CustomArchiveEntry): number { + return right.mtimeMs - left.mtimeMs || left.path.localeCompare(right.path) +} + +function oldestFirst(left: CustomArchiveEntry, right: CustomArchiveEntry): number { + return left.mtimeMs - right.mtimeMs || left.path.localeCompare(right.path) +} + +function errorHasCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code) +} + +async function delay(ms: number): Promise { + await new Promise((resolveDelay) => setTimeout(resolveDelay, ms)) +} + +export function isCustomPlaygroundWordPressArchive(archivePath: string): boolean { + return CUSTOM_ARCHIVE_PATTERN.test(basename(archivePath)) +} diff --git a/tests/playground-custom-archive-cache.test.ts b/tests/playground-custom-archive-cache.test.ts new file mode 100644 index 00000000..3f835a9c --- /dev/null +++ b/tests/playground-custom-archive-cache.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict" +import { mkdir, mkdtemp, readdir, rm, utimes, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { acquirePlaygroundArchiveReference, maintainPlaygroundCustomArchiveCache, withPlaygroundArchiveCacheLock } from "../packages/runtime-playground/src/playground-wordpress-archive-cache.js" + +const root = await mkdtemp(join(tmpdir(), "wp-codebox-playground-custom-cache-")) +const now = Date.now() + +try { + const stableArchive = await archive("6.9.1.zip", 101, now - 100_000) + const staleArchive = await archive("custom-stale.zip", 11, now - 100_000) + const invalidArchive = await archive("custom-invalid.zip", 13, now - 100_000, "not a zip") + const referencedArchive = await archive("custom-referenced.zip", 17, now - 100_000) + const lockedArchive = await archive("custom-locked.zip", 19, now - 100_000) + + const firstReference = await acquirePlaygroundArchiveReference(referencedArchive) + const secondReference = await acquirePlaygroundArchiveReference(referencedArchive) + const activeLock = `${lockedArchive}.lock` + await mkdir(activeLock) + await writeFile(join(activeLock, "owner.json"), JSON.stringify({ pid: process.pid, createdAt: new Date(now).toISOString() })) + + const dryRun = await maintainPlaygroundCustomArchiveCache(root, { + mode: "dry-run", + now, + maxAgeMs: 1_000, + maxBytes: 1_000, + maxCount: 20, + }) + assert.equal(dryRun.customArchiveCount, 4, "stable release archives must not enter custom retention") + assert.equal(dryRun.candidateCount, 2, "stale unprotected archives must be candidates regardless of archive validity") + assert.equal(dryRun.candidateBytes, 24) + assert.deepEqual(dryRun.activeProtection, { + count: 2, + bytes: 36, + lockCount: 1, + referenceCount: 2, + paths: [lockedArchive, referencedArchive].sort(), + }) + assert.equal(dryRun.removedCount, 0) + assert.deepEqual(await customArchiveNames(), ["custom-invalid.zip", "custom-locked.zip", "custom-referenced.zip", "custom-stale.zip"]) + + await firstReference.release() + const sharedUse = await maintainPlaygroundCustomArchiveCache(root, { mode: "diagnose", now, maxAgeMs: 1_000 }) + assert.equal(sharedUse.activeProtection.referenceCount, 1, "one shared user must keep the archive protected") + assert.ok(sharedUse.activeProtection.paths.includes(referencedArchive)) + + const applied = await maintainPlaygroundCustomArchiveCache(root, { + mode: "apply", + now, + maxAgeMs: 1_000, + maxBytes: 1_000, + maxCount: 20, + }) + assert.equal(applied.removedCount, 2) + assert.equal(applied.removedBytes, 24) + assert.equal(applied.verifiedReclaimedBytes, 24) + assert.equal(applied.retainedCount, 2) + assert.equal(applied.retainedBytes, 36) + assert.deepEqual(await customArchiveNames(), ["custom-locked.zip", "custom-referenced.zip"]) + assert.ok((await readdir(root)).includes("6.9.1.zip"), "stable release archive must remain intact") + + await secondReference.release() + await rm(activeLock, { recursive: true }) + const afterRelease = await maintainPlaygroundCustomArchiveCache(root, { mode: "apply", now, maxAgeMs: 1_000 }) + assert.equal(afterRelease.removedCount, 2, "teardown must make formerly active archives reclaimable") + assert.deepEqual(await customArchiveNames(), []) + + for (let index = 0; index < 5; index += 1) { + await archive(`custom-bound-${index}.zip`, 10, now - (5 - index) * 1_000) + } + const bounds = await maintainPlaygroundCustomArchiveCache(root, { + mode: "apply", + now, + maxAgeMs: 1_000_000, + maxBytes: 25, + maxCount: 2, + }) + assert.equal(bounds.candidateCount, 3, "count and byte bounds must retain only the newest fitting archives") + assert.equal(bounds.removedCount, 3) + assert.equal(bounds.verifiedReclaimedBytes, 30) + assert.deepEqual(await customArchiveNames(), ["custom-bound-3.zip", "custom-bound-4.zip"]) + + const idempotent = await maintainPlaygroundCustomArchiveCache(root, { + mode: "apply", + now, + maxAgeMs: 1_000_000, + maxBytes: 25, + maxCount: 2, + }) + assert.equal(idempotent.candidateCount, 0) + assert.equal(idempotent.removedCount, 0) + assert.equal(idempotent.verifiedReclaimedBytes, 0) + + const crashedArchive = await archive("custom-crashed.zip", 23, now - 100_000) + const referencesDirectory = `${crashedArchive}.refs` + await mkdir(referencesDirectory) + await writeFile(join(referencesDirectory, "stale.json"), JSON.stringify({ pid: 2_147_483_647, createdAt: new Date(now - 100_000).toISOString() })) + const crashedLock = `${crashedArchive}.lock` + await mkdir(crashedLock) + await writeFile(join(crashedLock, "owner.json"), JSON.stringify({ pid: 2_147_483_647, createdAt: new Date(now - 100_000).toISOString() })) + const crashRecovery = await maintainPlaygroundCustomArchiveCache(root, { mode: "apply", now, maxAgeMs: 1_000 }) + assert.ok(crashRecovery.removedCount >= 1, "dead process locks and references must not retain stale archives forever") + assert.ok(!(await customArchiveNames()).includes("custom-crashed.zip")) + + assert.ok((await readdir(root)).includes(stableArchive.split("/").at(-1)!)) + + const recoveredVersion = "custom-recovered" + const recoveredLock = join(root, `${recoveredVersion}.zip.lock`) + await mkdir(recoveredLock) + await writeFile(join(recoveredLock, "owner.json"), JSON.stringify({ pid: 2_147_483_647, createdAt: new Date(now - 100_000).toISOString() })) + let acquiredRecoveredLock = false + await withPlaygroundArchiveCacheLock(root, recoveredVersion, async () => { + acquiredRecoveredLock = true + }) + assert.equal(acquiredRecoveredLock, true, "a crashed archive producer must not block future cache users") + + console.log("playground custom archive cache retention passed") +} finally { + await rm(root, { recursive: true, force: true }) +} + +async function archive(name: string, size: number, mtimeMs: number, content = "x"): Promise { + const path = join(root, name) + const bytes = Buffer.alloc(size, content) + await writeFile(path, bytes) + await utimes(path, mtimeMs / 1_000, mtimeMs / 1_000) + return path +} + +async function customArchiveNames(): Promise { + return (await readdir(root)).filter((name) => /^custom-.*\.zip$/.test(name)).sort() +}