Skip to content

Commit a1ec33e

Browse files
committed
feat: US-057 - Fix top-level await semantics for Node.js ESM execution
1 parent 98b8e65 commit a1ec33e

9 files changed

Lines changed: 633 additions & 155 deletions

File tree

.agent/contracts/node-runtime.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,21 @@ The `__dynamicImport` bridge function SHALL return a Promise that resolves to th
168168
- **WHEN** user code calls `await import("./nonexistent")`
169169
- **THEN** the returned Promise MUST reject with an error indicating the module cannot be resolved
170170

171+
### Requirement: ESM Top-Level Await Completes Before Execution Finalization
172+
When sandboxed ESM execution uses top-level `await`, the runtime SHALL keep the entry-module evaluation promise alive until it settles instead of finalizing execution early.
173+
174+
#### Scenario: ESM exec waits for entry-module top-level await
175+
- **WHEN** `exec()` runs an ESM entrypoint whose top-level `await` waits on later async work such as timers or promise-driven startup
176+
- **THEN** the execution result MUST not be returned until the awaited work finishes and post-`await` statements have run
177+
178+
#### Scenario: Static imports wait for transitive top-level await
179+
- **WHEN** an ESM entrypoint statically imports a dependency that uses top-level `await`
180+
- **THEN** the entrypoint MUST not continue past the import until the dependency's async module evaluation has completed
181+
182+
#### Scenario: Dynamic import waits for imported module top-level await
183+
- **WHEN** sandboxed code executes `await import("./mod.mjs")` and `./mod.mjs` contains top-level `await`
184+
- **THEN** the import Promise MUST not resolve until the imported module's async evaluation has completed and its namespace is ready
185+
171186
### Requirement: Configurable CPU Time Limit for Node Runtime Execution
172187
The Node runtime MUST support an optional `cpuTimeLimitMs` execution budget for sandboxed code and MUST enforce it as a shared per-execution deadline across runtime calls that execute user-controlled code.
173188

@@ -179,6 +194,10 @@ The Node runtime MUST support an optional `cpuTimeLimitMs` execution budget for
179194
- **WHEN** a caller configures `cpuTimeLimitMs` and execution spends time across multiple user-code phases (for example module evaluation plus later active-handle waiting)
180195
- **THEN** the runtime MUST apply one shared budget across phases rather than resetting timeout per phase
181196

197+
#### Scenario: Top-level await timeout uses the shared deadline
198+
- **WHEN** an ESM entrypoint is still awaiting async module startup and later awaited work exceeds `cpuTimeLimitMs`
199+
- **THEN** the runtime MUST surface the same timeout failure contract instead of returning a successful result early
200+
182201
#### Scenario: Timeout contract is deterministic
183202
- **WHEN** execution exceeds a configured `cpuTimeLimitMs`
184203
- **THEN** the runtime MUST return `code` `124` and include `CPU time limit exceeded` in stderr

docs-internal/friction.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,9 @@
150150
- Symptom: compatibility fixtures paid repeated `copy + pnpm install` cost even when fixture inputs were unchanged.
151151
- Fix: added persistent fixture install cache under `packages/secure-exec/.cache/project-matrix/` keyed by fixture/toolchain/runtime factors with `.ready` marker semantics. Repeated `test:project-matrix` runs now reuse prepared installs.
152152

153-
7. TODO: follow up on lazy dynamic-import edge cases in ESM execution.
154-
- Symptom: `filePath: "/entry.mjs"` with top-level `await import("./mod.mjs")` can log pre-import output and imported-module side effects but miss post-await statements.
155-
- Next step: add a dedicated ESM top-level-await + dynamic-import regression test.
153+
7. **[resolved]** ESM top-level await could finalize before async startup completed.
154+
- Symptom: `filePath: "/entry.mjs"` with top-level `await import("./mod.mjs")` could log pre-import output and imported-module side effects but miss post-await statements.
155+
- Fix: kept the root module evaluation promise alive across the native V8 session event loop and only finalized exports/results after top-level await settled; added runtime-driver regressions for entrypoint, transitive-import, dynamic-import, and timeout coverage.
156156

157157
7. **[resolved]** Dynamic import error/fallback path masked ESM failures behind CJS-style wrappers.
158158
- Symptom: ESM compile/evaluation failures could be rethrown as generic dynamic-import errors, and fallback namespace construction could throw for primitive/null CommonJS exports.
@@ -180,9 +180,9 @@
180180
- Symptom: requests like `require('./request')` failed when both `request/` and `request.js` existed.
181181
- Fix: changed resolver order to match Node behavior: file + extension probes run before directory index/package resolution.
182182

183-
6. ESM + top-level await in this runtime path can return early for long async waits.
183+
6. **[resolved]** ESM + top-level await in this runtime path could return early for long async waits.
184184
- Symptom: module evaluation could finish before awaited async work (timers/network) completed.
185-
- Mitigation for example: runner switched to CJS async-IIFE, which `exec()` already awaits reliably.
185+
- Fix: native V8 ESM execution now defers finalization until the entry-module evaluation promise settles, so long async startup follows Node-style top-level-await semantics instead of requiring a CJS async-IIFE workaround.
186186

187187
7. `secure-exec` package build currently fails due to broad pre-existing type errors in bridge/browser files.
188188
- Symptom: importing `secure-exec` from `dist/` in example loader was not reliable in this workspace state.

0 commit comments

Comments
 (0)