Conversation
This test reproduces a regression introduced in 4.0.1 where a spawned task can no longer respond to signals from its parent's finally block during cleanup. Pattern (works in 4.0.0, deadlocks in 4.0.1): 1. Resource spawns a consumer task that waits on a signal 2. Finally block resolves the signal to notify consumer 3. Finally block waits for consumer to confirm it processed the signal This pattern is used by @effectionx/websocket's useWebSocket, where: - A spawned task consumes the message stream - Finally block calls socket.close() (triggering close event) - Finally block waits for the consumer to see the close The regression was introduced by PRs #1081 and #1085 which changed the task finalization order.
commit: |
Analysis of the Root CauseAfter investigating the commits between 4.0.0 and 4.0.1, I identified the regression was introduced by:
The ProblemThe key change is in the order of operations during cleanup: Before (4.0.0):
After (4.0.1):
Code FlowIn scope.ensure(function* () {
try {
yield* top.close(); // Runs parent's finally block
} finally {
// ... resolve/reject future
}
});
let routine = createCoroutine({
scope,
*operation() {
try {
yield* top;
} finally {
yield* destroy(); // Destroys spawned tasks, then runs ensure callbacks
}
},
});When Proposed FixThe fix needs to ensure that parent's finally block runs while spawned tasks are still alive. Option 1: Run
|
Refined Fix Proposal (Preserving Memory Leak Fix)After further consideration, the fix must preserve the memory leak fix from PR #1081 where The Core ProblemThe issue is the order of operations inside We need: Recommended Fix: Explicit delimiter close in
|
| Scenario | Behavior |
|---|---|
| Normal completion | Coroutine ends → destroy() → delimiter already finalized (no-op) → destructors run ✓ |
task.halt() |
destroy() → delimiter closes (finally runs) → destructors run ✓ |
| Error in task | Coroutine throws → destroy() → delimiter closes → destructors run ✓ |
| Memory leak | destroy() still called → scope cleaned up ✓ |
| Nested resources | Each scope's destroy() closes its delimiter before children ✓ |
Import Required
lib/scope-internal.ts will need to import DelimiterContext:
import { DelimiterContext } from "./delimiter.ts";This creates a dependency from scope-internal to delimiter, but this seems acceptable since scopes and delimiters are already tightly coupled in the task system.
Add setFinalizer() to ScopeInternal that runs BEFORE regular destructors. Use this for task delimiter closing, ensuring that a parent task's finally block can communicate with spawned child tasks before they are destroyed. This fixes a regression introduced in 4.0.1 where changes to the destruction order caused deadlocks when finally blocks tried to signal spawned tasks. Fixes #1090
This PR was generated by Opus 4.5. I do not expect this PR to be merged. I just wanted to capture the context that accumulated while troubleshooting the
@effectionx/websocketextension.Summary
This PR adds a test that reproduces a regression introduced in 4.0.1 and includes the fix.
Test Results
The Pattern
This pattern:
Works in 4.0.0, deadlocks in 4.0.1
Real-World Impact
This pattern is used by `@effectionx/websocket`'s `useWebSocket`, where:
See: thefrontside/effectionx#134
Root Cause
The regression was introduced by PRs #1081 and #1085 which changed the task finalization order. In 4.0.1:
Before (4.0.0):
After (4.0.1, broken):
The Fix
Added a `setFinalizer()` mechanism to `ScopeInternal` that runs BEFORE regular destructors during `destroy()`.
Changes
lib/scope-internal.ts:
lib/task.ts:
Why This Works
The finalizer ensures the task's own delimiter closes (running its finally block) before any child destructors run. This restores the 4.0.0 behavior where:
Diff
Expected Behavior
When a task is halted, spawned tasks within that scope should still be able to: