Skip to content

fix(lock): load proper-lockfile lazily so a Worker can import this package - #97

Merged
drewstone merged 1 commit into
mainfrom
fix/lazy-proper-lockfile
Jul 28, 2026
Merged

fix(lock): load proper-lockfile lazily so a Worker can import this package#97
drewstone merged 1 commit into
mainfrom
fix/lazy-proper-lockfile

Conversation

@drewstone

Copy link
Copy Markdown
Contributor

Every Cloudflare Worker importing this package is undeployable

graceful-fs@4.2.11 patches Node's fs at module scope:

fs.close = (function (fs$close) { ... })(fs.close)

workerd exposes node:fs's close/closeSync as getter-only accessors, so that assignment throws while Cloudflare runs startup validation on upload. The entire Worker is rejected — no request frames, nothing the app can catch:

Uncaught TypeError: Cannot set property close of #<Object> which has only a getter
  at index.js:14662:12
 [code: 10021]

The chain is a static top-level import at every edge:

@tangle-network/agent-runtime  dist/index.js:13        (the ROOT barrel)
  -> @tangle-network/agent-knowledge                   (an EXACT pin inside agent-runtime)
    -> src/mutation-lock.ts:4  import { check, lock } from 'proper-lockfile'
      -> proper-lockfile@4.1.2 -> graceful-fs@4.2.11

That root barrel is the documented home of runToolLoop / streamToolLoop, which is exactly what a product's chat turn imports — so any Worker on agent-runtime@0.108+ that imports the root barrel cannot deploy. It already took down one product's production deploys, which were unblocked with a per-app bundler alias. This is the source fix.

Change

proper-lockfile moves to a dynamic import() inside the two functions that take a lock. Both are already async, so deferring the module scope to the first lock is behaviour-preserving; LockOptions stays a type-only import. The built bundle now carries only lockfileModule ??= import("proper-lockfile").

The check CI was missing

wrangler deploy --dry-run only bundles. It never uploads, so it never executes module scope, and it is structurally blind to this entire class — which is why the breaking change went green.

verify:package (already run in CI after build) now walks the packed dist and fails on any static import of a module that patches a Node builtin.

Proof — the guard fails on the real regression

Reverting src/mutation-lock.ts to the static import and rebuilding:

$ pnpm run build && node scripts/verify-package.mjs
Error: published bundle statically imports a package that patches a Node builtin at module scope.
Cloudflare rejects the whole Worker on upload with code 10021, and a dry run cannot see it.
Load it with a dynamic import() inside the function that needs it.
  - inspect-D5iarJc2.js -> proper-lockfile
GUARD_EXIT_WHEN_BROKEN=1

inspect-D5iarJc2.js is the exact chunk hash published in 6.1.8, so the revert reproduces the shipped bytes.

Restoring the fix:

$ pnpm run build && node scripts/verify-package.mjs
Verified @tangle-network/agent-knowledge@6.1.9 with agent-eval 0.134.2 and agent-interface 0.36.0: clean install, 5 imports, skill, CLI version, and re-pack.
GUARD_EXIT_WHEN_FIXED=0

Proof — the real Worker upload

A minimal Worker whose only import is the agent-runtime root barrel, built with @cloudflare/vite-plugin (the build products use) and uploaded with wrangler versions upload (same startup validation as a deploy, zero traffic).

Before, on agent-knowledge@6.1.8:

$ npx wrangler deploy --dry-run
Total Upload: 1380.33 KiB / gzip: 248.18 KiB
--dry-run: exiting now.
DRYRUN_EXIT=0                      <- green, and blind

$ npx wrangler versions upload
Uncaught TypeError: Cannot set property close of #<Object> which has only a getter
  at index.js:14662:12
 [code: 10021]
BEFORE_FIX_UPLOAD_EXIT=1

The after-upload is run against the published 6.1.9 once this merges and releases, and posted here.

Verification

  • pnpm lint — clean (188 files)
  • pnpm typecheck — clean (src + contracts)
  • pnpm test468 passed | 12 skipped (480)
  • node scripts/verify-package.mjs — passes on the packed tarball

…ckage

`proper-lockfile` pulls in `graceful-fs`, which patches Node's `fs` at
module scope (`fs.close = ...`, `fs.closeSync = ...`). workerd exposes
those as getter-only accessors, so a static import made the assignment
throw while Cloudflare ran startup validation on an uploaded Worker:

  Uncaught TypeError: Cannot set property close of #<Object>
  which has only a getter        [code: 10021]

The whole Worker is rejected — no request frame, nothing the app can
catch — even for bundles that never take a lock. Every downstream
consumer inherits it: `@tangle-network/agent-runtime` pins this package
and re-exports the knowledge subsystem from its ROOT barrel, which is
where a product's chat turn imports `runToolLoop` from. Confirmed by
uploading a Worker whose only import is that root barrel.

Both call sites are already async, so deferring the module scope to the
first lock is behaviour-preserving. `LockOptions` stays a type import.

`verify:package` now walks the packed dist and fails on any static
import of a module that patches a Node builtin. `wrangler deploy
--dry-run` only bundles — it never executes module scope — so it is
structurally blind to this class, which is why the regression shipped
green.

@tangletools tangletools left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Auto-approved drewstone PR — 235def35

This PR was opened by the trusted drewstone account.
The full PR reviewer audit still runs separately and will publish findings if it detects issues.

tangletools · auto-approval · reason: drewstone_author · 2026-07-28T19:12:34Z

@drewstone
drewstone merged commit f455a68 into main Jul 28, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants