@xandreed/process-host 0.0.0-stage → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/index.d.ts +14 -0
- package/dist/index.js +10 -0
- package/dist/ports/process-peer.port.d.ts +22 -0
- package/dist/ports/process-peer.port.js +4 -0
- package/dist/process-host.adapter.d.ts +13 -0
- package/dist/process-host.adapter.js +153 -0
- package/dist/process-host.entity.d.ts +128 -0
- package/dist/process-host.entity.functions.d.ts +12 -0
- package/dist/process-host.entity.functions.js +17 -0
- package/dist/process-host.entity.js +38 -0
- package/dist/process-peer.conformance.d.ts +27 -0
- package/dist/process-peer.conformance.js +99 -0
- package/dist/process-peer.entity.d.ts +37 -0
- package/dist/process-peer.entity.js +40 -0
- package/dist/process-protocol.entity.d.ts +970 -0
- package/dist/process-protocol.entity.functions.d.ts +1495 -0
- package/dist/process-protocol.entity.functions.js +72 -0
- package/dist/process-protocol.entity.js +155 -0
- package/dist/process-runtime.adapter.d.ts +18 -0
- package/dist/process-runtime.adapter.js +49 -0
- package/dist/process-runtime.entity.d.ts +13 -0
- package/dist/process-runtime.entity.js +11 -0
- package/dist/stdio-peer.adapter.d.ts +11 -0
- package/dist/stdio-peer.adapter.js +260 -0
- package/package.json +29 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Xand Reed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @xandreed/process-host
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Efferent composable agent framework, 0.15.1. Node 24 or Bun.
|
|
4
|
+
|
|
5
|
+
Documentation: https://github.com/xandreeddev/efferent
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { PROCESS_PROTOCOL_VERSION, ProcessCommand, ProcessEvent, ProcessEventMode, ProcessRequestId } from "./process-protocol.entity.js";
|
|
2
|
+
export { decodeProcessCommand, encodeProcessEvent, processProgressOf, processLifecycleOf } from "./process-protocol.entity.functions.js";
|
|
3
|
+
export { ProcessPeer } from "./ports/process-peer.port.js";
|
|
4
|
+
export { ProcessPeerError, ProcessPeerReason, ProcessPeerDirection, ProcessPeerOptions, PROCESS_PEER_DEFAULTS } from "./process-peer.entity.js";
|
|
5
|
+
export { processPeerConformance } from "./process-peer.conformance.js";
|
|
6
|
+
export type { ProcessPeerSubject, ProcessPeerFixture } from "./process-peer.conformance.js";
|
|
7
|
+
export { StdioPeerLive } from "./stdio-peer.adapter.js";
|
|
8
|
+
export type { StdioPeerOptions } from "./stdio-peer.adapter.js";
|
|
9
|
+
export { ProcessHostConfig, ProcessHostError, processHostDefaults } from "./process-host.entity.js";
|
|
10
|
+
export type { ProcessBinding, ProcessObservers, ProcessHostOptions, ProcessHostResult } from "./process-host.entity.js";
|
|
11
|
+
export { serveProcess } from "./process-host.adapter.js";
|
|
12
|
+
export { PROCESS_EXIT_CODES } from "./process-runtime.entity.js";
|
|
13
|
+
export { runProcessHost } from "./process-runtime.adapter.js";
|
|
14
|
+
export type { RunProcessHostOptions } from "./process-runtime.adapter.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { PROCESS_PROTOCOL_VERSION, ProcessCommand, ProcessEvent, ProcessEventMode, ProcessRequestId } from "./process-protocol.entity.js";
|
|
2
|
+
export { decodeProcessCommand, encodeProcessEvent, processProgressOf, processLifecycleOf } from "./process-protocol.entity.functions.js";
|
|
3
|
+
export { ProcessPeer } from "./ports/process-peer.port.js";
|
|
4
|
+
export { ProcessPeerError, ProcessPeerReason, ProcessPeerDirection, ProcessPeerOptions, PROCESS_PEER_DEFAULTS } from "./process-peer.entity.js";
|
|
5
|
+
export { processPeerConformance } from "./process-peer.conformance.js";
|
|
6
|
+
export { StdioPeerLive } from "./stdio-peer.adapter.js";
|
|
7
|
+
export { ProcessHostConfig, ProcessHostError, processHostDefaults } from "./process-host.entity.js";
|
|
8
|
+
export { serveProcess } from "./process-host.adapter.js";
|
|
9
|
+
export { PROCESS_EXIT_CODES } from "./process-runtime.entity.js";
|
|
10
|
+
export { runProcessHost } from "./process-runtime.adapter.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
import type { Effect, Stream } from "effect";
|
|
3
|
+
import type { ProcessCommand, ProcessEvent } from "../process-protocol.entity.js";
|
|
4
|
+
import type { ProcessPeerError } from "../process-peer.entity.js";
|
|
5
|
+
declare const ProcessPeer_base: Context.ServiceClass<ProcessPeer, "@xandreed/process-host/ProcessPeer", {
|
|
6
|
+
readonly commands: Stream.Stream<ProcessCommand, ProcessPeerError>;
|
|
7
|
+
/** Accept and acknowledge one complete output frame, or fail without partial interleaving. */
|
|
8
|
+
readonly send: (event: ProcessEvent) => Effect.Effect<void, ProcessPeerError>;
|
|
9
|
+
/** Barrier for all writes accepted before this call; output failures remain failures. */
|
|
10
|
+
readonly flush: Effect.Effect<void, ProcessPeerError>;
|
|
11
|
+
}>;
|
|
12
|
+
/**
|
|
13
|
+
* One scoped, bounded, ordered NDJSON connection. `commands` has one lifetime
|
|
14
|
+
* consumer. Input EOF completes it only at a frame boundary. Accepted sends
|
|
15
|
+
* survive caller interruption; `flush` awaits every send accepted before its
|
|
16
|
+
* snapshot and preserves the first output failure. Closing the peer does not
|
|
17
|
+
* close host-owned native streams. `processPeerConformance` is the runnable contract.
|
|
18
|
+
* @public
|
|
19
|
+
*/
|
|
20
|
+
export declare class ProcessPeer extends ProcessPeer_base {
|
|
21
|
+
}
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { Scope } from "effect";
|
|
3
|
+
import { ProcessPeer } from "./ports/process-peer.port.js";
|
|
4
|
+
import { ProcessHostError } from "./process-host.entity.js";
|
|
5
|
+
import type { ProcessHostOptions, ProcessHostResult } from "./process-host.entity.js";
|
|
6
|
+
/**
|
|
7
|
+
* Serve one connection with a host-supplied runtime. Setup owns startup
|
|
8
|
+
* recovery and installs the observers before constructing its services.
|
|
9
|
+
* This scope keeps storage open through graceful shutdown and output ACKs.
|
|
10
|
+
* No native process exit or automatic task/drainer construction occurs here.
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
export declare const serveProcess: <E>(options: ProcessHostOptions<E>) => Effect.Effect<ProcessHostResult, ProcessHostError, Scope.Scope | ProcessPeer>;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Clock, Context, Deferred, Effect, Fiber, FiberSet, Match, Option, Predicate, Ref, Result, Schema, Stream } from "effect";
|
|
2
|
+
import { harnessCodeOf } from "@xandreed/core";
|
|
3
|
+
import { ProcessPeer } from "./ports/process-peer.port.js";
|
|
4
|
+
import { PROCESS_PROTOCOL_VERSION } from "./process-protocol.entity.js";
|
|
5
|
+
import { processLifecycleOf, processProgressOf } from "./process-protocol.entity.functions.js";
|
|
6
|
+
import { ProcessHostConfig, ProcessHostError, processHostDefaults } from "./process-host.entity.js";
|
|
7
|
+
import { admitProcessRequest, endedProcessRequestOf, noteProcessTurn, openProcessDispatch, processRequestOf, releaseProcessRequest, stopProcessDispatch } from "./process-host.entity.functions.js";
|
|
8
|
+
const peerFailure = (cause) => new ProcessHostError({
|
|
9
|
+
reason: cause.direction === "input" && ["InvalidFrame", "InvalidUtf8", "FrameTooLarge"].includes(cause.reason) ? "Protocol" : "Peer",
|
|
10
|
+
code: cause.direction === "input" && ["InvalidFrame", "InvalidUtf8", "FrameTooLarge"].includes(cause.reason) ? "protocol.invalid" : "host.peer",
|
|
11
|
+
message: cause.direction === "input" ? "the process input could not be read" : "the process output could not be written",
|
|
12
|
+
cause
|
|
13
|
+
}), correlation = (requestId) => Option.match(requestId, { onNone: () => ({}), onSome: (requestId) => ({ requestId }) });
|
|
14
|
+
export const serveProcess = Effect.fn("process.serve")(function* (options) {
|
|
15
|
+
const stoppingNotified = yield* Ref.make(!1), notifyStopping = Ref.getAndSet(stoppingNotified, !0).pipe(Effect.flatMap((already) => already ? Effect.void : options.onStopping ?? Effect.void));
|
|
16
|
+
return yield* Effect.scoped(Effect.gen(function* () {
|
|
17
|
+
const config = yield* Schema.decodeUnknownEffect(ProcessHostConfig)({
|
|
18
|
+
owners: options.owners,
|
|
19
|
+
events: options.events ?? processHostDefaults.events,
|
|
20
|
+
maxBegins: options.maxBegins ?? processHostDefaults.maxBegins,
|
|
21
|
+
maxShortCommands: options.maxShortCommands ?? processHostDefaults.maxShortCommands,
|
|
22
|
+
controlCapacity: options.controlCapacity ?? processHostDefaults.controlCapacity,
|
|
23
|
+
handshakeTimeoutMs: options.handshakeTimeoutMs ?? processHostDefaults.handshakeTimeoutMs,
|
|
24
|
+
shutdown: { ...processHostDefaults.shutdown, ...options.shutdown }
|
|
25
|
+
}).pipe(Effect.mapError((cause) => new ProcessHostError({ reason: "Config", code: "host.runtime", message: "invalid process host options", cause }))), peer = yield* ProcessPeer, state = yield* Ref.make({ _tag: "AwaitingHello", requests: [] }), binding = yield* Ref.make(Option.none()), failure = yield* Ref.make(Option.none()), stopped = yield* Deferred.make(), workers = yield* FiberSet.make(), stop = (request) => Effect.uninterruptible(Effect.gen(function* () {
|
|
26
|
+
if (!(yield* Ref.modify(state, (current) => [current._tag !== "Stopping", stopProcessDispatch(current)])))
|
|
27
|
+
return;
|
|
28
|
+
const notified = yield* Effect.exit(notifyStopping);
|
|
29
|
+
if (notified._tag === "Failure")
|
|
30
|
+
yield* Ref.update(failure, (current) => Option.isSome(current) ? current : Option.some(new ProcessHostError({ reason: "Runtime", code: "host.runtime", message: "the process stopping hook failed", cause: notified.cause })));
|
|
31
|
+
yield* Deferred.succeed(stopped, request);
|
|
32
|
+
})), fatal = (error, requestId) => Ref.update(failure, (current) => Option.isSome(current) ? current : Option.some(error)).pipe(Effect.andThen(stop({ reason: "eof", ...requestId === void 0 ? {} : { requestId } }))), send = (event) => peer.send(event).pipe(Effect.catch((error) => fatal(peerFailure(error))), Effect.catchDefect((cause) => fatal(new ProcessHostError({ reason: "Peer", code: "host.peer", message: "the process output failed", cause })))), refuse = (requestId, code, message, frameworkCode) => send({
|
|
33
|
+
_tag: "error",
|
|
34
|
+
requestId,
|
|
35
|
+
code,
|
|
36
|
+
message,
|
|
37
|
+
fatal: !1,
|
|
38
|
+
...frameworkCode === void 0 ? {} : { frameworkCode }
|
|
39
|
+
}), progressOf = (event) => processProgressOf(event, config.events).pipe(Effect.catch((error) => config.events === "full" && error.reason === "InvalidEvent" ? Effect.logWarning("a turn event's full content is not JSON; its projection is sent instead").pipe(Effect.annotateLogs({ "efferent.event": event._tag }), Effect.andThen(processProgressOf(event, "projection"))) : Effect.fail(error))), observe = {
|
|
40
|
+
sessionLog: (inner) => ({
|
|
41
|
+
create: (header) => inner.create(header),
|
|
42
|
+
head: (id) => inner.head(id),
|
|
43
|
+
list: (query) => inner.list(query),
|
|
44
|
+
read: (id, query) => inner.read(id, query),
|
|
45
|
+
remove: (id) => inner.remove(id),
|
|
46
|
+
commit: (id, commit) => Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
|
|
47
|
+
const header = (yield* restore(inner.head(id))).header, receipt = yield* restore(inner.commit(id, commit));
|
|
48
|
+
if (!config.owners.includes(header.owner))
|
|
49
|
+
return receipt;
|
|
50
|
+
const address = { id, owner: header.owner };
|
|
51
|
+
yield* Effect.forEach(receipt.events, (row) => processLifecycleOf(address, row).pipe(Effect.flatMap(Option.match({
|
|
52
|
+
onNone: () => Effect.void,
|
|
53
|
+
onSome: (event) => Match.value(event).pipe(Match.tag("turn.started", (started) => Ref.modify(state, (current) => [
|
|
54
|
+
processRequestOf(current, address, started.runId),
|
|
55
|
+
noteProcessTurn(current, address, started.runId, started.turn)
|
|
56
|
+
]).pipe(Effect.flatMap((requestId) => send({ ...started, ...correlation(requestId) })))), Match.tag("turn.ended", (ended) => Ref.get(state).pipe(Effect.flatMap((current) => send({ ...ended, ...correlation(endedProcessRequestOf(current, address, ended.turn)) })))), Match.orElse(() => Effect.void))
|
|
57
|
+
})), Effect.catch((cause) => fatal(new ProcessHostError({ reason: "Runtime", code: "host.runtime", message: "a committed process lifecycle event could not be observed", cause }))), Effect.catchDefect((cause) => fatal(new ProcessHostError({ reason: "Runtime", code: "host.runtime", message: "a committed process lifecycle observation failed", cause })))), { concurrency: 1, discard: !0 });
|
|
58
|
+
return receipt;
|
|
59
|
+
}))
|
|
60
|
+
}),
|
|
61
|
+
turnRunner: (inner) => ({
|
|
62
|
+
turn: (input, use) => inner.turn(input, (turn) => Effect.gen(function* () {
|
|
63
|
+
const address = turn.admitted.session;
|
|
64
|
+
if (config.owners.includes(address.owner))
|
|
65
|
+
yield* turn.events.subscribe(Option.some, (event) => progressOf(event).pipe(Effect.flatMap(Option.match({ onNone: () => Effect.void, onSome: (progress) => Ref.get(state).pipe(Effect.flatMap((current) => send({
|
|
66
|
+
_tag: "turn.event",
|
|
67
|
+
session: address,
|
|
68
|
+
turn: turn.turn,
|
|
69
|
+
runId: turn.runId,
|
|
70
|
+
progress,
|
|
71
|
+
...correlation(processRequestOf(current, address, turn.runId))
|
|
72
|
+
}))) })), Effect.catch((cause) => fatal(peerFailure(cause))), Effect.catchDefect((cause) => fatal(new ProcessHostError({ reason: "Peer", code: "host.peer", message: "a process progress event could not be observed", cause })))), { mode: "inline" });
|
|
73
|
+
return yield* use(turn);
|
|
74
|
+
}))
|
|
75
|
+
})
|
|
76
|
+
}, runCommand = (host, command) => Match.valueTags(command, {
|
|
77
|
+
hello: () => fatal(new ProcessHostError({ reason: "Protocol", code: "protocol.sequence", message: "hello is only accepted as the first command" }), command.requestId),
|
|
78
|
+
begin: (begin) => {
|
|
79
|
+
const { _tag, requestId, ...turn } = begin;
|
|
80
|
+
return host.runner.turn({ turn, services: host.services }, host.use).pipe(Effect.flatMap((result) => send({ _tag: "begin.result", requestId, result })));
|
|
81
|
+
},
|
|
82
|
+
send: (queued) => host.sessions.enqueue(queued.session, { ...queued.input, delivery: "next" }).pipe(Effect.flatMap((result) => send({ _tag: "queued", requestId: queued.requestId, command: "send", ...result }))),
|
|
83
|
+
steer: (queued) => host.sessions.enqueue(queued.session, { ...queued.input, delivery: "steer" }).pipe(Effect.flatMap((result) => send({ _tag: "queued", requestId: queued.requestId, command: "steer", ...result }))),
|
|
84
|
+
answer: (answer) => Option.match(host.tasks, {
|
|
85
|
+
onNone: () => refuse(answer.requestId, "host.unsupported", "task answers are not available in this host"),
|
|
86
|
+
onSome: (tasks) => tasks.answer(answer.parent, answer.taskId, { questionId: answer.questionId, text: answer.text }).pipe(Effect.flatMap((result) => send({ _tag: "answered", requestId: answer.requestId, ...result })))
|
|
87
|
+
}),
|
|
88
|
+
cancel: (cancel) => host.sessions.cancel(cancel.session, cancel.turn).pipe(Effect.flatMap((result) => send({ _tag: "cancelled", requestId: cancel.requestId, ...result }))),
|
|
89
|
+
ping: (ping) => host.activity.pipe(Effect.flatMap((activity) => send({ _tag: "pong", requestId: ping.requestId, activity }))),
|
|
90
|
+
shutdown: (shutdown) => stop({ reason: "shutdown", requestId: shutdown.requestId, ...shutdown.deadlineMs === void 0 ? {} : { deadlineMs: shutdown.deadlineMs } })
|
|
91
|
+
}).pipe(Effect.catch((error) => Predicate.isTagged(error, "SessionLogError") ? fatal(new ProcessHostError({ reason: "Runtime", code: "host.runtime", message: "the process storage operation failed", cause: error }), command.requestId) : refuse(command.requestId, "host.command", "the process command could not complete", Option.getOrUndefined(harnessCodeOf(error)))), Effect.catchDefect((cause) => fatal(new ProcessHostError({ reason: "Runtime", code: "host.runtime", message: "the process command failed", cause }), command.requestId))), dispatch = (host, command) => Effect.gen(function* () {
|
|
92
|
+
const active = {
|
|
93
|
+
requestId: command.requestId,
|
|
94
|
+
lane: Match.value(command).pipe(Match.tag("begin", () => "begin"), Match.tag("cancel", "ping", "shutdown", () => "control"), Match.orElse(() => "short")),
|
|
95
|
+
begin: Match.value(command).pipe(Match.tag("begin", (begin) => Option.some({ session: begin.session, runId: begin.runId, turn: Option.none() })), Match.orElse(() => Option.none()))
|
|
96
|
+
}, admitted = yield* Ref.modify(state, (current) => {
|
|
97
|
+
const next = admitProcessRequest(current, active, config);
|
|
98
|
+
return [next, Result.getOrElse(next, () => current)];
|
|
99
|
+
});
|
|
100
|
+
if (Result.isFailure(admitted))
|
|
101
|
+
return yield* Match.value(admitted.failure).pipe(Match.when("Duplicate", () => fatal(new ProcessHostError({ reason: "Protocol", code: "protocol.sequence", message: "a command request id is already outstanding" }), command.requestId)), Match.when("Stopping", () => refuse(command.requestId, "host.stopping", "the process host is stopping")), Match.when("Busy", () => refuse(command.requestId, "host.busy", "the process command capacity is full")), Match.exhaustive);
|
|
102
|
+
const address = Match.value(command).pipe(Match.tag("begin", "send", "steer", "cancel", (value) => Option.some(value.session)), Match.tag("answer", (value) => Option.some(value.parent)), Match.orElse(() => Option.none())), released = Ref.update(state, (current) => releaseProcessRequest(current, command.requestId));
|
|
103
|
+
if (Option.exists(address, (session) => !config.owners.includes(session.owner)))
|
|
104
|
+
return yield* refuse(command.requestId, "host.owner", "the session owner is not available in this host").pipe(Effect.ensuring(released));
|
|
105
|
+
if (command._tag === "shutdown") {
|
|
106
|
+
if (command.deadlineMs !== void 0 && command.deadlineMs > config.shutdown.deadlineMs)
|
|
107
|
+
return yield* refuse(command.requestId, "host.command", "the shutdown deadline exceeds the host maximum").pipe(Effect.ensuring(released));
|
|
108
|
+
return yield* runCommand(host, command);
|
|
109
|
+
}
|
|
110
|
+
yield* FiberSet.run(workers, runCommand(host, command).pipe(Effect.ensuring(released)));
|
|
111
|
+
}), accept = (command) => Effect.gen(function* () {
|
|
112
|
+
if ((yield* Ref.get(state))._tag === "AwaitingHello") {
|
|
113
|
+
if (command._tag !== "hello")
|
|
114
|
+
return yield* fatal(new ProcessHostError({ reason: "Protocol", code: "protocol.handshake", message: "the first process command must be hello" }), command.requestId);
|
|
115
|
+
if (command.protocolVersion !== PROCESS_PROTOCOL_VERSION)
|
|
116
|
+
return yield* fatal(new ProcessHostError({ reason: "Protocol", code: "protocol.version", message: "the process protocol version is unsupported" }), command.requestId);
|
|
117
|
+
yield* Ref.set(state, { _tag: "Starting", requests: [] });
|
|
118
|
+
yield* send({ _tag: "hello", requestId: command.requestId, protocolVersion: PROCESS_PROTOCOL_VERSION });
|
|
119
|
+
if ((yield* Ref.get(state))._tag === "Stopping")
|
|
120
|
+
return;
|
|
121
|
+
const built = yield* options.setup(observe).pipe(Effect.map(Option.some), Effect.catch((cause) => fatal(new ProcessHostError({ reason: "Setup", code: "host.setup", message: "the process host could not be set up", cause })).pipe(Effect.as(Option.none()))), Effect.catchDefect((cause) => fatal(new ProcessHostError({ reason: "Setup", code: "host.setup", message: "the process host setup failed", cause })).pipe(Effect.as(Option.none()))));
|
|
122
|
+
if (Option.isSome(built)) {
|
|
123
|
+
yield* Ref.set(binding, built);
|
|
124
|
+
yield* Ref.update(state, openProcessDispatch);
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const host = yield* Ref.get(binding);
|
|
129
|
+
if (Option.isSome(host))
|
|
130
|
+
return yield* dispatch(host.value, command);
|
|
131
|
+
}), reader = yield* peer.commands.pipe(Stream.runForEach(accept), Effect.andThen(Ref.get(state).pipe(Effect.flatMap((current) => current._tag === "AwaitingHello" ? fatal(new ProcessHostError({ reason: "Protocol", code: "protocol.handshake", message: "the process input ended before hello" })) : stop({ reason: "eof" })))), Effect.catch((cause) => fatal(peerFailure(cause))), Effect.catchDefect((cause) => fatal(new ProcessHostError({ reason: "Runtime", code: "host.runtime", message: "the process command reader failed", cause }))), Effect.forkScoped);
|
|
132
|
+
yield* Effect.sleep(config.handshakeTimeoutMs).pipe(Effect.andThen(Ref.get(state)), Effect.flatMap((current) => current._tag === "AwaitingHello" ? fatal(new ProcessHostError({ reason: "Protocol", code: "protocol.handshake", message: "the process hello deadline expired" })) : Effect.void), Effect.forkScoped);
|
|
133
|
+
yield* (options.stop ?? Effect.never).pipe(Effect.andThen(stop({ reason: "signal" })), Effect.catchDefect((cause) => fatal(new ProcessHostError({ reason: "Runtime", code: "host.runtime", message: "the process stop signal failed", cause }))), Effect.forkScoped);
|
|
134
|
+
const request = yield* Deferred.await(stopped);
|
|
135
|
+
yield* Effect.forkScoped(Fiber.interrupt(reader));
|
|
136
|
+
const host = yield* Ref.get(binding), shutdownStarted = yield* Clock.currentTimeMillis, report = yield* Option.match(host, {
|
|
137
|
+
onNone: () => Effect.succeed(Option.none()),
|
|
138
|
+
onSome: (host) => host.shutdown({ deadline: request.deadlineMs ?? config.shutdown.deadlineMs, reserve: Math.min(config.shutdown.reserveMs, request.deadlineMs ?? config.shutdown.deadlineMs) }).pipe(Effect.map(Option.some), Effect.catchDefect((cause) => fatal(new ProcessHostError({ reason: "Runtime", code: "host.runtime", message: "the process shutdown failed", cause })).pipe(Effect.as(Option.none()))))
|
|
139
|
+
}), remaining = Math.max(0, (request.deadlineMs ?? config.shutdown.deadlineMs) - ((yield* Clock.currentTimeMillis) - shutdownStarted)), responses = yield* FiberSet.awaitEmpty(workers).pipe(Effect.timeoutOption(Math.min(config.shutdown.reserveMs, remaining)));
|
|
140
|
+
if (Option.isNone(responses))
|
|
141
|
+
yield* FiberSet.clear(workers);
|
|
142
|
+
if (Option.isSome(report))
|
|
143
|
+
yield* send({ _tag: "shutdown", ...correlation(Option.fromNullishOr(request.requestId)), report: report.value });
|
|
144
|
+
const failed = yield* Ref.get(failure);
|
|
145
|
+
if (Option.isSome(failed))
|
|
146
|
+
yield* peer.send({ _tag: "error", ...correlation(Option.fromNullishOr(request.requestId)), code: failed.value.code, message: failed.value.message, fatal: !0 }).pipe(Effect.catch((cause) => Effect.logWarning("the process peer could not report the host failure").pipe(Effect.andThen(fatal(peerFailure(cause))))), Effect.catchDefect((cause) => fatal(new ProcessHostError({ reason: "Peer", code: "host.peer", message: "the process failure could not be written", cause }))));
|
|
147
|
+
yield* peer.flush.pipe(Effect.catch((cause) => fatal(peerFailure(cause))), Effect.catchDefect((cause) => fatal(new ProcessHostError({ reason: "Peer", code: "host.peer", message: "the process output could not be flushed", cause }))));
|
|
148
|
+
const finalFailure = yield* Ref.get(failure);
|
|
149
|
+
if (Option.isSome(finalFailure))
|
|
150
|
+
return yield* Effect.fail(finalFailure.value);
|
|
151
|
+
return { reason: request.reason, report };
|
|
152
|
+
}).pipe(Effect.onExit(() => notifyStopping)));
|
|
153
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import type { Context, Effect, Option, Scope } from "effect";
|
|
3
|
+
import type { ProcessActivity, SessionLog, SessionLogError, Sessions, ShutdownOptions, ShutdownReport, Tasks, Turn, TurnError, TurnOutcome, TurnRunner, TurnServices } from "@xandreed/core";
|
|
4
|
+
/** Services captured from the same host scope as every direct, queued and task turn. @public */
|
|
5
|
+
export interface ProcessBinding {
|
|
6
|
+
readonly runner: TurnRunner;
|
|
7
|
+
readonly services: Context.Context<never>;
|
|
8
|
+
readonly use: (turn: Turn) => Effect.Effect<TurnOutcome, TurnError, TurnServices | Scope.Scope>;
|
|
9
|
+
readonly sessions: Context.Service.Shape<typeof Sessions>;
|
|
10
|
+
readonly tasks: Option.Option<Context.Service.Shape<typeof Tasks>>;
|
|
11
|
+
readonly activity: Effect.Effect<ProcessActivity, SessionLogError>;
|
|
12
|
+
readonly shutdown: (options: ShutdownOptions) => Effect.Effect<ShutdownReport>;
|
|
13
|
+
}
|
|
14
|
+
/** Install both wrappers before constructing the host's Sessions, Agent and bridges. @public */
|
|
15
|
+
export interface ProcessObservers {
|
|
16
|
+
readonly sessionLog: (inner: Context.Service.Shape<typeof SessionLog>) => Context.Service.Shape<typeof SessionLog>;
|
|
17
|
+
readonly turnRunner: (inner: TurnRunner) => TurnRunner;
|
|
18
|
+
}
|
|
19
|
+
/** Bounded command admission; transport byte and native write limits belong to StdioPeerLive. @public */
|
|
20
|
+
export declare const ProcessHostConfig: Schema.Struct<{
|
|
21
|
+
readonly owners: Schema.$Array<Schema.Trimmed>;
|
|
22
|
+
readonly events: Schema.Literals<readonly ["projection", "full"]>;
|
|
23
|
+
readonly maxBegins: Schema.Int;
|
|
24
|
+
readonly maxShortCommands: Schema.Int;
|
|
25
|
+
readonly controlCapacity: Schema.Int;
|
|
26
|
+
readonly handshakeTimeoutMs: Schema.Int;
|
|
27
|
+
readonly shutdown: Schema.Struct<{
|
|
28
|
+
readonly deadlineMs: Schema.Int;
|
|
29
|
+
readonly reserveMs: Schema.Int;
|
|
30
|
+
}>;
|
|
31
|
+
}>;
|
|
32
|
+
export type ProcessHostConfig = typeof ProcessHostConfig.Type;
|
|
33
|
+
/** @public */
|
|
34
|
+
export declare const processHostDefaults: Omit<ProcessHostConfig, "owners">;
|
|
35
|
+
/** The parent supplies setup and explicitly owns startup reconciliation/takeover policy. @public */
|
|
36
|
+
export interface ProcessHostOptions<E = never> extends Partial<Omit<ProcessHostConfig, "owners" | "shutdown">> {
|
|
37
|
+
readonly owners: ProcessHostConfig["owners"];
|
|
38
|
+
readonly shutdown?: Partial<ProcessHostConfig["shutdown"]>;
|
|
39
|
+
readonly setup: (observe: ProcessObservers) => Effect.Effect<ProcessBinding, E, Scope.Scope>;
|
|
40
|
+
/** A native signal or an embedding host's stop request; it may complete before setup. */
|
|
41
|
+
readonly stop?: Effect.Effect<void>;
|
|
42
|
+
/** Runs once before shutdown/teardown can block, so the native edge can start its hard timer. */
|
|
43
|
+
readonly onStopping?: Effect.Effect<void>;
|
|
44
|
+
}
|
|
45
|
+
/** No native exit occurs in serveProcess; a signal before setup has no shutdown report. @public */
|
|
46
|
+
export interface ProcessHostResult {
|
|
47
|
+
readonly reason: "shutdown" | "eof" | "signal";
|
|
48
|
+
readonly report: Option.Option<ShutdownReport>;
|
|
49
|
+
}
|
|
50
|
+
declare const ProcessHostError_base: Schema.Class<ProcessHostError, Schema.TaggedStruct<"ProcessHostError", {
|
|
51
|
+
readonly reason: Schema.Literals<readonly ["Config", "Protocol", "Setup", "Peer", "Runtime"]>;
|
|
52
|
+
readonly code: Schema.Literals<readonly ["protocol.handshake", "protocol.version", "protocol.sequence", "protocol.invalid", "host.busy", "host.owner", "host.stopping", "host.unsupported", "host.command", "host.setup", "host.runtime", "host.peer"]>;
|
|
53
|
+
readonly message: Schema.String;
|
|
54
|
+
readonly cause: Schema.optional<Schema.Defect>;
|
|
55
|
+
}>, import("effect/Cause").YieldableError>;
|
|
56
|
+
/** Safe host failure; raw causes remain local and are never sent as wire messages. @public */
|
|
57
|
+
export declare class ProcessHostError extends ProcessHostError_base {
|
|
58
|
+
}
|
|
59
|
+
/** Internal bounded command identity; no model messages or attachment bytes are retained here. */
|
|
60
|
+
export declare const ProcessActiveRequest: Schema.Struct<{
|
|
61
|
+
readonly requestId: Schema.brand<Schema.Trimmed, "ProcessRequestId">;
|
|
62
|
+
readonly lane: Schema.Literals<readonly ["begin", "short", "control"]>;
|
|
63
|
+
readonly begin: Schema.OptionFromNullOr<Schema.Struct<{
|
|
64
|
+
readonly session: Schema.Struct<{
|
|
65
|
+
readonly id: Schema.brand<Schema.String, "ConversationId">;
|
|
66
|
+
readonly owner: Schema.Trimmed;
|
|
67
|
+
}>;
|
|
68
|
+
readonly runId: Schema.brand<Schema.String, "RunId">;
|
|
69
|
+
readonly turn: Schema.OptionFromNullOr<Schema.Int>;
|
|
70
|
+
}>>;
|
|
71
|
+
}>;
|
|
72
|
+
export type ProcessActiveRequest = typeof ProcessActiveRequest.Type;
|
|
73
|
+
/** The dispatcher transitions this aggregate atomically, then performs side effects. */
|
|
74
|
+
export declare const ProcessDispatchState: Schema.toTaggedUnion<"_tag", readonly [Schema.TaggedStruct<"AwaitingHello", {
|
|
75
|
+
readonly requests: Schema.$Array<Schema.Struct<{
|
|
76
|
+
readonly requestId: Schema.brand<Schema.Trimmed, "ProcessRequestId">;
|
|
77
|
+
readonly lane: Schema.Literals<readonly ["begin", "short", "control"]>;
|
|
78
|
+
readonly begin: Schema.OptionFromNullOr<Schema.Struct<{
|
|
79
|
+
readonly session: Schema.Struct<{
|
|
80
|
+
readonly id: Schema.brand<Schema.String, "ConversationId">;
|
|
81
|
+
readonly owner: Schema.Trimmed;
|
|
82
|
+
}>;
|
|
83
|
+
readonly runId: Schema.brand<Schema.String, "RunId">;
|
|
84
|
+
readonly turn: Schema.OptionFromNullOr<Schema.Int>;
|
|
85
|
+
}>>;
|
|
86
|
+
}>>;
|
|
87
|
+
}>, Schema.TaggedStruct<"Starting", {
|
|
88
|
+
readonly requests: Schema.$Array<Schema.Struct<{
|
|
89
|
+
readonly requestId: Schema.brand<Schema.Trimmed, "ProcessRequestId">;
|
|
90
|
+
readonly lane: Schema.Literals<readonly ["begin", "short", "control"]>;
|
|
91
|
+
readonly begin: Schema.OptionFromNullOr<Schema.Struct<{
|
|
92
|
+
readonly session: Schema.Struct<{
|
|
93
|
+
readonly id: Schema.brand<Schema.String, "ConversationId">;
|
|
94
|
+
readonly owner: Schema.Trimmed;
|
|
95
|
+
}>;
|
|
96
|
+
readonly runId: Schema.brand<Schema.String, "RunId">;
|
|
97
|
+
readonly turn: Schema.OptionFromNullOr<Schema.Int>;
|
|
98
|
+
}>>;
|
|
99
|
+
}>>;
|
|
100
|
+
}>, Schema.TaggedStruct<"Open", {
|
|
101
|
+
readonly requests: Schema.$Array<Schema.Struct<{
|
|
102
|
+
readonly requestId: Schema.brand<Schema.Trimmed, "ProcessRequestId">;
|
|
103
|
+
readonly lane: Schema.Literals<readonly ["begin", "short", "control"]>;
|
|
104
|
+
readonly begin: Schema.OptionFromNullOr<Schema.Struct<{
|
|
105
|
+
readonly session: Schema.Struct<{
|
|
106
|
+
readonly id: Schema.brand<Schema.String, "ConversationId">;
|
|
107
|
+
readonly owner: Schema.Trimmed;
|
|
108
|
+
}>;
|
|
109
|
+
readonly runId: Schema.brand<Schema.String, "RunId">;
|
|
110
|
+
readonly turn: Schema.OptionFromNullOr<Schema.Int>;
|
|
111
|
+
}>>;
|
|
112
|
+
}>>;
|
|
113
|
+
}>, Schema.TaggedStruct<"Stopping", {
|
|
114
|
+
readonly requests: Schema.$Array<Schema.Struct<{
|
|
115
|
+
readonly requestId: Schema.brand<Schema.Trimmed, "ProcessRequestId">;
|
|
116
|
+
readonly lane: Schema.Literals<readonly ["begin", "short", "control"]>;
|
|
117
|
+
readonly begin: Schema.OptionFromNullOr<Schema.Struct<{
|
|
118
|
+
readonly session: Schema.Struct<{
|
|
119
|
+
readonly id: Schema.brand<Schema.String, "ConversationId">;
|
|
120
|
+
readonly owner: Schema.Trimmed;
|
|
121
|
+
}>;
|
|
122
|
+
readonly runId: Schema.brand<Schema.String, "RunId">;
|
|
123
|
+
readonly turn: Schema.OptionFromNullOr<Schema.Int>;
|
|
124
|
+
}>>;
|
|
125
|
+
}>>;
|
|
126
|
+
}>]>;
|
|
127
|
+
export type ProcessDispatchState = typeof ProcessDispatchState.Type;
|
|
128
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Option, Result } from "effect";
|
|
2
|
+
import type { RunId, SessionAddress } from "@xandreed/core";
|
|
3
|
+
import type { ProcessRequestId } from "./process-protocol.entity.js";
|
|
4
|
+
import type { ProcessActiveRequest, ProcessDispatchState, ProcessHostConfig } from "./process-host.entity.js";
|
|
5
|
+
/** Decide admission without blocking the reader behind a running command. */
|
|
6
|
+
export declare const admitProcessRequest: (state: ProcessDispatchState, request: ProcessActiveRequest, config: ProcessHostConfig) => Result.Result<ProcessDispatchState, "Duplicate" | "Stopping" | "Busy">;
|
|
7
|
+
export declare const openProcessDispatch: (state: ProcessDispatchState) => ProcessDispatchState;
|
|
8
|
+
export declare const stopProcessDispatch: (state: ProcessDispatchState) => ProcessDispatchState;
|
|
9
|
+
export declare const releaseProcessRequest: (state: ProcessDispatchState, requestId: ProcessRequestId) => ProcessDispatchState;
|
|
10
|
+
export declare const processRequestOf: (state: ProcessDispatchState, session: SessionAddress, runId: RunId) => Option.Option<ProcessRequestId>;
|
|
11
|
+
export declare const noteProcessTurn: (state: ProcessDispatchState, session: SessionAddress, runId: RunId, turn: number) => ProcessDispatchState;
|
|
12
|
+
export declare const endedProcessRequestOf: (state: ProcessDispatchState, session: SessionAddress, turn: number) => Option.Option<ProcessRequestId>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Match, Option, Result } from "effect";
|
|
2
|
+
export const admitProcessRequest = (state, request, config) => {
|
|
3
|
+
if (state.requests.some((active) => active.requestId === request.requestId))
|
|
4
|
+
return Result.fail("Duplicate");
|
|
5
|
+
return Match.value(state).pipe(Match.tag("Open", (open) => {
|
|
6
|
+
const maximum = { begin: config.maxBegins, short: config.maxShortCommands, control: config.controlCapacity }[request.lane];
|
|
7
|
+
if (open.requests.filter((active) => active.lane === request.lane).length >= maximum)
|
|
8
|
+
return Result.fail("Busy");
|
|
9
|
+
return Result.succeed({ ...open, requests: [...open.requests, request] });
|
|
10
|
+
}), Match.orElse(() => Result.fail("Stopping")));
|
|
11
|
+
}, openProcessDispatch = (state) => Match.value(state).pipe(Match.tag("Stopping", (stopping) => stopping), Match.orElse((current) => ({ _tag: "Open", requests: current.requests }))), stopProcessDispatch = (state) => ({ _tag: "Stopping", requests: state.requests }), releaseProcessRequest = (state, requestId) => ({ ...state, requests: state.requests.filter((request) => request.requestId !== requestId) }), processRequestOf = (state, session, runId) => {
|
|
12
|
+
const candidates = state.requests.filter((request) => Option.exists(request.begin, (begin) => begin.session.id === session.id && begin.session.owner === session.owner && begin.runId === runId));
|
|
13
|
+
return candidates.length === 1 ? Option.fromNullishOr(candidates[0]).pipe(Option.map((request) => request.requestId)) : Option.none();
|
|
14
|
+
}, noteProcessTurn = (state, session, runId, turn) => {
|
|
15
|
+
const requestId = processRequestOf(state, session, runId);
|
|
16
|
+
return { ...state, requests: state.requests.map((request) => Option.contains(requestId, request.requestId) ? { ...request, begin: Option.map(request.begin, (begin) => ({ ...begin, turn: Option.some(turn) })) } : request) };
|
|
17
|
+
}, endedProcessRequestOf = (state, session, turn) => Option.fromNullishOr(state.requests.find((request) => Option.exists(request.begin, (begin) => begin.session.id === session.id && begin.session.owner === session.owner && Option.contains(begin.turn, turn)))).pipe(Option.map((request) => request.requestId));
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { ProcessErrorCode, ProcessEventMode, ProcessRequestId } from "./process-protocol.entity.js";
|
|
3
|
+
import { RunId, SessionAddress } from "@xandreed/core";
|
|
4
|
+
const Positive = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER })), Count = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }));
|
|
5
|
+
export const ProcessHostConfig = Schema.Struct({
|
|
6
|
+
owners: Schema.Array(Schema.Trimmed.check(Schema.isNonEmpty())).check(Schema.isMinLength(1), Schema.isMaxLength(1000)),
|
|
7
|
+
events: ProcessEventMode,
|
|
8
|
+
maxBegins: Positive.check(Schema.isLessThanOrEqualTo(64)),
|
|
9
|
+
maxShortCommands: Positive.check(Schema.isLessThanOrEqualTo(256)),
|
|
10
|
+
controlCapacity: Positive.check(Schema.isLessThanOrEqualTo(64)),
|
|
11
|
+
handshakeTimeoutMs: Positive,
|
|
12
|
+
shutdown: Schema.Struct({ deadlineMs: Count, reserveMs: Count }).check(Schema.makeFilter((value) => value.reserveMs <= value.deadlineMs || "shutdown reserve must not exceed the deadline"))
|
|
13
|
+
}), processHostDefaults = {
|
|
14
|
+
events: "projection",
|
|
15
|
+
maxBegins: 8,
|
|
16
|
+
maxShortCommands: 32,
|
|
17
|
+
controlCapacity: 8,
|
|
18
|
+
handshakeTimeoutMs: 1e4,
|
|
19
|
+
shutdown: { deadlineMs: 25000, reserveMs: 2000 }
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export class ProcessHostError extends Schema.TaggedError()("ProcessHostError", {
|
|
23
|
+
reason: Schema.Literals(["Config", "Protocol", "Setup", "Peer", "Runtime"]),
|
|
24
|
+
code: ProcessErrorCode,
|
|
25
|
+
message: Schema.String,
|
|
26
|
+
cause: Schema.optional(Schema.Defect())
|
|
27
|
+
}) {
|
|
28
|
+
}
|
|
29
|
+
export const ProcessActiveRequest = Schema.Struct({
|
|
30
|
+
requestId: ProcessRequestId,
|
|
31
|
+
lane: Schema.Literals(["begin", "short", "control"]),
|
|
32
|
+
begin: Schema.OptionFromNullOr(Schema.Struct({ session: SessionAddress, runId: RunId, turn: Schema.OptionFromNullOr(Positive) }))
|
|
33
|
+
}), ProcessDispatchState = Schema.Union([
|
|
34
|
+
Schema.TaggedStruct("AwaitingHello", { requests: Schema.Array(ProcessActiveRequest) }),
|
|
35
|
+
Schema.TaggedStruct("Starting", { requests: Schema.Array(ProcessActiveRequest) }),
|
|
36
|
+
Schema.TaggedStruct("Open", { requests: Schema.Array(ProcessActiveRequest) }),
|
|
37
|
+
Schema.TaggedStruct("Stopping", { requests: Schema.Array(ProcessActiveRequest) })
|
|
38
|
+
]).pipe(Schema.toTaggedUnion("_tag"));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { Context, Scope } from "effect";
|
|
3
|
+
import type { ConformanceCheck } from "@xandreed/core/conformance";
|
|
4
|
+
import type { ProcessPeer } from "./ports/process-peer.port.js";
|
|
5
|
+
import type { ProcessPeerOptions } from "./process-peer.entity.js";
|
|
6
|
+
/** Native-driver controls used to run the peer contract against an adapter. @public */
|
|
7
|
+
export interface ProcessPeerFixture {
|
|
8
|
+
readonly peer: Context.Service.Shape<typeof ProcessPeer>;
|
|
9
|
+
readonly feed: (bytes: Uint8Array) => Effect.Effect<void>;
|
|
10
|
+
readonly finishInput: Effect.Effect<void>;
|
|
11
|
+
readonly writes: Effect.Effect<ReadonlyArray<Uint8Array>>;
|
|
12
|
+
readonly releaseWrites: Effect.Effect<void>;
|
|
13
|
+
readonly failOutput: Effect.Effect<void>;
|
|
14
|
+
}
|
|
15
|
+
/** Opens a fresh scoped peer; held writes withhold their native acknowledgement. @public */
|
|
16
|
+
export interface ProcessPeerSubject {
|
|
17
|
+
readonly open: (options?: Partial<ProcessPeerOptions> & {
|
|
18
|
+
readonly holdWrites?: boolean;
|
|
19
|
+
}) => Effect.Effect<ProcessPeerFixture, unknown, Scope.Scope>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Checks: split-crlf, invalid-frame, invalid-utf8, unterminated, frame-bound,
|
|
23
|
+
* single-consumer, ordered-output, flush-barrier, writer-capacity, failure-latch.
|
|
24
|
+
* Each check opens a fresh peer; test drivers control input and native write ACKs.
|
|
25
|
+
* @public
|
|
26
|
+
*/
|
|
27
|
+
export declare const processPeerConformance: (subject: ProcessPeerSubject) => ReadonlyArray<ConformanceCheck>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Effect, Fiber, Option, Result, Stream } from "effect";
|
|
2
|
+
import { check, ConformanceFailure } from "@xandreed/core/conformance";
|
|
3
|
+
import { ProcessRequestId } from "./process-protocol.entity.js";
|
|
4
|
+
const bytes = (text) => new TextEncoder().encode(text), hello = (id = "one") => ({ _tag: "hello", requestId: ProcessRequestId.make(id), protocolVersion: 1 }), expect = (id, holds, message) => holds ? Effect.void : Effect.fail(new ConformanceFailure({ check: id, message })), eventually = (read, holds, tries = 200) => read.pipe(Effect.flatMap((value) => holds(value) || tries <= 0 ? Effect.succeed(value) : Effect.yieldNow.pipe(Effect.andThen(Effect.suspend(() => eventually(read, holds, tries - 1)))))), outputText = (chunks) => chunks.map((chunk) => new TextDecoder().decode(chunk)).join("");
|
|
5
|
+
export const processPeerConformance = (subject) => [
|
|
6
|
+
check("reads split UTF-8 commands and CRLF without losing subsequent frames", "split-crlf", () => Effect.scoped(Effect.gen(function* () {
|
|
7
|
+
const fixture = yield* subject.open({ readChunkBytes: 3 }), collected = yield* Stream.runCollect(fixture.peer.commands).pipe(Effect.forkScoped);
|
|
8
|
+
yield* fixture.feed(bytes('{"_tag":"hello","requestId":"first","protocolVersion":1}\r'));
|
|
9
|
+
yield* fixture.feed(bytes(`
|
|
10
|
+
{"_tag":"ping","requestId":"second"}
|
|
11
|
+
`));
|
|
12
|
+
yield* fixture.finishInput;
|
|
13
|
+
const commands = yield* Fiber.join(collected);
|
|
14
|
+
yield* expect("split-crlf", commands.length === 2 && commands[0]?._tag === "hello" && commands[1]?.requestId === "second", "split commands must arrive in order");
|
|
15
|
+
}))),
|
|
16
|
+
check("rejects a blank command frame", "invalid-frame", () => Effect.scoped(Effect.gen(function* () {
|
|
17
|
+
const fixture = yield* subject.open();
|
|
18
|
+
yield* fixture.feed(bytes(`
|
|
19
|
+
`));
|
|
20
|
+
yield* fixture.finishInput;
|
|
21
|
+
const result = yield* Effect.result(Stream.runDrain(fixture.peer.commands));
|
|
22
|
+
yield* expect("invalid-frame", Result.isFailure(result) && result.failure.reason === "InvalidFrame", "blank input must fail with InvalidFrame");
|
|
23
|
+
}))),
|
|
24
|
+
check("rejects malformed UTF-8", "invalid-utf8", () => Effect.scoped(Effect.gen(function* () {
|
|
25
|
+
const fixture = yield* subject.open();
|
|
26
|
+
yield* fixture.feed(new Uint8Array([195, 40, 10]));
|
|
27
|
+
yield* fixture.finishInput;
|
|
28
|
+
const result = yield* Effect.result(Stream.runDrain(fixture.peer.commands));
|
|
29
|
+
yield* expect("invalid-utf8", Result.isFailure(result) && result.failure.reason === "InvalidUtf8", "invalid bytes must fail with InvalidUtf8");
|
|
30
|
+
}))),
|
|
31
|
+
check("rejects an unterminated final frame", "unterminated", () => Effect.scoped(Effect.gen(function* () {
|
|
32
|
+
const fixture = yield* subject.open();
|
|
33
|
+
yield* fixture.feed(bytes('{"_tag":"ping","requestId":"one"}'));
|
|
34
|
+
yield* fixture.finishInput;
|
|
35
|
+
const result = yield* Effect.result(Stream.runDrain(fixture.peer.commands));
|
|
36
|
+
yield* expect("unterminated", Result.isFailure(result) && result.failure.reason === "InvalidFrame", "EOF must not silently commit a partial frame");
|
|
37
|
+
}))),
|
|
38
|
+
check("bounds partial input frames", "frame-bound", () => Effect.scoped(Effect.gen(function* () {
|
|
39
|
+
const fixture = yield* subject.open({ maxFrameBytes: 8, readChunkBytes: 3 });
|
|
40
|
+
yield* fixture.feed(bytes(`123456789
|
|
41
|
+
`));
|
|
42
|
+
yield* fixture.finishInput;
|
|
43
|
+
const result = yield* Effect.result(Stream.runDrain(fixture.peer.commands));
|
|
44
|
+
yield* expect("frame-bound", Result.isFailure(result) && result.failure.reason === "FrameTooLarge", "oversized input must fail before decoding");
|
|
45
|
+
}))),
|
|
46
|
+
check("permits only one lifetime command consumer", "single-consumer", () => Effect.scoped(Effect.gen(function* () {
|
|
47
|
+
const fixture = yield* subject.open();
|
|
48
|
+
yield* fixture.finishInput;
|
|
49
|
+
yield* Stream.runDrain(fixture.peer.commands);
|
|
50
|
+
const result = yield* Effect.result(Stream.runDrain(fixture.peer.commands));
|
|
51
|
+
yield* expect("single-consumer", Result.isFailure(result) && result.failure.reason === "Closed", "a second consumer must be rejected");
|
|
52
|
+
}))),
|
|
53
|
+
check("writes complete ordered LF-delimited frames", "ordered-output", () => Effect.scoped(Effect.gen(function* () {
|
|
54
|
+
const fixture = yield* subject.open();
|
|
55
|
+
yield* fixture.peer.send(hello("one"));
|
|
56
|
+
yield* fixture.peer.send(hello("two"));
|
|
57
|
+
yield* fixture.peer.flush;
|
|
58
|
+
const text = outputText(yield* fixture.writes);
|
|
59
|
+
yield* expect("ordered-output", text === `{"_tag":"hello","requestId":"one","protocolVersion":1}
|
|
60
|
+
{"_tag":"hello","requestId":"two","protocolVersion":1}
|
|
61
|
+
`, "output must contain exactly two ordered complete frames");
|
|
62
|
+
}))),
|
|
63
|
+
check("flush awaits every accepted write acknowledgement", "flush-barrier", () => Effect.scoped(Effect.gen(function* () {
|
|
64
|
+
const fixture = yield* subject.open({ holdWrites: !0 }), send = yield* fixture.peer.send(hello()).pipe(Effect.forkScoped), writes = yield* eventually(fixture.writes, (chunks) => chunks.length > 0);
|
|
65
|
+
yield* expect("flush-barrier", writes.length === 1, "the accepted write must reach the native writer");
|
|
66
|
+
const flush = yield* fixture.peer.flush.pipe(Effect.forkScoped);
|
|
67
|
+
yield* Effect.yieldNow;
|
|
68
|
+
yield* expect("flush-barrier", flush.pollUnsafe() === void 0 && send.pollUnsafe() === void 0, "neither send nor flush may complete before the native ACK");
|
|
69
|
+
yield* fixture.releaseWrites;
|
|
70
|
+
yield* Fiber.join(send);
|
|
71
|
+
yield* Fiber.join(flush);
|
|
72
|
+
}))),
|
|
73
|
+
check("makes an excess writer wait for a bounded slot, before encoding, for at most the write timeout", "writer-capacity", () => Effect.scoped(Effect.gen(function* () {
|
|
74
|
+
const fixture = yield* subject.open({ holdWrites: !0, maxPendingWrites: 1, writeTimeoutMs: 60000 }), first = yield* fixture.peer.send(hello("one")).pipe(Effect.forkScoped), writes = yield* eventually(fixture.writes, (chunks) => chunks.length > 0);
|
|
75
|
+
yield* expect("writer-capacity", writes.length === 1, "the first write must occupy the bounded slot");
|
|
76
|
+
const second = yield* Effect.result(fixture.peer.send(hello("two"))).pipe(Effect.forkScoped);
|
|
77
|
+
yield* Effect.forEach(Array.from({ length: 20 }), () => Effect.yieldNow, { concurrency: 1, discard: !0 });
|
|
78
|
+
yield* expect("writer-capacity", second.pollUnsafe() === void 0 && (yield* fixture.writes).length === 1, "an excess writer must wait for a free slot without failing or writing");
|
|
79
|
+
yield* fixture.releaseWrites;
|
|
80
|
+
yield* Fiber.join(first);
|
|
81
|
+
const waited = yield* Fiber.join(second);
|
|
82
|
+
yield* fixture.peer.flush;
|
|
83
|
+
yield* expect("writer-capacity", Result.isSuccess(waited) && outputText(yield* fixture.writes) === `{"_tag":"hello","requestId":"one","protocolVersion":1}
|
|
84
|
+
{"_tag":"hello","requestId":"two","protocolVersion":1}
|
|
85
|
+
`, "the waiting writer must be written once a slot frees");
|
|
86
|
+
const stalled = yield* subject.open({ holdWrites: !0, maxPendingWrites: 1, writeTimeoutMs: 20 }), held = yield* Effect.result(stalled.peer.send(hello("held"))).pipe(Effect.forkScoped);
|
|
87
|
+
yield* eventually(stalled.writes, (chunks) => chunks.length > 0);
|
|
88
|
+
const bounded = yield* Effect.result(stalled.peer.send(hello("bounded"))).pipe(Effect.timeoutOption(5000));
|
|
89
|
+
yield* Fiber.join(held);
|
|
90
|
+
yield* expect("writer-capacity", Option.exists(bounded, (result) => Result.isFailure(result) && ["Capacity", "WriteTimeout"].includes(result.failure.reason)), "a writer waiting on a stalled output must fail within the write timeout");
|
|
91
|
+
}))),
|
|
92
|
+
check("preserves the first output failure for flush and later sends", "failure-latch", () => Effect.scoped(Effect.gen(function* () {
|
|
93
|
+
const fixture = yield* subject.open({ holdWrites: !0 }), send = yield* Effect.result(fixture.peer.send(hello())).pipe(Effect.forkScoped), writes = yield* eventually(fixture.writes, (chunks) => chunks.length > 0);
|
|
94
|
+
yield* expect("failure-latch", writes.length === 1, "the write must be accepted before failing output");
|
|
95
|
+
yield* fixture.failOutput;
|
|
96
|
+
const first = yield* Fiber.join(send), flushed = yield* Effect.result(fixture.peer.flush), later = yield* Effect.result(fixture.peer.send(hello("later")));
|
|
97
|
+
yield* expect("failure-latch", Result.isFailure(first) && Result.isFailure(flushed) && Result.isFailure(later) && first.failure.reason === flushed.failure.reason && first.failure.reason === later.failure.reason, "output failure must remain observable after pending writers settle");
|
|
98
|
+
})))
|
|
99
|
+
];
|