RpcClient.layerProtocolWorker: what's the equivalent of Piscina's abortable tasks? (terminate a stuck worker on interrupt, control respawn)
Maintainer thường phản hồi trong vòng 1 ngày
@effect-bot đang làm issue này rồi.
Từ ngày 16/10/2026.
Đánh giá
Issue này chưa được đánh giá.
Mô tả
What is the problem this feature would solve?
We moved CPU-heavy work in a Node app (article extraction with jsdom, filter-list compiles) from Piscina to effect/rpc over worker threads (RpcClient.layerProtocolWorker with NodeWorker.layer). Typed requests, spans that cross into the worker, and Effect's interruption are a real improvement. What we couldn't find is an equivalent of Piscina's abortable tasks, so we rebuilt it around the protocol. Is there an intended way to do this that we missed?
With Piscina, pool.run(task, { signal }) gives two things:
- Aborting a running task terminates its thread. The README says "If the task is already running, the corresponding
Workerthread will be stopped" (README, source). So a deadline (AbortSignal.timeout) stops code that never yields, such as jsdom spinning on a hostile page. Abortable tasks never share a thread, so the kill takes no other task with it. A task still queued is just removed. - A dead thread is replaced only up to
minThreads. Replacement stops altogether once threads fail during bootstrap (source). WithminThreads: 0, the next task starts a fresh thread, and nothing is spawned while no task is waiting.
With layerProtocolWorker, the repro below shows four behaviours. It ran on effect and @effect/platform-node 4.0.4, Node 24.21.0, macOS arm64; the code involved is unchanged on main at 5462ed7.
- Interrupting a running request doesn't stop its thread. The client sends
Interrupt, which the worker handles only when the handler yields. A synchronous handler keeps the thread at 100% CPU. The protocol also releases the pool entry as soon as it sendsInterrupt(RpcClient.ts#L1452-L1458). So the next request goes to the same thread and waits behind the abandoned work: 3.5 s in the repro. - The only way to stop the thread is to close the client's scope, and that takes 5 s.
NodeWorkerposts a close message, waits for the exit underEffect.timeout(5000), and only then terminates (NodeWorker.ts#L55-L64). The 5 s isn't configurable. If you start a replacement meanwhile, two threads run during those 5 s. - A thread that exits is respawned a second later, whether or not a request is waiting. This is
Effect.retry(Schedule.spaced(1000))around the worker'srun(RpcClient.ts#L1397). #7147 kept the respawn deliberately. - A module that can't load is respawned every second for as long as the scope is open: five spawns in 4.5 s.
Repro
npm i [email protected] @effect/[email protected]
node main.mjs
rpcs.mjs
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/rpc"
export class Rpcs extends RpcGroup.make(
// Synchronous work that never yields, like jsdom on a hostile page
Rpc.make("Spin", { payload: { ms: Schema.Number }, success: Schema.Number }),
Rpc.make("Ping", { success: Schema.Number }),
// The thread dies mid-request, as it would at its heap limit
Rpc.make("Crash", {})
) {}
worker.mjs
import { threadId } from "node:worker_threads"
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import * as NodeWorkerRunner from "@effect/platform-node/NodeWorkerRunner"
import { Effect, Layer } from "effect"
import { RpcServer } from "effect/rpc"
import { Rpcs } from "./rpcs.mjs"
const handlers = Rpcs.toLayer({
Spin: ({ ms }) =>
Effect.sync(() => {
const end = Date.now() + ms
while (Date.now() < end) {
// never yields
}
return threadId
}),
Ping: () => Effect.succeed(threadId),
Crash: () => Effect.sync(() => process.exit(1))
})
RpcServer.layer(Rpcs).pipe(
Layer.provide(handlers),
Layer.provide(RpcServer.layerProtocolWorkerRunner),
Layer.provide(NodeWorkerRunner.layer),
Layer.launch,
NodeRuntime.runMain
)
broken-worker.mjs
// A worker module that can't load, e.g. a missing native dependency
throw new Error("cannot load this module")
main.mjs
import { Worker } from "node:worker_threads"
import * as NodeWorker from "@effect/platform-node/NodeWorker"
import { Duration, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { RpcClient } from "effect/rpc"
import { Rpcs } from "./rpcs.mjs"
let t0 = performance.now()
const log = (...args) => console.log(`[${((performance.now() - t0) / 1000).toFixed(2)}s]`, ...args)
const threads = []
const spawner = (file) =>
NodeWorker.layer(() => {
const worker = new Worker(new URL(file, import.meta.url))
const n = threads.push(worker)
log(`spawned thread #${n}`)
worker.on("exit", (code) => log(`thread #${n} exited (code ${code})`))
return worker
})
const makeClient = (scope, file) =>
Layer.buildWithScope(RpcClient.layerProtocolWorker({ size: 1 }).pipe(Layer.provide(spawner(file))), scope).pipe(
Effect.flatMap((context) => RpcClient.make(Rpcs).pipe(Effect.provideContext(context), Scope.provide(scope)))
)
const section = (title) =>
Effect.sync(() => {
threads.length = 0
t0 = performance.now()
console.log(`\n=== ${title}`)
})
const program = Effect.gen(function*() {
yield* section("1. A request that times out leaves its thread running")
{
const scope = yield* Scope.make()
const client = yield* makeClient(scope, "./worker.mjs")
yield* client.Ping()
log("Spin(5000) with a 500 ms timeout ->", yield* client.Spin({ ms: 5000 }).pipe(Effect.timeoutOption("500 millis")))
const before = threads[0].performance.eventLoopUtilization()
yield* Effect.sleep("1 second")
const after = threads[0].performance.eventLoopUtilization(before)
log(`thread #1 utilisation over the next second: ${after.utilization.toFixed(2)}`)
const [took] = yield* Effect.timed(client.Ping())
log(`the next Ping took ${Duration.format(took)}, on the same thread, behind the abandoned Spin`)
yield* Scope.close(scope, Exit.void)
}
yield* section("2. Closing the scope while a task is stuck takes NodeWorker's 5 s")
{
const scope = yield* Scope.make()
const client = yield* makeClient(scope, "./worker.mjs")
yield* client.Ping()
yield* client.Spin({ ms: 30_000 }).pipe(Effect.timeoutOption("500 millis"))
log("Spin(30000) timed out; closing the client's scope")
const [took] = yield* Effect.timed(Scope.close(scope, Exit.void))
log(`scope closed after ${Duration.format(took)}`)
}
yield* section("3. A thread that dies is respawned a second later, with nothing waiting")
{
const scope = yield* Scope.make()
const client = yield* makeClient(scope, "./worker.mjs")
yield* client.Ping()
log("Crash ->", (yield* Effect.exit(client.Crash())).toString().slice(0, 120))
yield* Effect.sleep("2500 millis")
log(`threads spawned: ${threads.length}`)
yield* Scope.close(scope, Exit.void)
}
yield* section("4. A module that can't load is respawned every second, forever")
{
const scope = yield* Scope.make()
const client = yield* makeClient(scope, "./broken-worker.mjs")
const ping = yield* Effect.forkChild(Effect.exit(client.Ping()))
yield* Effect.sleep("4500 millis")
log(`threads spawned in 4.5 s: ${threads.length}`)
const result = ping.pollUnsafe()
log("the Ping sent at the start:", result ? result.toString().slice(0, 160) : "still waiting")
yield* Fiber.interrupt(ping)
yield* Scope.close(scope, Exit.void)
}
})
Effect.runPromise(program).then(
() => process.exit(0),
(error) => {
console.error(error)
process.exit(1)
}
)
Output:
=== 1. A request that times out leaves its thread running
[0.01s] spawned thread #1
[0.78s] Spin(5000) with a 500 ms timeout -> { _id: 'Option', _tag: 'None' }
[1.78s] thread #1 utilisation over the next second: 1.00
[5.28s] the next Ping took 3s 497ms 937833ns, on the same thread, behind the abandoned Spin
[5.29s] thread #1 exited (code 130)
=== 2. Closing the scope while a task is stuck takes NodeWorker's 5 s
[0.00s] spawned thread #1
[0.65s] Spin(30000) timed out; closing the client's scope
[5.65s] thread #1 exited (code 1)
[5.65s] scope closed after 5s 3ms 373292ns
=== 3. A thread that dies is respawned a second later, with nothing waiting
[0.00s] spawned thread #1
[0.19s] thread #1 exited (code 1)
[0.19s] Crash -> Failure(Cause([Fail(~effect/rpc/RpcClientError: WorkerReceiveError: The worker has exited with code: 1)]))
[1.20s] spawned thread #2
[2.70s] threads spawned: 2
[2.70s] thread #2 exited (code 130)
=== 4. A module that can't load is respawned every second, forever
[0.00s] spawned thread #1
[0.02s] thread #1 exited (code 1)
[1.02s] spawned thread #2
[1.05s] thread #2 exited (code 1)
[2.05s] spawned thread #3
[2.07s] thread #3 exited (code 1)
[3.07s] spawned thread #4
[3.08s] thread #4 exited (code 1)
[4.09s] spawned thread #5
[4.10s] thread #5 exited (code 1)
[4.50s] threads spawned in 4.5 s: 5
[4.50s] the Ping sent at the start: Success(Failure(Cause([Fail(~effect/rpc/RpcClientError: WorkerReceiveError: An error event was emitted)])))
What is the feature you are proposing to solve the problem?
Three suggestions. Any one of them would remove part of our wrapper; together they would cover what Piscina gave us.
-
Terminate on interrupt. An option on
layerProtocolWorker(for exampleinterruptTerminates: true), or an annotation perRpc. When a request is interrupted while its worker runs it, the protocol would:- terminate that worker at once, with no graceful close;
- fail any other requests on that worker;
- drop the worker from the pool, so the next request gets a fresh one.
As in Piscina, such requests would run one per worker. Ideally the interruption would complete only after the worker's
exitevent, so that aSemaphorearound the call bounds the real number of threads. -
A respawn policy. For example
respawn: false | ScheduleonlayerProtocolWorker.falsewould invalidate the pool entry and let the nextPool.getspawn lazily. Separately, when a worker dies before its ready handshake, the protocol would stop respawning and fail waiting requests with the worker's error, as Piscina does once bootstrap fails. -
A configurable close timeout on
NodeWorker.layer, like Piscina'scloseTimeout, where 0 would mean terminate at once.
What alternatives have you considered?
-
Keeping Piscina behind a scoped service. It works, but we would lose typed requests, spans into the worker and Effect's interruption, and the queue would sit outside Effect.
-
What we run now: a wrapper around
layerProtocolWorker({ size: 1 }), one per worker module.- It keeps the
Workerhandle. On a deadline or an interrupt, it callsworker.terminate()itself, waits for the exit, then closes the protocol's scope. - Its spawner throws on a second spawn, so the protocol's retry can't bring the thread back. The next task builds a new protocol with a fresh thread.
- One
Semaphorebounds threads across all modules.
It works, but it depends on the protocol's internals, namely which spawn happens when, rather than on an API.
- It keeps the
-
Cooperative cancellation inside the worker. That isn't possible for third-party synchronous code such as jsdom.
- Ngôn ngữ chính
- TypeScript
- Star
- 16.7k
- Fork
- 808
- Merge trung bình
- 10 giờ 52 phút
- Pull request đã merge (30 ngày)
- 836
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
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- 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.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của Effect-TS/effect
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 75/100
Maintainer thường phản hồi trong vòng 1 ngày
-
RpcServer: with disableFatalDefects, a handler that throws before returning its Effect still fails every pending callCó thể đã có người làm @effect-bot đã nhận hôm nay. Đang mở
Effect-TS/effect#9892 · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Duration: ISO 8601 format/parse and a Schema codec (exact units only)Có thể đã có người làm @jayhemnani9910 đã nhận hôm nay. Đang mở
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 25/100
Effect-TS/effect#9888 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
HttpApiClient: `Redacted` query and header values appear in plain text in client spansCó thể đã có người làm @effect-bot đã nhận hôm nay. Đang mở
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 30/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Effect.fn stack frames keep the call's arguments alive in forked fibersCó thể đã có người làm @Hona đã nhận hôm nay. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 25/100
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của Effect-TS/effect
Issue tương tự
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
awesome-dsh-plugin/awesome-dsh-plugin#9184 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 85/100
farion1231/cc-switch#8829 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
[core-client] Remove unused hand-maintained transport helpers after the definition-table cutoverĐang mởdoc-audit enhancement
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 88/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 85/100
-
neon functions new: Next: hint says to export env vars for neon dev, but dev loads .env.localĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 82/100
neondatabase/neon-pkgs#941 ·
Maintainer thường phản hồi trong vòng 1 ngày