Skip to content

fix: unpack executable assets inside SASJS_ROOT, and remove them - #536

Merged
allanbowe merged 2 commits into
mainfrom
fix/pkg-cache-under-sasjs-root
Nov 4, 2026
Merged

allanbowe merged 2 commits into
mainfrom
fix/pkg-cache-under-sasjs-root

Conversation

@sasjs-dev

@sasjs-dev sasjs-dev Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Closes #523.

What happens today

@yao-pkg/pkg externalises the snapshot files an executable asks for into pkgCacheDir() - ~/.cache/pkg unless PKG_NATIVE_CACHE_PATH says otherwise - as tmp-XXXXXX, and registers the removal on beforeExit. This server's shutdown path ends with process.exit(), which does not emit beforeExit, so the folder survives (upstream: yao-pkg/pkg#52, open since 2024-04-09).

Measured on a linux executable built at this commit, desktop mode, fresh SASJS_ROOT: ~/.cache/pkg/tmp-d8roSV appears during the start, holds 192 files (the macros copied to the drive), and is still there after SIGTERM.

The fix

configurePkgCache (api/src/utils/pkgCache.ts, called from setProcessVariables once SASJS_ROOT is resolved) directs pkg's cache into <SASJS_ROOT>/tmp/pkg-cache-<pid>-<uuid> and removes it from an exit handler.

Four decisions behind that shape:

  • exit, not beforeExit. exit runs on process.exit() as well as on a drained event loop, and the removal is synchronous, so it completes before the process goes. This is the same route the upstream issue names as working for them.
  • Inside SASJS_ROOT, not os.tmpdir(). The data root is the directory the operator already watches and backs up, so a folder that does survive a kill is visible where someone looks. Pointing it back at /tmp would recreate Housekeeping of PKG_XXXX files #208's invisible files.
  • A random id in the name, not just the pid. Under Docker this server is pid 1 in every container, and two instances sharing a SASJS_ROOT would otherwise share pkg-cache-1 - one removing the other's live assets on exit, or inheriting a dead instance's folder on start.
  • Liveness is a timestamp, not a pid probe. process.kill(pid, 0) cannot tell live from dead across namespaces, so the sweep works by age: a running instance refreshes its folder's timestamp hourly (an unref'd timer), and a start removes a pkg-cache-* folder that nothing has refreshed for a day (staleAfterMs). A folder is never swept on the strength of its pid.

A PKG_NATIVE_CACHE_PATH the operator has set is left untouched - on a host whose data root is small or read-only that is their call. The README documents the variable, its source (pkg's), and the two caveats: a killed process's folder stays until a later start sweeps it, and a native addon's extraction happens in pkg's prelude, before this code runs.

Scope of the fix: no native addon ships today, so ~/.cache/pkg stays empty for the assets this issue is about; if one is ever bundled, pkg will use its default for that one and the README says so.

Evidence

Built from this commit's tree with npx pkg . --targets node24-linux-x64, desktop mode (MODE=desktop RUN_TIMES=js), a fresh SASJS_ROOT each run:

=== A: clean run, then SIGTERM
  during: pkg-cache-93175-48ab2e61-034a-4c99-b12e-557ce874c480  files=192
  exit: 0
  after SIGTERM, tmp holds: ''
  new ~/.cache/pkg entries: ''
=== B: SIGKILL leaves the folder; an aged folder is swept at the next start
  after SIGKILL: pkg-cache-93215-83093f2d-eceb-4f7c-a96f-99375c2a0532
  after restart, aged folder swept? yes
  folders now: pkg-cache-93244-16d6b7a0-950d-469f-b432-fa9734a620b5
  exit: 0
  after SIGTERM, tmp holds: ''

Without this change, on the same tree, ~/.cache/pkg/tmp-d8roSV is created during the start and is still there after SIGTERM.

The packaged path is asserted by the smoke test now, on three platforms:

$ node .github/scripts/smoke-executable.mjs executables/api-linux-fixed
the executable served /ddc-host and /ddc-host.js
the executable unpacked 192 file(s) into /tmp/sasjs-smoke-DijBOE/sasjs_root/tmp/pkg-cache-93335-76c9f395-d359-4aef-a3b2-9ddd28467f1c, not /opt/data/.cache/pkg
the executable answered http://127.0.0.1:46305/SASjsApi/info

and it bites - run with the cache forced elsewhere, the same binary fails the job:

$ PKG_NATIVE_CACHE_PATH=$HOME/.cache/pkg node .github/scripts/smoke-executable.mjs executables/api-linux-fixed
::error title=smoke-executable::expected one pkg cache folder under /tmp/sasjs-smoke-JxYtJ1/sasjs_root/tmp, found 0

Tests and gates

  • api/src/utils/specs/pkgCache.spec.ts - eight cases: a plain node process is left alone; an executable gets a folder whose name carries the pid and a uuid; two instances get different folders; an operator-set PKG_NATIVE_CACHE_PATH wins; the exit handler removes the folder; the heartbeat moves the folder's own mtime; the sweep removes a folder nothing has refreshed for a day and keeps a recent one; the sweep touches nothing it did not name. Proven to bite: dropping the heartbeat's utimesSync fails the heartbeat case.
  • npx jest with the CI env: 64 suites / 747 tests green.
  • npx tsc --noEmit -p . clean; prettier@3.9.9 --check clean over api/src.

Docs

README.md - the SASJS_ROOT block and the standalone-executable section say where the unpacked assets live, that a killed process's folder is swept once a day old, that PKG_NATIVE_CACHE_PATH (pkg's own variable) moves it, and that native addons are unpacked before this code runs.

