Skip to content

perf(proxies): avoid stack capture with explicit ownership - #8

Merged
Upd4ting merged 3 commits into
mainfrom
perf/explicit-module-ownership
Aug 19, 2026
Merged

perf(proxies): avoid stack capture with explicit ownership#8
Upd4ting merged 3 commits into
mainfrom
perf/explicit-module-ownership

Conversation

@Upd4ting

@Upd4ting Upd4ting commented Aug 18, 2026

Copy link
Copy Markdown
Member

🔗 Linked issue

N/A

❓ Type of change

  • 👌 Enhancement (improving an existing functionality like performance)
  • ✨ New feature (a non-breaking change adds an opt-in ownership API)

📚 Description

GetResponsibleModule currently changes Error.prepareStackTrace and Error.stackTraceLimit, captures an unlimited stack, then walks every frame for every provider/handler registration. This PR adds the smallest explicit ownership boundary:

RunWithResponsibleModule(moduleId, () => loadOrRunModule());

The context uses AsyncLocalStorage, so nested contexts, thrown errors, promises, and async continuations retain the correct owner. Each loaded generation has an opaque token. ModuleDestroyed invalidates that token before cleanup, making detached work from the destroyed generation fail with ModuleContextInvalidatedError before it can mutate a proxy. A reload of the same module ID receives a fresh token; old nested or concurrent contexts remain invalid.

GetResponsibleModule checks explicit ownership first. Calls outside a context still execute the existing stack resolver unchanged, preserving backward compatibility and automatic detach/cleanup.

No attribution is bypassed: proxy attachment and registration continue to store the resolved module ID, and ModuleDestroyed continues to detach providers and remove registrations/events.

Integration left for @antelopejs/core

A later core change should wrap known module-owned boundaries (module evaluation and lifecycle callbacks) with RunWithResponsibleModule(moduleId, callback). Nested module loads should establish nested contexts. Core must use run, not a process-wide enterWith, so concurrent module work remains isolated. This PR does not change another repository.

Benchmark

Protocol:

  • Node 20.9.0, Linux x64, pnpm 10.6.5
  • compiled CommonJS output (dist), node --expose-gc
  • 9 independent processes per case; 100 stack-resolution warmups
  • explicit-context series also warm the one-time AsyncLocalStorage initialization
  • reported time is median ns/operation with IQR; memory is median retained heap bytes after forced GC
  • before = stack attribution; after = the same operations inside an explicit benchmark owner
  • counts 1, 1,000, and 10,000 cover providers/proxies, registering handlers, event handlers, and an ImplementInterface bootstrap fixture
Case n Before ns/op (IQR) After ns/op (IQR) Change Heap before/after bytes
unit ownership resolution 10,000 5,326.1 (125.1) 145.3 (7.1) -97.3% 6,488 / 4,688
AsyncProxy.onCall providers 1 204,391 (32,546) 137,400 (7,531) -32.8% -31,536 / -6,496
1,000 6,536.1 (236.9) 852.8 (96.2) -87.0% 114,560 / 147,208
10,000 6,818.3 (303.7) 633.9 (34.4) -90.7% 1,395,032 / 1,374,000
RegisteringProxy.onRegister providers 1 197,345 (9,208) 150,179 (4,095) -23.9% -32,048 / -5,616
1,000 7,699.5 (165.0) 853.2 (40.7) -88.9% 276,616 / 309,784
10,000 6,945.2 (225.7) 911.4 (15.2) -86.9% 2,996,184 / 2,981,056
RegisteringProxy.register handlers 1 183,124 (11,555) 146,889 (40,341) -19.8% -31,504 / -8,920
1,000 6,218.1 (806.6) 586.6 (22.3) -90.6% -25,744 / -1,792
10,000 6,684.9 (366.3) 434.2 (23.2) -93.5% 25,296 / 15,176
EventProxy.register handlers 1 116,161 (23,205) 81,078 (3,197) -30.2% -18,136 / -7,888
1,000 11,014.3 (608.8) 5,368.4 (840.1) -51.3% 119,968 / 143,080
10,000 16,515.3 (625.0) 8,812.6 (514.3) -46.6% 1,455,456 / 1,451,464
ImplementInterface bootstrap 1 256,139 (20,361) 267,161 (28,288) +4.3% -29,136 / -6,240
1,000 18,996.9 (298.0) 12,543.4 (1,015.9) -34.0% 160,552 / 189,128
10,000 10,385.0 (470.4) 3,357.5 (257.2) -67.7% 1,799,832 / 1,785,744

Negative tiny heap deltas are GC noise. Larger registration workloads retain comparable heap to stack attribution. A separate 9-process cold-start measure puts the first ever context entry at 576.9 µs median (22.3 µs IQR); normal module work amortizes this once per process. Heap at this scale was below GC noise.

The post-change fallback remains 5,323.2 ns/op median (267.2 ns IQR), matching the 5,326.1 ns baseline and confirming that compatibility is preserved rather than short-circuited.

Tests

  • explicit resolution performs zero stack captures
  • fallback still captures and resolves the stack
  • nested and async ownership propagation
  • detached work rejected after destruction
  • same-ID reload gets a new generation without reviving old work
  • nested and concurrent generation invalidation
  • stale provider cannot replace a reloaded provider
  • restoration after errors
  • async/registering provider detachment on module destruction
  • registration replay attribution and consumer cleanup
  • event cleanup
  • hot-reload reattachment
  • stub-mode behavior
  • full existing suite

Validation:

pnpm build                                      pass
pnpm lint                                       pass
pnpm dlx @antelopejs/core module test .         43 passing

📝 Checklist

  • I have updated the documentation accordingly.
  • The public API is backward compatible.
  • Benchmarks include repetitions, median, dispersion, and memory.

Comment thread src/proxies.ts Outdated
@Upd4ting
Upd4ting merged commit 0eb546a into main Aug 19, 2026
2 checks passed
@Upd4ting
Upd4ting deleted the perf/explicit-module-ownership branch August 19, 2026 16:16
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