Summary
Clearing a stale runtime from the jac cache is fatal. If any rmdir inside the stale directory fails, jac dies with Uncaught exception and exit 1 before running the command at all - even though the new runtime has already been extracted successfully and nothing needs the stale one.
main is red on jac-check right now for exactly this, and it is not the diff that merged.
Evidence
Two jobs, same stale generation id f4ca7e3e22fe2d5d-2796bddb91431b70, different file each time, both dying in the "Warm the runtime cache" step before reading a single source file:
main @ 9b02a4b2d9 (#8944), jac-check job 101933238973:
jac: extracting runtime...
jac: removing stale runtime /home/runner/_work/_temp/.cache/jac/rt/f4ca7e3e22fe2d5d-2796bddb91431b70...
Uncaught exception: rmtree: could not remove '/home/runner/_work/_temp/.cache/jac/rt/f4ca7e3e22fe2d5d-2796bddb91431b70/python/lib/python3.14/site-packages/websocket/tests'
##[error]Process completed with exit code 1.
PR #9045 @ a810161, jac-check job 101923479268:
jac: removing stale runtime /home/runner/_work/_temp/.cache/jac/rt/f4ca7e3e22fe2d5d-2796bddb91431b70...
Uncaught exception: rmtree: could not remove '/home/runner/_work/_temp/.cache/jac/rt/f4ca7e3e22fe2d5d-2796bddb91431b70/python/lib/python3.14/site-packages/openai/types/conversations/__pycache__'
The immediately preceding commit on main (7e2ddc1f8a) was green on the same job, so this is intermittent and driven by which cache actions/cache/restore hands the job, not by any diff.
Mechanism
Two separate defects, and the second is why the error message points at the wrong file.
1. A best-effort cleanup is fatal. jac/jaclang/dist/fused/materialize.jac:213-214:
say(f" removing stale runtime {full}...");
shutil.rmtree(full);
Unguarded. This loop only ever visits directories it has already decided are stale (name != keep_key, same path hash). The freshly extracted runtime at keep_key is complete before this runs, so failing to delete a directory nobody will read again cannot affect correctness - but it takes the whole process down.
2. The failure is reported at the wrong level. jac/jaclang/runtime/na_stdlib/shutil.jac:96-102:
if os.path.isdir(child) {
rmtree(child);
} else {
unlink(child); // i32 return, discarded
}
...
if rmdir(path) != 0 {
raise ValueError(f"rmtree: could not remove '{path}'");
}
unlink is declared -> i32 (:3) and its result is dropped. So a file that cannot be unlinked fails silently, the directory is then non-empty, and rmdir fails on the parent. That is why both messages name a directory (websocket/tests, .../__pycache__) rather than the file that actually could not be removed. Whoever debugs this from the message alone is looking one level above the real cause.
shutil.jac:70 drops an unlink return the same way inside move.
Why this is worth fixing rather than re-running
Suggested shape
materialize.jac:214 - a stale-runtime removal that fails should warn and continue. Leaving a stale directory on disk costs disk, not correctness, and the next run retries it.
shutil.jac:97 - check unlink's return and raise naming the file, so when a removal genuinely must succeed the diagnostic points at the cause. Same for :70.
I have not reproduced this on a local machine; both witnesses are CI jobs and the mechanism above is read off the source as it stands on 9b02a4b2d9. What I have not established is why the unlink fails on those particular paths (permissions from the cache restore, or a concurrent reader) - but that is the third defect, and the first two make it non-fatal either way.
Summary
Clearing a stale runtime from the jac cache is fatal. If any
rmdirinside the stale directory fails,jacdies withUncaught exceptionand exit 1 before running the command at all - even though the new runtime has already been extracted successfully and nothing needs the stale one.mainis red onjac-checkright now for exactly this, and it is not the diff that merged.Evidence
Two jobs, same stale generation id
f4ca7e3e22fe2d5d-2796bddb91431b70, different file each time, both dying in the "Warm the runtime cache" step before reading a single source file:main@9b02a4b2d9(#8944),jac-checkjob101933238973:PR #9045 @
a810161,jac-checkjob101923479268:The immediately preceding commit on main (
7e2ddc1f8a) was green on the same job, so this is intermittent and driven by which cacheactions/cache/restorehands the job, not by any diff.Mechanism
Two separate defects, and the second is why the error message points at the wrong file.
1. A best-effort cleanup is fatal.
jac/jaclang/dist/fused/materialize.jac:213-214:Unguarded. This loop only ever visits directories it has already decided are stale (
name != keep_key, same path hash). The freshly extracted runtime atkeep_keyis complete before this runs, so failing to delete a directory nobody will read again cannot affect correctness - but it takes the whole process down.2. The failure is reported at the wrong level.
jac/jaclang/runtime/na_stdlib/shutil.jac:96-102:unlinkis declared-> i32(:3) and its result is dropped. So a file that cannot be unlinked fails silently, the directory is then non-empty, andrmdirfails on the parent. That is why both messages name a directory (websocket/tests,.../__pycache__) rather than the file that actually could not be removed. Whoever debugs this from the message alone is looking one level above the real cause.shutil.jac:70drops anunlinkreturn the same way insidemove.Why this is worth fixing rather than re-running
public/at the web root, every server consumes it #8175 this week, and it currently has main red.jacinvocation on any machine holding a stale generation is exposed, not just CI. Local dev caches accumulate generations the same way.Suggested shape
materialize.jac:214- a stale-runtime removal that fails should warn and continue. Leaving a stale directory on disk costs disk, not correctness, and the next run retries it.shutil.jac:97- checkunlink's return and raise naming the file, so when a removal genuinely must succeed the diagnostic points at the cause. Same for:70.I have not reproduced this on a local machine; both witnesses are CI jobs and the mechanism above is read off the source as it stands on
9b02a4b2d9. What I have not established is why the unlink fails on those particular paths (permissions from the cache restore, or a concurrent reader) - but that is the third defect, and the first two make it non-fatal either way.