Skip to content

Commit 968af0d

Browse files
authored
fix(sandbox): supervise preview source sync (#213)
## Why A transient Daytona FUSE read denial could terminate the durable-to-native source synchronizer while the Vite child stayed healthy. Process reuse then saw a live port and returned a permanently stale preview. ## What changed - Retry transient durable-source read failures immediately and within a bounded grace period. - Treat the app and synchronizer as one managed process unit; a dead synchronizer terminates the app so the existing bounded supervisor restarts both. - Extend immutable snapshot smoke checks to cover transient recovery, live source propagation, and sync-child failure. - Document the managed-preview liveness contract at the sandbox and agent-worker boundaries. ## Verification - `pnpm lint` - `pnpm typecheck` - `pnpm turbo build --force` - `pnpm deadcode` - `pnpm architecture:check` - `pnpm turbo skills:build` - Local lifecycle smoke: simulated the observed `EPERM`, verified source recovery, verified live update propagation, killed the sync child, and confirmed the preview parent exited non-zero. All repository commands ran with Node 24.18.0 and pnpm 11.15.0. Production browser QA follows immutable snapshot publication and promotion.
1 parent 680f5fa commit 968af0d

4 files changed

Lines changed: 188 additions & 19 deletions

File tree

.github/workflows/build-snapshot.yml

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,69 @@ jobs:
149149
test -x /opt/cheatcode/project-source-sync.py
150150
test -x /opt/cheatcode/pnpm-build-policy.mjs
151151
python3 -c "import ast,pathlib;ast.parse(pathlib.Path(\"/opt/cheatcode/project-source-sync.py\").read_text())"
152+
python3 - <<PY
153+
import errno
154+
import importlib.util
155+
import multiprocessing
156+
import os
157+
import pathlib
158+
import sys
159+
import tempfile
160+
import time
161+
162+
sys.dont_write_bytecode = True
163+
script = "/opt/cheatcode/project-source-sync.py"
164+
spec = importlib.util.spec_from_file_location("project_source_sync", script)
165+
if spec is None or spec.loader is None:
166+
raise RuntimeError("source synchronizer module could not be loaded")
167+
synchronizer = importlib.util.module_from_spec(spec)
168+
spec.loader.exec_module(synchronizer)
169+
170+
with tempfile.TemporaryDirectory() as root:
171+
source = os.path.join(root, "durable")
172+
target = os.path.join(root, "mirror")
173+
lock = os.path.join(root, "project.lock")
174+
os.makedirs(source)
175+
pathlib.Path(source, "index.html").write_text("sync recovered", encoding="utf-8")
176+
original_sync = synchronizer.sync_directory
177+
failures_remaining = [4]
178+
179+
def transient_sync(current_source, current_target):
180+
if failures_remaining[0] > 0:
181+
failures_remaining[0] -= 1
182+
raise PermissionError(
183+
errno.EPERM,
184+
"simulated durable source contention",
185+
os.path.join(source, "index.html"),
186+
)
187+
original_sync(current_source, current_target)
188+
189+
synchronizer.sync_directory = transient_sync
190+
process = multiprocessing.get_context("fork").Process(
191+
target=synchronizer.preview_sync,
192+
args=(source, target, "preview-loop", lock),
193+
)
194+
process.start()
195+
try:
196+
deadline = time.monotonic() + 5
197+
output = pathlib.Path(target, "index.html")
198+
while time.monotonic() < deadline and not output.exists():
199+
if not process.is_alive():
200+
raise RuntimeError("preview sync exited after transient source contention")
201+
time.sleep(0.05)
202+
if not output.is_file() or output.read_text(encoding="utf-8") != "sync recovered":
203+
raise RuntimeError("preview sync did not recover after transient source contention")
204+
if not process.is_alive():
205+
raise RuntimeError("preview sync stopped after recovering source access")
206+
finally:
207+
process.terminate()
208+
process.join(timeout=5)
209+
if process.is_alive():
210+
process.kill()
211+
process.join(timeout=5)
212+
if process.is_alive():
213+
raise RuntimeError("preview sync smoke process did not stop")
214+
PY
152215
node --check /opt/cheatcode/pnpm-build-policy.mjs
153216
policy_test_dir="$(mktemp -d)"
154217
printf "packages:\\n - .\\nallowBuilds:\\n sharp: false\\n" > "$policy_test_dir/pnpm-workspace.yaml"
@@ -175,10 +238,12 @@ jobs:
175238
176239
wait_for_preview() {
177240
preview_port="$1"
178-
preview_log="$2"
241+
preview_text="$2"
242+
preview_log="$3"
243+
preview_limit="$4"
179244
preview_attempt=0
180-
while [ "$preview_attempt" -lt 90 ]; do
181-
if curl --fail --silent "http://127.0.0.1:$preview_port" | grep --fixed-strings --quiet "reviewed lifecycle policy works"; then
245+
while [ "$preview_attempt" -lt "$preview_limit" ]; do
246+
if curl --fail --silent "http://127.0.0.1:$preview_port" | grep --fixed-strings --quiet "$preview_text"; then
182247
return 0
183248
fi
184249
if ! kill -0 "$preview_pid" 2>/dev/null; then
@@ -194,7 +259,7 @@ jobs:
194259
cold_started_at="$(date +%s)"
195260
PORT=5199 /opt/cheatcode/project-source-sync.py preview-run "$preview_source" "$preview_mirror" "$preview_lock" "$preview_mirror" - -- pnpm run dev --host 0.0.0.0 --port 5199 > "$preview_test_dir/cold.log" 2>&1 &
196261
preview_pid="$!"
197-
wait_for_preview 5199 "$preview_test_dir/cold.log"
262+
wait_for_preview 5199 "reviewed lifecycle policy works" "$preview_test_dir/cold.log" 90
198263
cold_elapsed=$(( $(date +%s) - cold_started_at ))
199264
kill "$preview_pid" 2>/dev/null || true
200265
wait "$preview_pid" 2>/dev/null || true
@@ -209,18 +274,37 @@ jobs:
209274
warm_started_at="$(date +%s)"
210275
PORT=5200 /opt/cheatcode/project-source-sync.py preview-run "$preview_source" "$preview_mirror" "$preview_lock" "$preview_mirror" - -- pnpm run dev --host 0.0.0.0 --port 5200 > "$preview_test_dir/warm.log" 2>&1 &
211276
preview_pid="$!"
212-
wait_for_preview 5200 "$preview_test_dir/warm.log"
277+
wait_for_preview 5200 "reviewed lifecycle policy works" "$preview_test_dir/warm.log" 10
213278
warm_elapsed=$(( $(date +%s) - warm_started_at ))
214-
kill "$preview_pid" 2>/dev/null || true
215-
wait "$preview_pid" 2>/dev/null || true
216-
preview_pid=""
217279
test "$warm_elapsed" -le 10
218280
test ! -e "$preview_source/pnpm-workspace.yaml"
219281
test ! -e "$preview_mirror/pnpm-workspace.yaml"
220282
if grep --fixed-strings --quiet "Progress:" "$preview_test_dir/warm.log"; then
221283
echo "Warm preview unexpectedly reinstalled dependencies." >&2
222284
exit 1
223285
fi
286+
printf "%s\n" "<main id=\"app\">live source synchronization works</main>" > "$preview_source/index.html"
287+
wait_for_preview 5200 "live source synchronization works" "$preview_test_dir/warm.log" 10
288+
sync_pid="$(pgrep -P "$preview_pid" -f "project-source-sync.py preview-loop" | head -n 1)"
289+
if [ -z "$sync_pid" ]; then
290+
echo "Managed preview did not expose a source synchronizer child." >&2
291+
exit 1
292+
fi
293+
kill "$sync_pid"
294+
preview_stop_attempt=0
295+
while kill -0 "$preview_pid" 2>/dev/null && [ "$preview_stop_attempt" -lt 20 ]; do
296+
sleep 1
297+
preview_stop_attempt=$((preview_stop_attempt + 1))
298+
done
299+
if kill -0 "$preview_pid" 2>/dev/null; then
300+
echo "Managed preview stayed alive without its source synchronizer." >&2
301+
exit 1
302+
fi
303+
if wait "$preview_pid"; then
304+
echo "Managed preview reported success after its source synchronizer failed." >&2
305+
exit 1
306+
fi
307+
preview_pid=""
224308
echo "Vite preview smoke passed: cold=${cold_elapsed}s warm=${warm_elapsed}s"
225309
cleanup_preview_test
226310
trap - EXIT

apps/agent-worker/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,11 @@ webpack compilation even after the listening socket opens. A baked Python synchr
167167
content identities rather than unrelated FUSE/native-disk timestamps, performs atomic replacement
168168
on native disk and checksum-verified direct overwrites on the non-POSIX durable mount, and mirrors
169169
subsequent writes and deletions within 250 ms, including equal-size and
170-
shell-based edits. The local source, dependency tree, and build cache are disposable;
170+
shell-based edits. Transient read contention on the durable source is retried within a bounded grace
171+
period; sustained source failure or a terminated synchronizer fails the whole managed preview so the
172+
existing bounded process restart policy restores both the app and its source feed instead of reusing
173+
a stale listening port. The local source,
174+
dependency tree, and build cache are disposable;
171175
wake and restart reconstruct them from the durable project without changing the Files surface.
172176
Persisted pnpm-backed preview commands restore a missing sandbox-local dependency tree before the
173177
server starts. A package/lock/config digest skips the install entirely when the local dependency tree

infra/containers/sandbox/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,11 @@ Direct pnpm invocations execute inside one `flock`-guarded transaction; process
106106
releases that lock automatically, and successful source changes commit only after a
107107
three-way conflict check against the durable tree. Long-lived previews invoke the same helper as
108108
direct argv: it synchronizes source, restores dependencies under the project lock, supervises the
109-
native-disk app and sync-loop children, and forwards termination signals. A dependency-state digest
109+
native-disk app and sync-loop children, and forwards termination signals. Transient read contention
110+
from the durable FUSE source is retried during a bounded grace period without interrupting the app.
111+
If source access does not recover or the synchronizer otherwise exits, the managed preview exits as
112+
one failed process unit so its existing bounded restart policy cannot leave a healthy port backed by
113+
stale source. A dependency-state digest
110114
skips unchanged reinstalls, while projects without a durable lockfile install without creating one
111115
as a preview side effect. Dependency restoration temporarily merges the image's reviewed build
112116
policy into the sandbox-local workspace, currently permitting `esbuild` so Vite can install its

infra/containers/sandbox/scripts/project-source-sync.py

Lines changed: 86 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env python3
22
"""Synchronize durable project source with its sandbox-local native-disk mirror."""
33

4+
import errno
45
import fcntl
56
import hashlib
67
import os
@@ -16,8 +17,20 @@
1617
IGNORED_DIRS = {".expo", ".next", ".turbo", "build", "coverage", "dist", "node_modules", "out"}
1718
LOCAL_ONLY_NAMES = IGNORED_DIRS | {"next-env.d.ts"}
1819
PACKAGE_CONFLICT_EXIT = 74
20+
PREVIEW_SYNC_FAILURE_EXIT = 75
1921
DEPENDENCY_STATE_FILENAME = ".cheatcode-dependency-state"
2022
PNPM_BUILD_POLICY_BINARY = "/opt/cheatcode/pnpm-build-policy.mjs"
23+
PREVIEW_SYNC_INTERVAL_SECONDS = 0.25
24+
SOURCE_FAILURE_GRACE_SECONDS = 10
25+
SOURCE_READ_RETRY_DELAYS_SECONDS = (0.05, 0.1, 0.2)
26+
SOURCE_WARNING_INTERVAL_SECONDS = 5
27+
TRANSIENT_SOURCE_ERRNOS = {
28+
errno.EACCES,
29+
errno.EBUSY,
30+
errno.EIO,
31+
errno.EPERM,
32+
errno.ESTALE,
33+
}
2134

2235

2336
def remove_path(path):
@@ -136,18 +149,68 @@ def try_lock(lock):
136149
return False
137150

138151

152+
def is_transient_source_error(error, source):
153+
if error.errno not in TRANSIENT_SOURCE_ERRNOS or not error.filename:
154+
return False
155+
source_root = os.path.abspath(source)
156+
failed_path = os.path.abspath(error.filename)
157+
try:
158+
return os.path.commonpath([source_root, failed_path]) == source_root
159+
except ValueError:
160+
return False
161+
162+
163+
def sync_source_once(source, target):
164+
for delay in (*SOURCE_READ_RETRY_DELAYS_SECONDS, None):
165+
try:
166+
sync_directory(source, target)
167+
return
168+
except OSError as error:
169+
if delay is None or not is_transient_source_error(error, source):
170+
raise
171+
time.sleep(delay)
172+
173+
139174
def preview_sync(source, target, mode, lock_path):
140175
is_once = mode == "preview-once"
176+
source_failure_started_at = None
177+
last_warning_at = 0
141178
while True:
142-
with open_lock(lock_path) as lock:
143-
if is_once:
144-
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
145-
sync_directory(source, target)
146-
elif try_lock(lock):
147-
sync_directory(source, target)
179+
did_attempt_sync = False
180+
did_sync = False
181+
try:
182+
with open_lock(lock_path) as lock:
183+
if is_once:
184+
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
185+
did_attempt_sync = True
186+
sync_source_once(source, target)
187+
did_sync = True
188+
elif try_lock(lock):
189+
did_attempt_sync = True
190+
sync_source_once(source, target)
191+
did_sync = True
192+
except OSError as error:
193+
if is_once or not is_transient_source_error(error, source):
194+
raise
195+
now = time.monotonic()
196+
if source_failure_started_at is None:
197+
source_failure_started_at = now
198+
elif now - source_failure_started_at >= SOURCE_FAILURE_GRACE_SECONDS:
199+
raise
200+
if now - last_warning_at >= SOURCE_WARNING_INTERVAL_SECONDS:
201+
print(
202+
f"durable source temporarily unreadable; preview sync is retrying: {error}",
203+
file=sys.stderr,
204+
flush=True,
205+
)
206+
last_warning_at = now
207+
if did_sync:
208+
source_failure_started_at = None
209+
elif not did_attempt_sync:
210+
source_failure_started_at = None
148211
if is_once:
149212
return
150-
time.sleep(0.25)
213+
time.sleep(PREVIEW_SYNC_INTERVAL_SECONDS)
151214

152215

153216
def overwrite_durable_file(source, target):
@@ -415,7 +478,7 @@ def preview_run(source, target, lock_path, local_cwd, dependency_template, comma
415478
raise ValueError("preview-run requires an app command")
416479
with open_lock(lock_path) as lock:
417480
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
418-
sync_directory(source, target)
481+
sync_source_once(source, target)
419482
dependency_status = restore_pnpm_dependencies(local_cwd, target, dependency_template)
420483
if dependency_status != 0:
421484
return dependency_status
@@ -442,7 +505,21 @@ def terminate_children(_signum=None, _frame=None):
442505
env=app_environment,
443506
start_new_session=True,
444507
)
445-
return app_process.wait()
508+
while True:
509+
app_status = app_process.poll()
510+
if app_status is not None:
511+
return app_status
512+
sync_status = sync_process.poll()
513+
if sync_status is not None:
514+
print(
515+
f"preview source synchronizer exited unexpectedly with status {sync_status}",
516+
file=sys.stderr,
517+
flush=True,
518+
)
519+
stop_process(app_process)
520+
reap_process(app_process)
521+
return sync_status or PREVIEW_SYNC_FAILURE_EXIT
522+
time.sleep(PREVIEW_SYNC_INTERVAL_SECONDS)
446523
finally:
447524
terminate_children()
448525
reap_process(app_process)

0 commit comments

Comments
 (0)