diff --git a/AGENTS.md b/AGENTS.md index 037429d..9502840 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ Runner must not import server, HTTP, WebSocket, gRPC, broker, scheduler, or opti ## Public contracts - `Job` is a cold, single-use execution and awaitable lifetime node. It settles only after its body and actual descendants finish. +- `HandoffJob` is a Job whose body offers one value and suspends until resumed. The handoff is rendezvous state released by the Job's cancellation and closure; it owns no lifetime, signal, or queue. - `Supervisor` manages a supplied ordinary Job. It does not create hidden startup, background, or shutdown owners. - `Supervisor.start(options)` follows `Job.start(options)` ownership and context rules. Use `{ parent: undefined }` to explicitly select an independent root; Supervisor state is its Job state. - Applications own initialization order and error reporting. Do not add readiness handshakes, automatic logging, or reporting callbacks to the execution core. diff --git a/README.md b/README.md index 64de170..02817fa 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,6 @@ const result = await execute(async () => { - `fork()` starts a child of the current Job. `new Job(body).start()` does the same inside an execution, or starts a root outside one. - `start({ parent })` selects an explicit owner and inherits that owner's context. `start({ parent: undefined })` starts an independent root. - `cancel(reason)` requests cooperative cancellation. It does not mean the work has finished. -- `value()` observes a value published by the body without waiting for descendants. It rejects if the Job settles without invoking its publisher. - `join()` and `await job` observe the complete result. Awaiting a cold Job rejects; awaiting never starts it. - `finish()` stops admission of direct children and joins without cancellation. `close(reason)` stops admission, cancels, and joins. Repeated `close()` calls share their result. - `close()` ignores expected cancellation but rejects genuine execution or finalizer failures. Jobs support `await using`. @@ -55,27 +54,7 @@ if (result.ok) { } ``` -Use `ok` to distinguish success from failure: both a successful value and a thrown error may be `undefined`. `result()` and `value()` reject self/ancestor observation synchronously. - -The body receives a publisher that may be called once while the body is running. -`Job` keeps the published value's type separate from the -body's eventual result. The publisher accepts a value or `PromiseLike` value. -Cancellation does not disable publication; `result()` and `await job` still report the -Job's final success or failure independently. - -```ts -const release = Promise.withResolvers(); -const job = new Job(async (publish) => { - publish("ready"); - await release.promise; - return 42; -}); - -job.start(); -console.log(await job.value()); // "ready"; the Job is still running. -release.resolve(); -console.log(await job); // 42; the body and descendants have settled. -``` +Use `ok` to distinguish success from failure: both a successful value and a thrown error may be `undefined`. `result()` rejects self/ancestor observation synchronously. `cancelChildren(reason)` cancels direct children, lets cancellation cascade through their descendants, and resolves after those child lifetimes settle. It does not cancel @@ -87,6 +66,30 @@ are preserved together in an `AggregateError`. Use native `try/finally`, `using`, or `await using` for resources. Body-local cleanup has its ordinary lexical lifetime. If a resource must outlive an entire subtree, acquire it outside the awaited Job or `execute()` call. +### HandoffJob + +A `HandoffJob` body offers one value and suspends until the consumer resumes it. The Job still settles only after its body and descendants finish. + +```ts +import { HandoffJob } from "@tiberjs/runner"; + +const exchange = new HandoffJob(async (handoff) => { + const response = await prepare(); + const outcome = await handoff.offer(response); // suspended until resume() + await release(outcome); +}); + +exchange.start(); +const response = await exchange.receive(); // the Job is still running +exchange.resume(await deliver(response)); +await exchange; // body and descendants have settled +``` + +- `offer()` and `resume()` each accept one call; a second throws `TypeError`. +- Cancellation rejects a pending `offer()` with its reason. An offer after cancellation still reaches `receive()` but rejects for the body. A later `resume()` is discarded. +- A body that returns without offering fails. A Job that closes without offering rejects `receive()` with its failure. +- `receive()` rejects self/ancestor observation synchronously. + ## Supervision A `Supervisor` manages an ordinary Job supplied by the caller. It directs submissions to that Job and applies failure policy. The application decides when initialization has finished and when to submit subsequent work. diff --git a/src/execution/state.ts b/src/execution/state.ts index 87ddffc..3aabe9f 100644 --- a/src/execution/state.ts +++ b/src/execution/state.ts @@ -7,7 +7,7 @@ import type { Job } from "../job/job.js"; */ export interface RuntimeState { readonly context: ExecutionContext; - readonly job: Job; + readonly job: Job; } const storage = new AsyncLocalStorage(); diff --git a/src/index.ts b/src/index.ts index 5562fde..2e4f9d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,8 @@ export { } from "./execution/context/access.js"; export { Job } from "./job/job.js"; -export type { JobPublisher, JobStartOptions, JobState, JobResult } from "./job/job.js"; +export type { JobStartOptions, JobState, JobResult } from "./job/job.js"; +export { HandoffJob } from "./job/handoff-job.js"; +export type { Handoff } from "./job/handoff.js"; export { TaskGroup } from "./supervisor/task-group.js"; export type { TaskGroupOptions, GroupMember, GroupResults } from "./supervisor/task-group.js"; diff --git a/src/job/handoff-job.ts b/src/job/handoff-job.ts new file mode 100644 index 0000000..af3b2d8 --- /dev/null +++ b/src/job/handoff-job.ts @@ -0,0 +1,34 @@ +import type { ExecutionSeed } from "../execution/context/execution-context.js"; +import { HandoffState, type Handoff } from "./handoff.js"; +import { Job } from "./job.js"; + +/** A Job whose body offers one value mid-execution and suspends until the consumer resumes it. */ +export class HandoffJob extends Job { + private readonly rendezvous: HandoffState; + + constructor( + body: (handoff: Handoff) => Result | PromiseLike, + seed?: ExecutionSeed, + ) { + if (typeof body !== "function") { + throw new TypeError("HandoffJob requires a body."); + } + const rendezvous = new HandoffState(); + super(() => body(rendezvous), seed); + this.rendezvous = rendezvous; + this.attach(rendezvous); + } + + /** The offered value, or the Job's failure if it closes without offering. */ + receive(): Promise { + const dependency = this.dependency("receive"); + if (dependency) { + throw dependency; + } + return this.rendezvous.receive(); + } + + resume(value: Resumed): void { + this.rendezvous.resume(value); + } +} diff --git a/src/job/handoff.ts b/src/job/handoff.ts new file mode 100644 index 0000000..eeb15d4 --- /dev/null +++ b/src/job/handoff.ts @@ -0,0 +1,64 @@ +export interface Handoff { + /** Hand over one value and suspend until resumed. Rejects with the Job's cancellation reason. */ + offer(value: Offered): Promise; +} + +export interface OwnedHandoff { + readonly offered: boolean; + release(reason: unknown): void; + settle(failure: unknown): void; +} + +export class HandoffState implements Handoff, OwnedHandoff { + private readonly value = Promise.withResolvers(); + private readonly answer = Promise.withResolvers(); + offered = false; + private resumed = false; + private received = false; + private released = false; + private releaseReason: unknown; + + offer(value: Offered): Promise { + if (this.offered) { + throw new TypeError("Handoff.offer() may only be called once."); + } + this.offered = true; + this.value.resolve(value); + if (this.released) { + this.answer.reject(this.releaseReason); + } + return this.answer.promise; + } + + receive(): Promise { + this.received = true; + return this.value.promise; + } + + resume(value: Resumed): void { + if (this.resumed) { + throw new TypeError("Handoff.resume() may only be called once."); + } + this.resumed = true; + this.answer.resolve(value); + } + + release(reason: unknown): void { + if (this.released) { + return; + } + this.released = true; + this.releaseReason = reason; + if (this.offered) { + this.answer.reject(reason); + } + } + + settle(failure: unknown): void { + this.offered = true; + this.value.reject(failure); + if (!this.received) { + void this.value.promise.catch(() => {}); + } + } +} diff --git a/src/job/job.ts b/src/job/job.ts index 23fec58..641a4a9 100644 --- a/src/job/job.ts +++ b/src/job/job.ts @@ -6,9 +6,10 @@ import { isCancellation } from "./abort.js"; import { CancellationBindings } from "./cancellation-bindings.js"; import { FailureSet } from "./failure-set.js"; import { peekState, runWith, withoutExecution } from "../execution/state.js"; +import type { OwnedHandoff } from "./handoff.js"; export interface JobStartOptions { - readonly parent?: Job; + readonly parent?: Job; readonly context?: ExecutionContext; readonly propagation?: "propagate" | "isolate"; } @@ -18,30 +19,26 @@ export type JobResult = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: unknown }; -export type JobPublisher = (value: T | PromiseLike) => void; - const CHILD_FAILED = new DOMException("A child job failed", "AbortError"); -/** A cold, single-use execution that may publish before its full lifetime settles. */ -export class Job implements PromiseLike, AsyncDisposable { +/** A cold, single-use execution that settles once its body and descendants finish. */ +export class Job implements PromiseLike, AsyncDisposable { private readonly controller = new AbortController(); private readonly settled = Promise.withResolvers>(); - private publication?: PromiseWithResolvers>; - private publicationResult?: JobResult; - private publishing = false; - private children: Set> | undefined; - private owner: Job | undefined; + private children: Set> | undefined; + private owner: Job | undefined; private executionContext: ExecutionContext | undefined; private phase: JobState = "created"; private propagation: "propagate" | "isolate" = "propagate"; - private supervisor: Supervisor | undefined; + private supervisor: Supervisor | undefined; private propagateFailureToParent = false; private failures: FailureSet | undefined; private cancellation: CancellationBindings | undefined; private closing: Promise | undefined; + private handoff: OwnedHandoff | undefined; constructor( - private readonly body: (publish: JobPublisher) => T | PromiseLike, + private readonly body: () => T | PromiseLike, private readonly seed?: ExecutionSeed, ) { if (typeof body !== "function") { @@ -49,24 +46,7 @@ export class Job implements PromiseLike, AsyncDisposable { } } - /** - * Publish one value while the body is running, independently of Job cancellation. - */ - private readonly publish = (value: unknown | PromiseLike): void => { - if (this.phase !== "running") { - throw new LifecycleStateError("Job", "publish", this.phase); - } - if (this.publishing) { - throw new TypeError("Job.publish() may only be called once."); - } - this.publishing = true; - void Promise.resolve(value).then( - (published) => this.resolvePublication({ ok: true, value: published }), - (error: unknown) => this.resolvePublication({ ok: false, error }), - ); - }; - - get parent(): Job | undefined { + get parent(): Job | undefined { return this.owner; } @@ -111,7 +91,7 @@ export class Job implements PromiseLike, AsyncDisposable { return this.children?.size ?? 0; } - owns(other: Job | undefined): boolean { + owns(other: Job | undefined): boolean { for (let node = other; node; node = node.owner) { if (node === this) { return true; @@ -121,13 +101,21 @@ export class Job implements PromiseLike, AsyncDisposable { } /** Bind an optional manager before activation; ownership remains on this Job. */ - manage(supervisor: Supervisor): void { + manage(supervisor: Supervisor): void { if (this.phase !== "created" || this.supervisor) { throw new LifecycleStateError("Job", "manage", this.phase); } this.supervisor = supervisor; } + /** @internal Bind a HandoffJob's rendezvous before activation. */ + attach(handoff: OwnedHandoff): void { + if (this.phase !== "created" || this.handoff) { + throw new LifecycleStateError("Job", "attach", this.phase); + } + this.handoff = handoff; + } + start(options: JobStartOptions = {}): this { if (this.phase !== "created") { throw new LifecycleStateError("Job", "start", this.phase); @@ -230,7 +218,11 @@ export class Job implements PromiseLike, AsyncDisposable { value = await runWith({ job: this, context: this.context }, async () => { try { this.signal.throwIfAborted(); - return await this.body(this.publish); + const value = await this.body(); + if (this.handoff && !this.handoff.offered) { + throw new TypeError("HandoffJob body completed without offering a value."); + } + return value; } catch (error) { // Capture before a caller can cancel with this same value. this.recordFailure(error); @@ -294,7 +286,7 @@ export class Job implements PromiseLike, AsyncDisposable { } cancel(reason?: unknown): void { - const jobs: Job[] = [this]; + const jobs: Job[] = [this]; const reasons: unknown[] = [reason]; while (jobs.length > 0) { const job = jobs.pop()!; @@ -303,6 +295,7 @@ export class Job implements PromiseLike, AsyncDisposable { continue; } job.controller.abort(received); + job.handoff?.release(job.signal.reason); for (const child of job.children ?? []) { jobs.push(child); reasons.push(job.signal.reason); @@ -310,7 +303,7 @@ export class Job implements PromiseLike, AsyncDisposable { } } - private dependency(operation: string): LifecycleDependencyError | undefined { + protected dependency(operation: string): LifecycleDependencyError | undefined { return this.owns(peekState()?.job) ? new LifecycleDependencyError("Job", operation, "Job") : undefined; @@ -348,29 +341,6 @@ export class Job implements PromiseLike, AsyncDisposable { return this.settled.promise as Promise>; } - /** - * Observe the body-published value without joining the Job's descendants. - * - * A Job that closes without publishing rejects with its failure, or with a - * lifecycle error when the Job itself succeeded. - */ - value(): Promise { - const dependency = this.dependency("value"); - if (dependency) { - throw dependency; - } - const publication = (this.publication ??= Promise.withResolvers>()); - if (this.publicationResult) { - publication.resolve(this.publicationResult); - } - return publication.promise.then((result) => { - if (!result.ok) { - throw result.error; - } - return result.value as Published; - }); - } - joinChildren(): Promise { const current = peekState()?.job; if (current !== this && this.owns(current)) { @@ -440,24 +410,14 @@ export class Job implements PromiseLike, AsyncDisposable { return promise; } - private resolvePublication(result: JobResult): void { - this.publicationResult = result; - this.publication?.resolve(result); - } - private complete(result: JobResult): void { this.phase = "closed"; - if (!this.publishing) { - this.publishing = true; - this.resolvePublication( - result.ok - ? { ok: false, error: new LifecycleStateError("Job", "value", this.phase) } - : result, - ); - } this.cancellation?.[Symbol.dispose](); this.cancellation = undefined; this.owner?.children?.delete(this); + if (this.handoff && !this.handoff.offered && !result.ok) { + this.handoff.settle(result.error); + } this.settled.resolve(result); } diff --git a/src/supervisor/group-plan.ts b/src/supervisor/group-plan.ts index e96d343..5a6756b 100644 --- a/src/supervisor/group-plan.ts +++ b/src/supervisor/group-plan.ts @@ -12,7 +12,7 @@ export interface GroupBoundary { } export interface GroupLeaf { - readonly job: Job; + readonly job: Job; readonly boundary: GroupBoundary; readonly results: unknown[]; readonly index: number; @@ -24,7 +24,7 @@ export function planGroup(group: TaskGroup): { results: unknown[]; } { const leaves: GroupLeaf[] = []; - const seen = new Set>(); + const seen = new Set>(); const visit = ( declaration: TaskGroup, diff --git a/src/supervisor/group-runner.ts b/src/supervisor/group-runner.ts index 2290f94..43c3a57 100644 --- a/src/supervisor/group-runner.ts +++ b/src/supervisor/group-runner.ts @@ -9,12 +9,12 @@ const GROUP_FAILED = new DOMException("A TaskGroup member failed", "AbortError") /** Submission metadata and policy only; every leaf belongs to the supplied owner. */ export class GroupRunner { - readonly #members = new Map, GroupBoundary>(); + readonly #members = new Map, GroupBoundary>(); - constructor(private readonly owner: Job) {} + constructor(private readonly owner: Job) {} /** Apply declaration policy before the Supervisor decides root propagation. */ - childFailed(child: Job): boolean { + childFailed(child: Job): boolean { let boundary = this.#members.get(child); while (boundary) { if (boundary.failure === "isolate") { diff --git a/src/supervisor/supervisor.ts b/src/supervisor/supervisor.ts index 530d614..ced3225 100644 --- a/src/supervisor/supervisor.ts +++ b/src/supervisor/supervisor.ts @@ -12,12 +12,12 @@ export interface SupervisorOptions { type Handler = () => T | PromiseLike; /** Optional admission and failure policy for an ordinary, explicitly supplied Job. */ -export class Supervisor implements AsyncDisposable { - readonly job: Job; +export class Supervisor implements AsyncDisposable { + readonly job: Job; readonly failure: "fail-fast" | "isolate"; #groupRunner: GroupRunner | undefined; - constructor(job: Job, options: SupervisorOptions = {}) { + constructor(job: Job, options: SupervisorOptions = {}) { const failure = options.failure ?? "isolate"; if (failure !== "fail-fast" && failure !== "isolate") { throw new TypeError("Supervisor failure must be fail-fast or isolate."); @@ -31,7 +31,7 @@ export class Supervisor implements AsyncDisposable { return this.job.state; } - start(options?: JobStartOptions): Job { + start(options?: JobStartOptions): Job { if (this.job.state !== "created") { return this.job; } @@ -40,18 +40,14 @@ export class Supervisor implements AsyncDisposable { run(handler: Handler): Job; run(seed: ExecutionSeed, handler: Handler): Job; - run(job: Job): Job; + run>(job: J): J; run( group: TaskGroup, ): Promise>; run( - input: - | ExecutionSeed - | Handler - | Job - | TaskGroup, + input: ExecutionSeed | Handler | Job | TaskGroup, handler?: Handler, - ): Job | Promise { + ): Job | Promise { if (this.job.state !== "running" || this.job.signal.aborted) { throw new LifecycleStateError("Supervisor", "run", this.state); } @@ -84,7 +80,7 @@ export class Supervisor implements AsyncDisposable { } /** @internal Decide propagation from an actual child's genuine failure. */ - childFailed(child: Job): boolean { + childFailed(child: Job): boolean { const propagate = this.#groupRunner?.childFailed(child) ?? true; return propagate && this.failure === "fail-fast"; } diff --git a/src/supervisor/task-group.ts b/src/supervisor/task-group.ts index 446bb32..64916a7 100644 --- a/src/supervisor/task-group.ts +++ b/src/supervisor/task-group.ts @@ -1,9 +1,9 @@ import type { Job } from "../job/job.js"; -export type GroupMember = Job | TaskGroup; +export type GroupMember = Job | TaskGroup; export type GroupResults = { - -readonly [Index in keyof Members]: Members[Index] extends Job + -readonly [Index in keyof Members]: Members[Index] extends Job ? Result : Members[Index] extends TaskGroup ? GroupResults diff --git a/tests/handoff-job.test.ts b/tests/handoff-job.test.ts new file mode 100644 index 0000000..214a8f5 --- /dev/null +++ b/tests/handoff-job.test.ts @@ -0,0 +1,220 @@ +import { setImmediate as nextTurn } from "node:timers/promises"; +import { expect, test } from "vitest"; +import { + currentState, + fork, + signal, + HandoffJob, + Job, + LifecycleDependencyError, + Supervisor, +} from "../src/index.js"; + +test("the body suspends at its offer until the consumer resumes, and the Job still settles after descendants", async () => { + const releaseChild = Promise.withResolvers(); + const childStarted = Promise.withResolvers(); + const order: string[] = []; + const job = new HandoffJob(async (handoff) => { + fork(async () => { + childStarted.resolve(); + await releaseChild.promise; + order.push("child"); + }); + const answer = await handoff.offer(1); + order.push(`resumed:${answer}`); + return "done"; + }).start(); + let settled = false; + const completion = job.then((value) => { + settled = true; + return value; + }); + + expect(await job.receive()).toBe(1); + await childStarted.promise; + await nextTurn(); + expect(order).toEqual([]); + + job.resume("ack"); + await nextTurn(); + expect(order).toEqual(["resumed:ack"]); + expect(settled).toBe(false); + + releaseChild.resolve(); + expect(await completion).toBe("done"); + expect(order).toEqual(["resumed:ack", "child"]); +}); + +test("offer and resume are each accepted at most once", async () => { + const job = new HandoffJob(async (handoff) => { + const first = handoff.offer("first"); + expect(() => handoff.offer("second")).toThrowError(TypeError); + await first; + }).start(); + + expect(await job.receive()).toBe("first"); + job.resume("ack"); + expect(() => job.resume("again")).toThrowError(TypeError); + await job; +}); + +test("failure before an offer rejects the receiver with the composed Job failure", async () => { + const bodyFailure = new Error("body"); + const childFailure = new Error("child"); + const job = new HandoffJob(async () => { + fork(() => { + throw childFailure; + }); + await nextTurn(); + throw bodyFailure; + }); + const received = job.receive(); + job.start(); + + const result = await job.result(); + if (result.ok) { + throw new Error("expected the Job to fail"); + } + await expect(received).rejects.toBe(result.error); + await expect(received).rejects.toMatchObject({ errors: [childFailure, bodyFailure] }); +}); + +test("a body that completes without offering fails, and its receiver fails with the same error", async () => { + const job = new HandoffJob(() => 42).start(); + + const result = await job.result(); + if (result.ok) { + throw new Error("expected the Job to fail"); + } + expect(result.error).toBeInstanceOf(TypeError); + await expect(job.receive()).rejects.toBe(result.error); +}); + +test("a failed HandoffJob that nobody receives from does not raise an unhandled rejection", async () => { + const failure = new Error("unobserved"); + const job = new HandoffJob(() => { + throw failure; + }).start(); + + expect(await job.result()).toEqual({ ok: false, error: failure }); + await nextTurn(); + // A late receiver still learns why no value arrived. + await expect(job.receive()).rejects.toBe(failure); +}); + +test("cancellation releases a body suspended in its offer with the original reason", async () => { + const reason = new Error("stop"); + let released: unknown; + const job = new HandoffJob(async (handoff) => { + try { + await handoff.offer("value"); + } catch (error) { + released = error; + } + return "cleaned"; + }).start(); + + expect(await job.receive()).toBe("value"); + job.cancel(reason); + expect(await job.result()).toEqual({ ok: false, error: reason }); + expect(released).toBe(reason); + // A late acknowledgement is discarded rather than rejected. + job.resume("late"); + expect(() => job.resume("twice")).toThrowError(TypeError); +}); + +test("an offer made after cancellation still reaches the consumer without suspending the body", async () => { + const childFailure = new Error("child"); + const job = new HandoffJob(async (handoff) => { + fork(() => { + throw childFailure; + }); + await nextTurn(); + expect(signal().aborted).toBe(true); + await expect(handoff.offer("mapped")).rejects.toBe(signal().reason); + }).start(); + + expect(await job.receive()).toBe("mapped"); + expect(await job.result()).toEqual({ ok: false, error: childFailure }); +}); + +test("an abandoned consumer cannot keep a closing Job suspended", async () => { + const job = new HandoffJob(async (handoff) => { + await handoff.offer("value").catch(() => {}); + }).start(); + expect(await job.receive()).toBe("value"); + await nextTurn(); + expect(job.state).toBe("running"); + + await job.close(); + expect(job.state).toBe("closed"); +}); + +test("owner cancellation cascades into a supervised HandoffJob's suspended offer", async () => { + const reason = new Error("shutdown"); + let released: unknown; + const owner = new Supervisor(new Job(() => untilCancelled())); + owner.start({ parent: undefined }); + const job = owner.run( + new HandoffJob(async (handoff) => { + try { + await handoff.offer("value"); + } catch (error) { + released = error; + } + }), + ); + expect(await job.receive()).toBe("value"); + + await owner.close(reason); + expect(released).toBe(reason); + expect(job.state).toBe("closed"); +}); + +test("a resumed answer is the body's to interpret; only what the body raises fails the Job", async () => { + const delivery = new Error("delivery"); + const cleanup = new Error("cleanup"); + type Outcome = { readonly error?: unknown }; + + const tolerant = new HandoffJob(async (handoff) => { + const outcome = await handoff.offer("value"); + return outcome.error === delivery ? "tolerated" : "unexpected"; + }).start(); + expect(await tolerant.receive()).toBe("value"); + tolerant.resume({ error: delivery }); + expect(await tolerant.result()).toEqual({ ok: true, value: "tolerated" }); + + const strict = new HandoffJob(async (handoff) => { + await handoff.offer("value"); + throw cleanup; + }).start(); + expect(await strict.receive()).toBe("value"); + strict.resume({ error: delivery }); + expect(await strict.result()).toEqual({ ok: false, error: cleanup }); +}); + +test("receive rejects self and descendant observation synchronously", async () => { + const job = new HandoffJob(async (handoff) => { + const owner = currentState().job as HandoffJob; + expect(() => owner.receive()).toThrowError(LifecycleDependencyError); + const value = await fork(() => { + expect(() => owner.receive()).toThrowError(LifecycleDependencyError); + return 42; + }); + await handoff.offer("ready"); + return value; + }); + const received = job.receive(); + job.start(); + expect(await received).toBe("ready"); + job.resume(); + expect(await job).toBe(42); +}); + +function untilCancelled(): Promise { + const current = signal(); + current.throwIfAborted(); + const { promise, resolve } = Promise.withResolvers(); + current.addEventListener("abort", () => resolve(), { once: true }); + return promise; +} diff --git a/tests/job.test.ts b/tests/job.test.ts index d1b0b7b..a2b7dd4 100644 --- a/tests/job.test.ts +++ b/tests/job.test.ts @@ -44,82 +44,6 @@ test("a fork cannot settle before its nested child", async () => { }); }); -test("a Job can publish its value before its body and descendants settle", async () => { - const releaseBody = Promise.withResolvers(); - const releaseChild = Promise.withResolvers(); - const childStarted = Promise.withResolvers(); - let settled = false; - const job = new Job(async (publish) => { - fork(async () => { - childStarted.resolve(); - await releaseChild.promise; - }); - publish(Promise.resolve("ready")); - expect(() => publish("again")).toThrowError(TypeError); - await releaseBody.promise; - return 7; - }).start(); - const completion = job.then((value) => { - settled = true; - return value; - }); - - await childStarted.promise; - expect(await job.value()).toBe("ready"); - expect(settled).toBe(false); - releaseBody.resolve(); - await nextTurn(); - expect(settled).toBe(false); - releaseChild.resolve(); - expect(await completion).toBe(7); -}); - -test("value rejects when a Job settles before publishing", async () => { - const unreported = new Job(() => 42); - const missing = unreported.value(); - unreported.start(); - await expect(missing).rejects.toBeInstanceOf(LifecycleStateError); - expect(await unreported).toBe(42); - - const failure = new Error("before publication"); - const failed = new Job(() => { - throw failure; - }); - const value = failed.value(); - failed.start(); - await expect(value).rejects.toBe(failure); -}); - -test("a cancelled Job still publishes the answer its body owes", async () => { - const childFailure = new Error("child"); - const released = Promise.withResolvers(); - const job = new Job(async (publish) => { - fork(() => { - throw childFailure; - }); - await nextTurn(); - // The child's failure has cancelled this Job; the answer is still owed. - expect(signal().aborted).toBe(true); - publish("mapped"); - await released.promise; - }).start(); - - expect(await job.value()).toBe("mapped"); - released.resolve(); - expect(await job.result()).toEqual({ ok: false, error: childFailure }); -}); - -test("a rejected publication does not replace the Job's lifetime result", async () => { - const publicationFailure = new Error("publication"); - const job = new Job((publish) => { - publish(Promise.reject(publicationFailure)); - return 42; - }).start(); - - await expect(job.value()).rejects.toBe(publicationFailure); - expect(await job).toBe(42); -}); - test("successful completion joins naturally without cancelling body or child signals", async () => { let body!: AbortSignal; let child!: AbortSignal; @@ -276,10 +200,8 @@ test("self and descendant result observations reject without replacing the owner const job = new Job(async () => { const owner = currentState().job; expect(() => owner.result()).toThrowError(LifecycleDependencyError); - expect(() => owner.value()).toThrowError(LifecycleDependencyError); return await fork(() => { expect(() => owner.result()).toThrowError(LifecycleDependencyError); - expect(() => owner.value()).toThrowError(LifecycleDependencyError); return 42; }); });