Effect.fn stack frames keep the call's arguments alive in forked fibers

Đã đóng
#9,877 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

Maintainer thường phản hồi trong vòng 1 ngày

Một pull request liên quan đã được merge.

  • #9878 của @Hona — đã merge

Đánh giá

Độ khó
2/5
Thời gian dự kiến
1-3 giờ
Mức phù hợp với người mới
25/100
Loại issue
Lỗi
Độ rõ ràng
Đặc tả rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
node.js, typescript
Lĩnh vực
performance

Hướng nghiên cứu

Investigate makeFn in packages/effect/src/internal/effect.ts where the stack frame closure captures arguments. Run the provided Node repro to observe retained. A fix moves frame building to a helper outside the traced function; expected outcome is collected and open PR #9878 already implements it.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

What is the problem?

An Effect.fn call's stack frame keeps every argument of that call alive. A fiber forked inside the call inherits the frame through CurrentStackFrame, so the arguments of every traced call above it stay reachable for as long as that fiber lives, long after the calls have returned.

Reproduction

Run with Node against [email protected]:

import { Effect, Fiber } from "effect"
import { setFlagsFromString } from "node:v8"
import { runInNewContext } from "node:vm"

setFlagsFromString("--expose_gc")
const gc = runInNewContext("gc") as () => void

const start = Effect.fn("start")(function*(input: { payload: string }) {
  yield* Effect.annotateCurrentSpan("payload", input.payload.length)
  return yield* Effect.forkDetach(Effect.never)
})

let reference: WeakRef<object> | undefined
const fiber = await (async () => {
  const input = { payload: "x".repeat(1024) }
  reference = new WeakRef(input)
  return Effect.runPromise(start(input))
})()
for (let i = 0; i < 8; i++) {
  await new Promise((resolve) => setTimeout(resolve, 0))
  gc()
}
console.log(reference!.deref() ? "retained" : "collected")
await Effect.runPromise(Fiber.interrupt(fiber))
Result
Expected collected: start has returned and nothing references input
Actual retained
Effect.fnUntraced collected

Bun shows the same behaviour.

Cause

makeFn in packages/effect/src/internal/effect.ts builds the frame inside the traced function:

return defineFunctionLength(body.length, function(this: any, ...args: Array<any>) {
  let result = suspend(() => {
    const iter = body.apply(this, arguments) // closes over `arguments`
    ...
  })
  ...
  return updateService(result, CurrentStackFrame, (prev) => ({
    name,
    stack: callError ? fnStackCleaner(() => callError.stack) : constUndefined, // same scope
    parent: { name: definitionName, stack: definitionStack, parent: prev }
  }))
})

The stack closure only reads callError. Engines allocate one context per scope, though, and this scope also holds arguments, which the suspend thunk uses. So the frame's stack closure keeps the whole arguments object alive:

flowchart LR
  F["forked fiber"] --> C["context: CurrentStackFrame"]
  C --> S["frame.stack closure"]
  S --> A["scope: arguments"]
  A --> I["every call argument"]
Impact

We found this in a production heap snapshot of a long-running server (opencode). 152 fibers that outlive their calls kept the frame chain … < SessionRunner.runStep, for example:

  • watchers and plugin services first started inside a request;
  • background job notifiers.

runStep receives the full loaded conversation as its first argument, so those fibers kept 117 MB of message history (including base64 screenshots) alive indefinitely.

Proposed fix

Build the frame in a helper outside the traced function, so the closure's context holds only name, callError and the definition frame. PR: https://github.com/Effect-TS/effect/pull/9878

Ngôn ngữ chính
TypeScript
Star
16.7k
Fork
808
Merge trung bình
11 giờ 31 phút
Pull request đã merge (30 ngày)
834

Chuẩn bị môi trường

Dự án này không cung cấp dev container, Dockerfile hay hướng dẫn đóng góp, nên bạn cần tự thiết lập môi trường: hãy bắt đầu từ README và xem hướng dẫn đóng góp lần đầu của chúng tôi để biết các bước chung.

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của Effect-TS/effect

Tất cả issue của Effect-TS/effect

Issue tương tự

Thêm issue về TypeScript

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.