server.sasjs.io: SASJS_ROOT in docs/settings.md, in sasjs/server.sasjs.io#32.

@sasjs-dev
sasjs-dev Bot force-pushed the fix/pkg-cache-under-sasjs-root branch 2 times, most recently from 15191eb to 97a7443 Compare November 4, 2026 13:42

@sasjs-dev sasjs-dev Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REVIEWED ON 2026-11-04 13:58 UTC

Reviewed the diff against yao-pkg/pkg's prelude (pkgCacheDir, deflateSync, patchDlopen) and the executable smoke harness; three findings.

  • api/src/utils/pkgCache.ts:117 - naming the cache folder pkg-cache-<pid> assumes a pid identifies a process across restarts and instances. Under Docker (README.md:470 documents --stop-timeout for exactly this deployment) the server is pid 1 in every container, so a pkg-cache-1 left by an OOM-killed or SIGKILLed instance looks alive to the sweep at line 119 (pidIsAlive returns true for its own pid), is reused by the mkdirSync at line 121, and gains another pkg/tmp-XXXXXX on every start - a crash-loop accumulates the residue this change exists to remove. Two instances sharing one SASJS_ROOT collide the other way: both name the folder pkg-cache-1, and the first to exit deletes the other's live assets. Make the folder unique (${cacheFolderPrefix}${process.pid}-${randomUUID()}) and sweep on age rather than on process.kill(pid, 0), which cannot tell pids apart across namespaces.

  • api/src/utils/specs/pkgCache.spec.ts:1 - the packaged path is never exercised: each spec injects packaged: true into an ordinary node process, and .github/scripts/smoke-executable.mjs boots the real binary but asserts nothing about where pkg unpacked. The assumption the change rests on - that setting PKG_NATIVE_CACHE_PATH during setProcessVariables (api/src/app.ts:44) still redirects extraction - holds only until pkg's first extraction, because prelude/bootstrap.js pins its tmpFolder on first use (if (!tmpFolder) { ... }) and reads the variable once. Any snapshot file read during module import, before that await resolves, pins ~/.cache/pkg for the life of the process. Assert it in the smoke script, which already sets SASJS_ROOT: a tmp/pkg-cache-* folder appears there and ~/.cache/pkg does not.

  • README.md:78 - "removed when the process exits" is stated unconditionally, while the code's own note says pkg's removal never runs for process.exit() and a killed process leaves the folder to the sweep, and that ~/.cache/pkg can still appear for a native addon. One sentence for each caveat keeps the operator's expectation honest.

3 findings above for review.

@sasjs-dev
sasjs-dev Bot force-pushed the fix/pkg-cache-under-sasjs-root branch from 97a7443 to f12c4ee Compare November 4, 2026 14:08
@sasjs-dev

sasjs-dev Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

All three actioned in ee95f31 (the first push, f12c4ee, failed the lint job on spec formatting - prettier reflow only, no assertion changed).

1. pid is not an identity across namespaces (pkgCache.ts). Correct, and it invalidated the sweep rather than just the name. The folder is now pkg-cache-<pid>-<uuid> - unique per instance, so two containers that are both pid 1 never share one, and the exit handler can only remove its own. Liveness moved off process.kill(pid, 0) entirely: a running instance refreshes its folder's own mtime hourly on an unref'd timer, and a start sweeps pkg-cache-* folders that nothing has refreshed for a day (staleAfterMs). Nothing is removed on the strength of a pid. A spec pins that two instances get different folders, and another that the heartbeat moves the mtime (proven to bite: dropping the utimesSync fails it).

