Skip to content

Commit 6bbfc83

Browse files
authored
Harden Playground cache retention safety (#1930)
* fix: harden Playground cache retention leases * fix: make Playground cache retention generation-safe * fix: harden cache retention race checks * fix: preserve replacement cache leases on release * fix: expire cache leases without pathname unlink * fix: tolerate concurrent cache lock reclamation
1 parent d804f12 commit 6bbfc83

9 files changed

Lines changed: 1090 additions & 158 deletions

.github/workflows/agent-task-contracts.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ on:
2424
- "tests/production-boundary-enforcement.test.ts"
2525
- "tests/runtime-tool-policy.test.ts"
2626
- "packages/runtime-playground/src/phpunit-command-handlers.ts"
27+
- "packages/runtime-playground/src/playground-cli-runner.ts"
28+
- "packages/runtime-playground/src/playground-wordpress-archive-cache.ts"
29+
- "tests/playground-custom-archive-cache*.test.ts"
30+
- "tests/fixtures/playground-cache-lease-child.ts"
2731
- "tests/playground-phpunit-readonly-cache.integration.test.ts"
2832
- "tests/fixtures/phpunit-playground-harness/**"
2933
- "packages/cli/src/bounded-recipe-plan.ts"
@@ -53,6 +57,10 @@ on:
5357
- "tests/production-boundary-enforcement.test.ts"
5458
- "tests/runtime-tool-policy.test.ts"
5559
- "packages/runtime-playground/src/phpunit-command-handlers.ts"
60+
- "packages/runtime-playground/src/playground-cli-runner.ts"
61+
- "packages/runtime-playground/src/playground-wordpress-archive-cache.ts"
62+
- "tests/playground-custom-archive-cache*.test.ts"
63+
- "tests/fixtures/playground-cache-lease-child.ts"
5664
- "tests/playground-phpunit-readonly-cache.integration.test.ts"
5765
- "tests/fixtures/phpunit-playground-harness/**"
5866
- "packages/cli/src/bounded-recipe-plan.ts"
@@ -84,6 +92,7 @@ jobs:
8492
- run: npm run test:disposable-mysql-mysqli-e2e
8593
- run: npm run test:runtime-sources-playground-integration
8694
- run: npm run test:playground-phpunit-readonly-cache-integration
95+
- run: npm run test:playground-custom-archive-cache
8796
- run: npm run test:native-agent-task-playground-e2e
8897
- run: npm run test:native-agent-task-interruption
8998
- run: npm run test:trusted-apply-artifact-channel

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,7 @@ kimaki tunnel -- sh -c 'npm run wp-codebox -- run \
519519

520520
When a caller exposes the local Playground through a tunnel or proxy, pass `--preview-public-url <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.
521521

522-
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.
522+
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`. Active locks and runtimes use token-owned renewable leases; configure their duration with `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_LEASE_MS` and 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, including filesystem protections, orphan sidecars, logical bytes removed, estimated allocated inode bytes removed, and the concurrently observed filesystem free-space delta.
523523

524524
Remote-host previews can opt into `--preview-bind <host>` 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.
525525

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@
108108
"test:playground-readonly-mounts-integration": "tsx tests/playground-readonly-mounts-integration.test.ts",
109109
"test:playground-phpunit-readonly-cache-integration": "tsx tests/playground-phpunit-readonly-cache.integration.test.ts",
110110
"test:playground-phpunit-bootstrap-failure-integration": "tsx tests/playground-phpunit-bootstrap-failure.integration.test.ts",
111-
"test:playground-custom-archive-cache": "tsx tests/playground-custom-archive-cache.test.ts",
111+
"test:playground-custom-archive-cache": "tsx tests/playground-custom-archive-cache.test.ts && tsx tests/playground-custom-archive-cache-process.test.ts && tsx tests/playground-custom-archive-cache.integration.test.ts",
112112
"test:phpunit-runtime-failure-diagnostics": "tsx tests/phpunit-runtime-failure-diagnostics.test.ts",
113113
"test:php-wasm-extension-manifests": "tsx tests/php-wasm-extension-manifests.test.ts",
114114
"test:browser-task-builder": "tsx tests/browser-task-builder.test.ts",

packages/runtime-playground/src/playground-cli-runner.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import * as PlaygroundStorage from "@wp-playground/storage"
1313
import { resolveWordPressRelease } from "@wp-playground/wordpress"
1414
import { phpEnvAssignments, phpLiteral, phpWpConfigDefineAssignments } from "./php-snippets.js"
1515
import { stageReadonlyPlaygroundMounts, type ReadonlyMountStaging } from "./mount-materialization.js"
16-
import { acquirePlaygroundArchiveReference, isCustomPlaygroundWordPressArchive, maintainPlaygroundCustomArchiveCache, playgroundWordPressArchiveCacheDirectory, withPlaygroundArchiveCacheLock, type PlaygroundArchiveReference, type PlaygroundCustomArchiveCacheMaintenance } from "./playground-wordpress-archive-cache.js"
16+
import { acquirePlaygroundArchiveReference, isCustomPlaygroundWordPressArchive, maintainPlaygroundCustomArchiveCache, playgroundWordPressArchiveCacheDirectory, withPlaygroundArchiveCacheLock, type PlaygroundArchiveReference, type PlaygroundCustomArchiveCacheDiagnostic, type PlaygroundCustomArchiveCacheMaintenance } from "./playground-wordpress-archive-cache.js"
1717

1818
const DEFAULT_RUNTIME_PHP_INI_ENTRIES = { memory_limit: "512M" }
1919

@@ -66,6 +66,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
6666
let readonlyMountStaging: ReadonlyMountStaging | undefined
6767
let archiveReference: PlaygroundArchiveReference | undefined
6868
let cacheMaintenance: PlaygroundCustomArchiveCacheMaintenance | undefined
69+
let cacheMaintenanceDiagnostics: PlaygroundCustomArchiveCacheDiagnostic[] = []
6970
let usesArchiveCache = false
7071
try {
7172
if (spec.preview?.port) {
@@ -89,11 +90,13 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
8990
const stagedMounts = readonlyMountStaging.mounts
9091
const preinstallMounts = stagedMounts.filter((mount) => mount.target === "/wordpress/wp-config.php")
9192
const postinstallMounts = stagedMounts.filter((mount) => mount.target !== "/wordpress/wp-config.php")
92-
const wordpressStartupAsset = wordpressDirectory ? undefined : await resolvePlaygroundWordPressStartupAsset(spec.environment.version, spec.environment.assets?.wordpressZip)
93-
archiveReference = wordpressStartupAsset?.archiveReference
9493
if (usesArchiveCache) {
95-
cacheMaintenance = await maintainPlaygroundCustomArchiveCache().catch(() => undefined)
94+
const maintenance = await automaticPlaygroundCustomArchiveCacheMaintenance()
95+
cacheMaintenance = maintenance.result
96+
cacheMaintenanceDiagnostics = maintenance.diagnostics
9697
}
98+
const wordpressStartupAsset = wordpressDirectory ? undefined : await resolvePlaygroundWordPressStartupAsset(spec.environment.version, spec.environment.assets?.wordpressZip)
99+
archiveReference = wordpressStartupAsset?.archiveReference
97100
const cacheValidation = wordpressStartupAsset?.cacheValidation ?? {
98101
version: spec.environment.version ?? "mounted-wordpress-source",
99102
sourceUrl: wordpressDirectory ?? "",
@@ -103,6 +106,9 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
103106
if (cacheMaintenance) {
104107
cacheValidation.retention = cacheMaintenance
105108
}
109+
if (cacheMaintenanceDiagnostics.length > 0) {
110+
cacheValidation.retentionDiagnostics = cacheMaintenanceDiagnostics
111+
}
106112
const blueprintSummary = summarizeBlueprint(spec.environment.blueprint)
107113
if (blueprintSummary.steps > 0) {
108114
emitProgress("preview:applying-blueprint", "running", "Applying site setup", blueprintSummary)
@@ -187,7 +193,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
187193
} finally {
188194
await archiveReference?.release().catch(() => undefined)
189195
if (usesArchiveCache) {
190-
await maintainPlaygroundCustomArchiveCache().catch(() => undefined)
196+
await automaticPlaygroundCustomArchiveCacheMaintenance(true)
191197
}
192198
await readonlyMountStaging?.[Symbol.asyncDispose]()
193199
}
@@ -200,7 +206,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
200206

201207
await archiveReference?.release().catch(() => undefined)
202208
if (usesArchiveCache) {
203-
await maintainPlaygroundCustomArchiveCache().catch(() => undefined)
209+
await automaticPlaygroundCustomArchiveCacheMaintenance(true)
204210
}
205211
await readonlyMountStaging?.[Symbol.asyncDispose]()
206212
if (spec.preview?.port && errorHasCode(error, "EADDRINUSE")) {
@@ -623,6 +629,29 @@ function errorDetail(error: unknown): Record<string, unknown> {
623629
return { message: String(error) }
624630
}
625631

632+
async function automaticPlaygroundCustomArchiveCacheMaintenance(warnOnFailure = false): Promise<{ result?: PlaygroundCustomArchiveCacheMaintenance; diagnostics: PlaygroundCustomArchiveCacheDiagnostic[] }> {
633+
try {
634+
const result = await maintainPlaygroundCustomArchiveCache()
635+
if (warnOnFailure) {
636+
for (const diagnostic of result.diagnostics) {
637+
console.warn(`[wp-codebox] ${diagnostic.code}: ${diagnostic.message}`)
638+
}
639+
}
640+
return { result, diagnostics: result.diagnostics }
641+
} catch (error) {
642+
const diagnostic = {
643+
code: "playground-custom-archive-cache-maintenance-failed",
644+
message: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500),
645+
severity: "warning" as const,
646+
path: playgroundWordPressArchiveCacheDirectory(),
647+
}
648+
if (warnOnFailure) {
649+
console.warn(`[wp-codebox] ${diagnostic.code}: ${diagnostic.message}`)
650+
}
651+
return { diagnostics: [diagnostic] }
652+
}
653+
}
654+
626655
export interface PlaygroundWordPressArchiveCacheValidation {
627656
version: string
628657
sourceUrl: string
@@ -640,6 +669,7 @@ export interface PlaygroundWordPressArchiveCacheValidation {
640669
deleted: boolean
641670
}>
642671
retention?: PlaygroundCustomArchiveCacheMaintenance
672+
retentionDiagnostics?: PlaygroundCustomArchiveCacheDiagnostic[]
643673
}
644674

645675
export interface PlaygroundWordPressArchiveCacheValidationOptions {

0 commit comments

Comments
 (0)