ReviewOS

craft-native/craft

Builds and tests leave craft-* temp directories behind

#357
Closed chris opened this 9 days ago ·
9 days ago

On 2026-10-14 the shared Mac ran out of disk twice (248 MB, then 1.1 GB free), killing simulator runs. One cause was 1,983 stale craft-* directories older than an hour in $(getconf DARWIN_USER_TEMP_DIR), which another session deleted by hand to free ~13 GB (with ~/.bun/install/cache).

Something in Craft (the build pipeline, native screen compilation, template/fixture tests, or the E2E harness) creates a craft-* temp dir per run and never removes it, including on success.

Wanted:

  • Find every place that creates a craft-* temp dir (mkdtemp or similar) and remove it when the run ends, on success, failure and interrupt (try/finally plus a signal handler where a process can be killed mid-run).
  • Long-lived dirs that are meant as caches should live under a single, bounded, named cache dir with pruning, not in temp.
  • A test that runs a representative build/test and asserts no craft-* dir is left behind.
  • Note it in the release notes so apps' builds stop leaking after upgrading.
9 days ago

Fixed in 8e48f41, released in v0.0.187.

Every Craft scratch directory now comes from packages/typescript/src/temp-dir.ts. A directory is removed in three ways:

  1. by the caller when it is done (withTempDir, or removeTempDir in a finally);
  2. on process exit and on SIGINT/SIGTERM/SIGHUP. Node skips exit listeners on a signal's default action, so the signals get their own handlers. When no other listener is registered, the process then exits with 128+n, as the default action would;
  3. by a sweep, for runs killed with SIGKILL. Names carry the owning pid (craft-<label>-p<pid>-XXXXXX), and a later process's first createTempDir removes dirs whose pid is dead. Only that naming is ever swept, dirs younger than 60 s are skipped, and a dir marked with keepTempDir() (.craft-keep) is left alone.

Leaks found and fixed

WhereLeakFix
packages/ios/src/index.test.ts77 mkdtemps with no cleanup (rmSync wasn't even imported)createTempDir + afterAll(removeAllTempDirs)
packages/android/src/index.test.ts67 dirs, 6 removedsame
Measured together~150 dirs / 125 MB per bun test of ios + android, the bulk of the 1,9830 now
~40 other test files (packages/typescript, scripts/*.test.ts, hybrid, native-shell, source-audit, watch-bundle, …)per-test cleanup that a failed assertion skippedsame helper, removed per file and at exit
iOS/Android harnesses: test-native-navigation, test-native-render, test-native-health, compile-templates, load-failure, recovery-budgetfinally only. A killed run (timeouts, Ctrl-C during xcodebuild/gradle) never reached it; craft-native-navigation-* alone appeared 233 times in session logshelper (exit + signals + sweep). The CRAFT_KEEP_* switches now go through keepTempDir
package.ts createDEB / createAppImagestaging tree leaked when a step threw before dpkg-deb/appimagetool spawnedhoisted, removed in catch
package.ts createPKG / createMSIfinally onlyhelper
signing/index.ts osslsigncode password dirparent derived with a /-only regex, which matched nothing in backslash-separated Windows paths; a failed password write skipped cleanupremoves the dir it made, on every path
updater/macos-bundle.tsits own craft-update-* staging leaked when DMG/ZIP extraction failedremoved unless handed back (caller-supplied dirs untouched)
index.ts sidebar config spillcraft-sidebar-* never removedremoved when the window process closes or fails to spawn
ios/src/testing.ts native mutation validatoronly dispose() removed italso removed on exit, pid-stamped for the sweep (local, since that builder's declarations are emitted standalone)
scripts/mobile-e2e.tsartifacts/mobile-e2e/<label>/work (generated projects + DerivedData, GBs) kept after every runremoved after the run; CRAFT_E2E_KEEP_WORK=1 keeps it. CI already excluded it from uploads

Checked and clean: the init()/build() paths Stacks' build:ios/build:android call (no temp dirs), xcodegen/gradle generation (writes into the caller's output), Zig tests (.zig-cache/tmp via std.testing.tmpDir, plus fixed /tmp file names), the Windows multi-window verifier, and CI's $RUNNER_TEMP use. No temp dir was serving as a cache. The updater's craft-updates/<app> download dir is one fixed path per app, so it is already bounded, and it is removed after install.

Tests

  • packages/typescript/src/__tests__/temp-dir.test.ts: naming, removal, withTempDir on throw, signal listeners only while dirs are live, sweep (removes dead-pid dirs only; keeps live, young, kept, foreign and legacy names).

  • packages/ios/src/temp-dir-leaks.test.ts: each case runs in a child process with TMPDIR set to an empty sandbox, and asserts no craft-* is left after:

    • a Stacks-style iOS + Android init + build that succeeds;
    • the same build failing with an uncaught error;
    • SIGINT and SIGTERM mid-run (exit 130/143);
    • SIGKILL, followed by the next process's sweep;
    • the whole iOS builder suite plus a deliberately failing test file.

    With the exit/signal hooks disabled, 4 of the 6 cases fail.

Locally, after the fix: ios (250), android (134), SDK (668) and the 17 touched scripts/*.test.ts suites all pass, with zero new craft-* dirs in the real temp folder.

Apps stop leaking once they pick up craft-native ≥ 0.0.187. Known limit: if a run is blocked in Bun.spawnSync (xcodebuild) and only the parent gets SIGTERM, cleanup waits for the child to finish. If the run is SIGKILLed first, the next run's sweep removes the dir.

Sign in to comment on this issue.