2. the packaged path is untested. Added to .github/scripts/smoke-executable.mjs, which already sets SASJS_ROOT and now runs on all three smoke jobs: exactly one pkg-cache-* folder under the run's SASJS_ROOT, non-empty, and no new entry in ~/.cache/pkg (baseline captured before spawn). Ran it against a locally built binary:

the executable unpacked 192 file(s) into /tmp/sasjs-smoke-DijBOE/sasjs_root/tmp/pkg-cache-93335-76c9f395-d359-4aef-a3b2-9ddd28467f1c, not /opt/data/.cache/pkg

and it bites - the same binary with PKG_NATIVE_CACHE_PATH forced elsewhere fails the check: expected one pkg cache folder under .../sasjs_root/tmp, found 0.

3. README overclaimed. It now says the folder is removed on exit, that one left by a process killed outright is removed by a later start once it is a day old, that PKG_NATIVE_CACHE_PATH is pkg's own variable, and that pkg unpacks native addons before this server's code runs - none are bundled today.

Also re-measured on the rebuilt binary: 192 files under <SASJS_ROOT>/tmp/pkg-cache-<pid>-<uuid> during the run and gone after SIGTERM, nothing new in ~/.cache/pkg; after a SIGKILL the folder stays, and once aged past a day the next start sweeps it. npx jest 64 suites / 747 tests green, tsc --noEmit and prettier@3.9.9 --check clean.

A packaged executable reads its bundled files from a snapshot, and pkg
externalises each one it is asked for into `pkgCacheDir()` - `~/.cache/pkg`
unless `PKG_NATIVE_CACHE_PATH` says otherwise - under a random `tmp-XXXXXX`
name. It registers that folder's removal on `beforeExit`, which is not emitted
for `process.exit()` (yao-pkg/pkg#52), and this server's shutdown path ends with
exactly that call. Every start that refreshed the drive therefore left a folder
holding the 192 macro files it copied in the service account's home, where
nothing looks and nothing cleans (#523).

`configurePkgCache` now directs the cache into `<SASJS_ROOT>/tmp/pkg-cache-<pid>-<uuid>`
and removes it from an `exit` handler, which runs on `process.exit()` as well as
on a drained event loop, and whose synchronous removal completes before the
process goes. A `PKG_NATIVE_CACHE_PATH` the operator has set is left alone.

Three decisions behind that shape:

- `exit`, not `beforeExit`: `exit` runs on `process.exit()`, and the removal is
  synchronous, which is the route the upstream issue names as working.
- The name carries a random id as well as the pid, so two instances never share
  a folder - including two containers, where this server is pid 1 in each, and
  where a reused name would also have made a dead instance's folder look alive.
- Liveness for the sweep is a timestamp the instance refreshes hourly, not a pid
  probe: a pid means nothing outside its own namespace, so the sweep removes by
  age instead. A folder is only swept once nothing has refreshed it for a day.

The packaged path itself is now asserted by `.github/scripts/smoke-executable.mjs`,
which boots the real binary on three platforms: exactly one `pkg-cache-*` folder
under the run's SASJS_ROOT, holding extracted files, and nothing new in
`~/.cache/pkg`. That assertion is the only thing that can prove the redirect
lands before pkg's first extraction - a source checkout and the npm package run
no pkg prelude at all.

Scope: pkg resolves the directory it extracts NATIVE ADDONS into when its prelude
loads, before any application code runs, so `~/.cache/pkg` can still appear if a
native addon is ever added. This server ships none - the assets here are the ones
the application reads, and the README says so.

Measured on a built linux executable at this commit, desktop mode, fresh
SASJS_ROOT:

- before: `~/.cache/pkg/tmp-d8roSV` appears during the start, holds 192 files,
  and is still there after SIGTERM;
- after: nothing is created in `~/.cache/pkg`, the 192 files sit in
  `<SASJS_ROOT>/tmp/pkg-cache-<pid>-<uuid>` during the run, and the folder is
  gone after SIGTERM;
- after a SIGKILL the folder is still there, and once it is a day old the next
  start removes it while creating its own.
@sasjs-dev
sasjs-dev Bot force-pushed the fix/pkg-cache-under-sasjs-root branch from f12c4ee to ee95f31 Compare November 4, 2026 14:12
Removed unnecessary line breaks and cleaned up text formatting in the README.
@allanbowe
allanbowe merged commit 92da63c into main Nov 4, 2026
10 checks passed
@allanbowe
allanbowe deleted the fix/pkg-cache-under-sasjs-root branch November 4, 2026 14:22
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 3.14.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pkg leaves its extracted assets behind in ~/.cache/pkg on every drive refresh

1 participant