From b585a1b9ce23db98c6b30d69509cfc9a9fdda44c Mon Sep 17 00:00:00 2001
From: Taras Mankovski <74687+taras@users.noreply.github.com>
Date: Fri, 7 Aug 2026 19:05:00 -0400
Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20Add=20cooperative=20service=20h?=
=?UTF-8?q?ost=20API?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
packages/cli/src/bun-service.ts | 17 ++
packages/cli/src/compiled-service.ts | 17 ++
packages/cli/src/deno-service.ts | 17 ++
packages/cli/src/node-service.ts | 17 ++
packages/cli/src/service-host.ts | 245 ++++++++++++++++++
.../tests/fixtures/cooperative-service.mjs | 84 ++++++
packages/cli/tests/service-host.test.ts | 182 +++++++++++++
packages/runtime/apis.ts | 13 +-
packages/runtime/mod.ts | 24 ++
packages/runtime/service.ts | 185 +++++++++++++
packages/runtime/test/README.md | 8 +
packages/runtime/test/mod.ts | 3 +-
packages/runtime/test/stubs.ts | 19 ++
packages/runtime/tests/service.test.ts | 119 +++++++++
14 files changed, 946 insertions(+), 4 deletions(-)
create mode 100644 packages/cli/src/bun-service.ts
create mode 100644 packages/cli/src/compiled-service.ts
create mode 100644 packages/cli/src/deno-service.ts
create mode 100644 packages/cli/src/node-service.ts
create mode 100644 packages/cli/src/service-host.ts
create mode 100644 packages/cli/tests/fixtures/cooperative-service.mjs
create mode 100644 packages/cli/tests/service-host.test.ts
create mode 100644 packages/runtime/service.ts
create mode 100644 packages/runtime/tests/service.test.ts
diff --git a/packages/cli/src/bun-service.ts b/packages/cli/src/bun-service.ts
new file mode 100644
index 00000000..4b9f80e7
--- /dev/null
+++ b/packages/cli/src/bun-service.ts
@@ -0,0 +1,17 @@
+import { randomBytes } from "node:crypto";
+import process from "node:process";
+import type { Operation } from "effection";
+import { inheritedEnvironment, installHostService } from "./service-host.ts";
+
+export function useBunService(): Operation {
+ return installHostService({
+ token: () => randomBytes(32).toString("hex"),
+ environment: () => inheritedEnvironment(process.env),
+ stdout(bytes) {
+ process.stdout.write(bytes);
+ },
+ stderr(bytes) {
+ process.stderr.write(bytes);
+ },
+ });
+}
diff --git a/packages/cli/src/compiled-service.ts b/packages/cli/src/compiled-service.ts
new file mode 100644
index 00000000..998225de
--- /dev/null
+++ b/packages/cli/src/compiled-service.ts
@@ -0,0 +1,17 @@
+import { randomBytes } from "node:crypto";
+import process from "node:process";
+import type { Operation } from "effection";
+import { inheritedEnvironment, installHostService } from "./service-host.ts";
+
+export function useCompiledService(): Operation {
+ return installHostService({
+ token: () => randomBytes(32).toString("hex"),
+ environment: () => inheritedEnvironment(process.env),
+ stdout(bytes) {
+ process.stdout.write(bytes);
+ },
+ stderr(bytes) {
+ process.stderr.write(bytes);
+ },
+ });
+}
diff --git a/packages/cli/src/deno-service.ts b/packages/cli/src/deno-service.ts
new file mode 100644
index 00000000..c0cc0233
--- /dev/null
+++ b/packages/cli/src/deno-service.ts
@@ -0,0 +1,17 @@
+import { randomBytes } from "node:crypto";
+import process from "node:process";
+import type { Operation } from "effection";
+import { inheritedEnvironment, installHostService } from "./service-host.ts";
+
+export function useDenoService(): Operation {
+ return installHostService({
+ token: () => randomBytes(32).toString("hex"),
+ environment: () => inheritedEnvironment(process.env),
+ stdout(bytes) {
+ process.stdout.write(bytes);
+ },
+ stderr(bytes) {
+ process.stderr.write(bytes);
+ },
+ });
+}
diff --git a/packages/cli/src/node-service.ts b/packages/cli/src/node-service.ts
new file mode 100644
index 00000000..060b5226
--- /dev/null
+++ b/packages/cli/src/node-service.ts
@@ -0,0 +1,17 @@
+import { randomBytes } from "node:crypto";
+import process from "node:process";
+import type { Operation } from "effection";
+import { inheritedEnvironment, installHostService } from "./service-host.ts";
+
+export function useNodeService(): Operation {
+ return installHostService({
+ token: () => randomBytes(32).toString("hex"),
+ environment: () => inheritedEnvironment(process.env),
+ stdout(bytes) {
+ process.stdout.write(bytes);
+ },
+ stderr(bytes) {
+ process.stderr.write(bytes);
+ },
+ });
+}
diff --git a/packages/cli/src/service-host.ts b/packages/cli/src/service-host.ts
new file mode 100644
index 00000000..e128ca95
--- /dev/null
+++ b/packages/cli/src/service-host.ts
@@ -0,0 +1,245 @@
+/** Shared mechanics for the runtime-named cooperative service adapters. */
+
+import { ensure, race, resource, withResolvers, type Operation } from "effection";
+import { daemon, Stdio } from "@effectionx/process";
+import { timebox } from "@effectionx/timebox";
+import {
+ API,
+ SERVICE_HOSTNAME,
+ SERVICE_READY_PREFIX,
+ ServiceProcessExitBeforeReadyError,
+ ServiceProtocolDuplicateError,
+ ServiceProtocolMalformedError,
+ ServiceStartupTimeoutError,
+ ServiceUnexpectedExitError,
+ parseServiceReadyRecord,
+ timeout,
+} from "@executablemd/runtime";
+import type { ServiceEndpoint, ServiceResource, ServiceStartOptions } from "@executablemd/runtime";
+
+interface HostServiceAdapter {
+ token(): string;
+ environment(): Record;
+ stdout(bytes: Uint8Array): void;
+ stderr(bytes: Uint8Array): void;
+}
+
+interface ProtocolObserver {
+ stdout(bytes: Uint8Array): Operation;
+ flush(): Operation;
+}
+
+const encoder = new TextEncoder();
+const prefixBytes = encoder.encode(SERVICE_READY_PREFIX);
+
+function concat(left: Uint8Array, right: Uint8Array): Uint8Array {
+ const joined = new Uint8Array(left.byteLength + right.byteLength);
+ joined.set(left);
+ joined.set(right, left.byteLength);
+ return joined;
+}
+
+function startsWithPrefix(line: Uint8Array): boolean {
+ if (line.byteLength < prefixBytes.byteLength) {
+ return false;
+ }
+ for (let index = 0; index < prefixBytes.byteLength; index += 1) {
+ if (line[index] !== prefixBytes[index]) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function createProtocolObserver(options: {
+ token: string;
+ ready(endpoint: ServiceEndpoint): void;
+ fail(error: Error): void;
+ forward(bytes: Uint8Array): void;
+}): ProtocolObserver {
+ let pending: Uint8Array = new Uint8Array();
+ let readinessSeen = false;
+
+ function consume(line: Uint8Array): void {
+ if (!startsWithPrefix(line)) {
+ options.forward(line);
+ return;
+ }
+ if (readinessSeen) {
+ options.fail(new ServiceProtocolDuplicateError());
+ return;
+ }
+
+ let payload: string;
+ try {
+ payload = new TextDecoder("utf-8", { fatal: true }).decode(
+ line.subarray(prefixBytes.byteLength, line.byteLength - 1),
+ );
+ } catch {
+ options.fail(new ServiceProtocolMalformedError());
+ return;
+ }
+
+ try {
+ const endpoint = parseServiceReadyRecord(payload, options.token);
+ readinessSeen = true;
+ options.ready(endpoint);
+ } catch (error) {
+ options.fail(error instanceof Error ? error : new ServiceProtocolMalformedError());
+ }
+ }
+
+ return {
+ *stdout(bytes: Uint8Array): Operation {
+ pending = concat(pending, bytes);
+ let newline = pending.indexOf(10);
+ while (newline !== -1) {
+ const line = pending.slice(0, newline + 1);
+ pending = pending.slice(newline + 1);
+ consume(line);
+ newline = pending.indexOf(10);
+ }
+ },
+ *flush(): Operation {
+ if (pending.byteLength > 0) {
+ if (startsWithPrefix(pending)) {
+ pending = new Uint8Array();
+ throw new ServiceProtocolMalformedError();
+ } else {
+ options.forward(pending);
+ pending = new Uint8Array();
+ }
+ }
+ },
+ };
+}
+
+function validTimeout(value: number): number {
+ if (!Number.isFinite(value) || value <= 0) {
+ throw new Error("service startup timeout must be a positive finite number");
+ }
+ return value;
+}
+
+function exitFacts(status: { code?: number; signal?: string }): {
+ code?: number;
+ signal?: string;
+} {
+ return {
+ ...(typeof status.code === "number" ? { code: status.code } : {}),
+ ...(typeof status.signal === "string" ? { signal: status.signal } : {}),
+ };
+}
+
+function* waitForStartup(options: {
+ ready: Operation;
+ protocolFailure: Operation;
+ process: { join(): Operation<{ code?: number; signal?: string }> };
+ observer: ProtocolObserver;
+ startupTimeout: number;
+}): Operation {
+ const result = yield* timebox(options.startupTimeout, () =>
+ race([
+ options.ready,
+ (function* (): Operation {
+ const status = yield* options.process.join();
+ yield* options.observer.flush();
+ throw new ServiceProcessExitBeforeReadyError(exitFacts(status));
+ })(),
+ (function* (): Operation {
+ return yield* options.protocolFailure;
+ })(),
+ ]),
+ );
+ if (result.timeout) {
+ throw new ServiceStartupTimeoutError(options.startupTimeout);
+ }
+ return result.value;
+}
+
+function startHostService(
+ options: ServiceStartOptions,
+ adapter: HostServiceAdapter,
+): Operation {
+ return resource(function* (provide) {
+ const token = adapter.token();
+ if (!/^[0-9a-f]{64}$/.test(token)) {
+ throw new Error("host service adapter returned an invalid authentication token");
+ }
+ const startupTimeout = validTimeout(options.startupTimeout ?? (yield* timeout));
+ const ready = withResolvers();
+ const protocolFailure = withResolvers();
+ const observer = createProtocolObserver({
+ token,
+ ready: ready.resolve,
+ fail: protocolFailure.reject,
+ forward: adapter.stdout,
+ });
+
+ yield* Stdio.around({
+ *stdout([bytes]) {
+ yield* observer.stdout(bytes);
+ },
+ *stderr([bytes]) {
+ adapter.stderr(bytes);
+ },
+ });
+
+ yield* ensure(function* () {
+ yield* observer.flush();
+ });
+
+ const environment = {
+ ...adapter.environment(),
+ XMD_SERVICE_PROTOCOL: "1",
+ XMD_SERVICE_TOKEN: token,
+ XMD_SERVICE_HOST: SERVICE_HOSTNAME,
+ XMD_SERVICE_PORT: "0",
+ };
+ const process = yield* daemon(options.command, {
+ shell: true,
+ cwd: options.cwd,
+ env: environment,
+ });
+ const endpoint = yield* waitForStartup({
+ ready: ready.operation,
+ protocolFailure: protocolFailure.operation,
+ process,
+ observer,
+ startupTimeout,
+ });
+
+ yield* race([
+ provide({ endpoint }),
+ (function* (): Operation {
+ const status = yield* process.join();
+ yield* observer.flush();
+ throw new ServiceUnexpectedExitError(exitFacts(status));
+ })(),
+ protocolFailure.operation,
+ ]);
+ });
+}
+
+export function installHostService(adapter: HostServiceAdapter): Operation {
+ return API.Service.around(
+ {
+ *start([options]) {
+ return yield* startHostService(options, adapter);
+ },
+ },
+ { at: "min" },
+ );
+}
+
+export function inheritedEnvironment(
+ source: Record,
+): Record {
+ const environment: Record = {};
+ for (const [name, value] of Object.entries(source)) {
+ if (value !== undefined) {
+ environment[name] = value;
+ }
+ }
+ return environment;
+}
diff --git a/packages/cli/tests/fixtures/cooperative-service.mjs b/packages/cli/tests/fixtures/cooperative-service.mjs
new file mode 100644
index 00000000..25eeca41
--- /dev/null
+++ b/packages/cli/tests/fixtures/cooperative-service.mjs
@@ -0,0 +1,84 @@
+import { createServer } from "node:http";
+
+const [mode = "normal", nonce = "none"] = process.argv.slice(2);
+const host = process.env.XMD_SERVICE_HOST;
+const requestedPort = Number(process.env.XMD_SERVICE_PORT);
+const token = process.env.XMD_SERVICE_TOKEN;
+
+process.stderr.write(`service pid:${process.pid}\n`);
+
+if (mode === "exit-before") {
+ process.exit(17);
+}
+
+if (mode === "non-cooperative") {
+ setInterval(() => {}, 1_000);
+} else {
+ process.stdout.write("service stdout before readiness\n");
+ process.stderr.write("service stderr before readiness\n");
+
+ const server = createServer((_request, response) => {
+ response.end(`service:${nonce}`);
+ });
+
+ server.listen(requestedPort, host, () => {
+ const address = server.address();
+ if (typeof address !== "object" || address === null) {
+ process.exit(18);
+ }
+
+ const ready = {
+ version: 1,
+ token,
+ hostname: host,
+ port: address.port,
+ };
+ if (mode === "malformed") {
+ process.stdout.write(`XMD_SERVICE_READY:{not-json}\n`);
+ return;
+ }
+ if (mode === "non-object") {
+ process.stdout.write(`XMD_SERVICE_READY:null\n`);
+ return;
+ }
+ if (mode === "incompatible") {
+ process.stdout.write(`XMD_SERVICE_READY:${JSON.stringify({ ...ready, version: 2 })}\n`);
+ return;
+ }
+ if (mode === "forged") {
+ process.stdout.write(
+ `XMD_SERVICE_READY:${JSON.stringify({ ...ready, token: "forged-token" })}\n`,
+ );
+ return;
+ }
+ if (mode === "wrong-host") {
+ process.stdout.write(
+ `XMD_SERVICE_READY:${JSON.stringify({ ...ready, hostname: "0.0.0.0" })}\n`,
+ );
+ return;
+ }
+ if (mode === "extra-member") {
+ process.stdout.write(`XMD_SERVICE_READY:${JSON.stringify({ ...ready, unsafe: token })}\n`);
+ return;
+ }
+ if (mode === "partial-record") {
+ process.stdout.write(`XMD_SERVICE_READY:${JSON.stringify(ready)}`);
+ setTimeout(() => process.exit(20), 10);
+ }
+ if (mode === "delayed") {
+ return;
+ }
+
+ const line = `XMD_SERVICE_READY:${JSON.stringify(ready)}\n`;
+ process.stdout.write(line);
+ process.stdout.write("service stdout after readiness\n");
+ process.stderr.write("service stderr after readiness\n");
+
+ if (mode === "duplicate") {
+ setTimeout(() => process.stdout.write(line), 10);
+ }
+ if (mode === "exit-after") {
+ setTimeout(() => process.exit(19), 10);
+ }
+ });
+}
diff --git a/packages/cli/tests/service-host.test.ts b/packages/cli/tests/service-host.test.ts
new file mode 100644
index 00000000..836cc204
--- /dev/null
+++ b/packages/cli/tests/service-host.test.ts
@@ -0,0 +1,182 @@
+import { describe, it } from "@executablemd/test-support/bdd";
+import { expect } from "@executablemd/test-support/expect";
+import { scoped, spawn, suspend, until, withResolvers, type Operation } from "effection";
+import { when } from "@effectionx/converge";
+import { timebox } from "@effectionx/timebox";
+import {
+ ServiceProcessExitBeforeReadyError,
+ ServiceProtocolDuplicateError,
+ ServiceProtocolHostnameMismatchError,
+ ServiceProtocolIncompatibleError,
+ ServiceProtocolMalformedError,
+ ServiceProtocolTokenMismatchError,
+ ServiceStartupTimeoutError,
+ ServiceUnexpectedExitError,
+ startService,
+} from "@executablemd/runtime";
+import { inheritedEnvironment, installHostService } from "../src/service-host.ts";
+import process from "node:process";
+
+const TOKEN = "12".repeat(32);
+const fixture = new URL("./fixtures/cooperative-service.mjs", import.meta.url).pathname;
+
+function command(mode: string, nonce = "nonce"): string {
+ return `node ${JSON.stringify(fixture)} ${mode} ${nonce}`;
+}
+
+function adapter(stdout: string[], stderr: string[]) {
+ const decoder = new TextDecoder();
+ return {
+ token: () => TOKEN,
+ environment: () => inheritedEnvironment(process.env),
+ stdout(bytes: Uint8Array) {
+ stdout.push(decoder.decode(bytes));
+ },
+ stderr(bytes: Uint8Array) {
+ stderr.push(decoder.decode(bytes));
+ },
+ };
+}
+
+function fixturePids(stderr: string[]): number[] {
+ return [...stderr.join("").matchAll(/service pid:(\d+)/g)].map((match) => Number(match[1]));
+}
+
+function isAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function* expectGone(pids: number[]): Operation {
+ const result = yield* timebox(2_000, () =>
+ when(function* () {
+ if (pids.some(isAlive)) {
+ throw new Error("service child has not exited yet");
+ }
+ }),
+ );
+ expect(result.timeout).toBe(false);
+}
+
+describe("cooperative host service adapter", () => {
+ it("starts real isolated services, forwards live output, and suppresses readiness", function* () {
+ const stdout: string[] = [];
+ const stderr: string[] = [];
+
+ yield* scoped(function* () {
+ yield* installHostService(adapter(stdout, stderr));
+ const first = yield* startService({ command: command("normal", "first") });
+ const second = yield* startService({ command: command("normal", "second") });
+
+ expect(first.endpoint.port).not.toBe(second.endpoint.port);
+ expect(Object.isFrozen(first.endpoint)).toBe(true);
+
+ const firstResponse = yield* until(
+ globalThis
+ .fetch(`http://${first.endpoint.hostname}:${first.endpoint.port}`)
+ .then((response) => response.text()),
+ );
+ const secondResponse = yield* until(
+ globalThis
+ .fetch(`http://${second.endpoint.hostname}:${second.endpoint.port}`)
+ .then((response) => response.text()),
+ );
+ expect(firstResponse).toBe("service:first");
+ expect(secondResponse).toBe("service:second");
+ });
+
+ expect(stdout.join("")).toContain("service stdout before readiness");
+ expect(stdout.join("")).toContain("service stdout after readiness");
+ expect(stderr.join("")).toContain("service stderr before readiness");
+ expect(stderr.join("")).toContain("service stderr after readiness");
+ expect(stdout.join("")).not.toContain("XMD_SERVICE_READY");
+ expect(stdout.join("")).not.toContain(TOKEN);
+ expect(fixturePids(stderr)).toHaveLength(2);
+ yield* expectGone(fixturePids(stderr));
+ });
+
+ it("categorizes startup failures without exposing protocol records", function* () {
+ const cases: Array<[string, { prototype: Error }, number]> = [
+ ["exit-before", ServiceProcessExitBeforeReadyError, 2_000],
+ ["malformed", ServiceProtocolMalformedError, 2_000],
+ ["non-object", ServiceProtocolMalformedError, 2_000],
+ ["incompatible", ServiceProtocolIncompatibleError, 2_000],
+ ["forged", ServiceProtocolTokenMismatchError, 2_000],
+ ["wrong-host", ServiceProtocolHostnameMismatchError, 2_000],
+ ["extra-member", ServiceProtocolMalformedError, 2_000],
+ ["partial-record", ServiceProtocolMalformedError, 2_000],
+ ["non-cooperative", ServiceStartupTimeoutError, 75],
+ ];
+
+ for (const [mode, ErrorType, startupTimeout] of cases) {
+ let failure: unknown;
+ const stdout: string[] = [];
+ const stderr: string[] = [];
+ try {
+ yield* scoped(function* () {
+ yield* installHostService(adapter(stdout, stderr));
+ yield* startService({ command: command(mode), startupTimeout });
+ });
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(failure).toBeInstanceOf(ErrorType);
+ expect(String(failure)).not.toContain(TOKEN);
+ expect(JSON.stringify(failure)).not.toContain(TOKEN);
+ expect(stdout.join("")).not.toContain("XMD_SERVICE_READY");
+ yield* expectGone(fixturePids(stderr));
+ }
+ });
+
+ it("cancels startup and releases the child before readiness", function* () {
+ const pidPublished = withResolvers();
+ const decoder = new TextDecoder();
+ let pid = 0;
+
+ yield* scoped(function* () {
+ yield* installHostService({
+ ...adapter([], []),
+ stderr(bytes: Uint8Array) {
+ const match = /service pid:(\d+)/.exec(decoder.decode(bytes));
+ if (match) {
+ pidPublished.resolve(Number(match[1]));
+ }
+ },
+ });
+ yield* spawn(function* () {
+ yield* startService({ command: command("delayed"), startupTimeout: 10_000 });
+ });
+ pid = yield* pidPublished.operation;
+ });
+
+ expect(pid).toBeGreaterThan(0);
+ yield* expectGone([pid]);
+ });
+
+ it("fails the owning scope when a ready process exits or repeats readiness", function* () {
+ const cases: Array<[string, { prototype: Error }]> = [
+ ["exit-after", ServiceUnexpectedExitError],
+ ["duplicate", ServiceProtocolDuplicateError],
+ ];
+
+ for (const [mode, ErrorType] of cases) {
+ let failure: unknown;
+ try {
+ yield* scoped(function* () {
+ yield* installHostService(adapter([], []));
+ yield* startService({ command: command(mode) });
+ yield* suspend();
+ });
+ } catch (error) {
+ failure = error;
+ }
+ expect(failure).toBeInstanceOf(ErrorType);
+ expect(String(failure)).not.toContain(TOKEN);
+ }
+ });
+});
diff --git a/packages/runtime/apis.ts b/packages/runtime/apis.ts
index 2a08fcee..1dfd263a 100644
--- a/packages/runtime/apis.ts
+++ b/packages/runtime/apis.ts
@@ -1,7 +1,8 @@
/**
* Runtime Context APIs — platform I/O operations with pluggable middleware.
*
- * Five domain-specific context APIs built on `@effectionx/context-api`.
+ * Five host-backed domain APIs plus the provider-neutral Service Api, built on
+ * `@effectionx/context-api`.
* Each API provides default Node.js implementations. Use `.around()` to
* install middleware (mocking, instrumentation, sandboxing) scoped to the
* current Effection scope.
@@ -25,7 +26,7 @@
* });
* ```
*
- * ## Why four separate APIs?
+ * ## Why separate APIs?
*
* - **Process** — subprocess lifecycle has its own cancellation semantics
* (killing processes on scope teardown). Middleware targets exec only.
@@ -41,6 +42,8 @@
* use `.around()` to mock platform/env for deterministic replay; an
* entrypoint installs its `command` and `compile` with `{ at: "min" }` so
* ordinary middleware can wrap them.
+ * - **Service** — scoped cooperative-service acquisition. Its terminal handler
+ * requires an explicit host provider and never detects or imports a runtime.
*
* ## Middleware
*
@@ -59,7 +62,8 @@
* ## Test stubs
*
* Common stubs are provided by `@executablemd/runtime/test`:
- * `useStubFs(files)`, `useEchoExec()`, `useFailingExec(code, stderr)`.
+ * `useStubFs(files)`, `useEchoExec()`, `useFailingExec(code, stderr)`,
+ * `useStubService(endpoint)`.
*/
import { type Api, createApi } from "@effectionx/context-api";
@@ -80,6 +84,7 @@ import { exec as processExec } from "@effectionx/process";
import { race, sleep, until } from "effection";
import type { Operation } from "effection";
import { timeout as contextualTimeout } from "./config.ts";
+import { Service } from "./service.ts";
/**
* Result of a `stat` call.
@@ -339,6 +344,7 @@ export const API: {
Fs: Api;
Fetch: Api;
Env: Api;
+ Service: typeof Service;
} = {
/**
* Subprocess execution.
@@ -539,6 +545,7 @@ export const API: {
);
},
}),
+ Service,
};
export const exec: typeof API.Process.operations.exec = API.Process.operations.exec;
diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts
index 5099f055..911b26b2 100644
--- a/packages/runtime/mod.ts
+++ b/packages/runtime/mod.ts
@@ -13,6 +13,7 @@
* - `API.Env` — the host: variables, platform info, the command that invokes
* this xmd, and eval-block compilation
* (`cwd`, `env`, `platform`, `command`, `compile`)
+ * - `API.Service` — scoped cooperative service startup (`startService`)
* - `Config` — shared execution config (`timeout`)
*
* See `apis.ts` for architecture rationale.
@@ -39,5 +40,28 @@ export {
} from "./apis.ts";
export type { EvalBlock, ResponseHeaders, RuntimeFetchResponse, StatResult } from "./apis.ts";
export { findFreePort } from "./find-free-port.ts";
+export {
+ Service,
+ SERVICE_HOSTNAME,
+ SERVICE_READY_PREFIX,
+ ServiceProcessExitBeforeReadyError,
+ ServiceProtocolDuplicateError,
+ ServiceProtocolHostnameMismatchError,
+ ServiceProtocolIncompatibleError,
+ ServiceProtocolMalformedError,
+ ServiceProtocolTokenMismatchError,
+ ServiceProviderError,
+ ServiceStartupTimeoutError,
+ ServiceTeardownError,
+ ServiceUnexpectedExitError,
+ parseServiceReadyRecord,
+ startService,
+} from "./service.ts";
+export type {
+ ServiceEndpoint,
+ ServiceHandler,
+ ServiceResource,
+ ServiceStartOptions,
+} from "./service.ts";
export { Config, timeout } from "./config.ts";
export type { ConfigApi } from "./config.ts";
diff --git a/packages/runtime/service.ts b/packages/runtime/service.ts
new file mode 100644
index 00000000..2a6891fe
--- /dev/null
+++ b/packages/runtime/service.ts
@@ -0,0 +1,185 @@
+/**
+ * Provider-neutral cooperative service lifecycle.
+ *
+ * The shared runtime owns the protocol shape and validation. A runtime-named
+ * host adapter supplies process startup through `API.Service` middleware.
+ */
+
+import { type Api, createApi, type Operations } from "@effectionx/context-api";
+import type { Operation } from "effection";
+
+export const SERVICE_READY_PREFIX = "XMD_SERVICE_READY:";
+export const SERVICE_HOSTNAME = "127.0.0.1";
+
+export interface ServiceEndpoint {
+ readonly hostname: string;
+ readonly port: number;
+}
+
+export interface ServiceStartOptions {
+ readonly command: string;
+ readonly cwd?: string;
+ readonly startupTimeout?: number;
+}
+
+export interface ServiceResource {
+ readonly endpoint: Readonly;
+}
+
+export interface ServiceHandler {
+ start(options: ServiceStartOptions): Operation;
+}
+
+export class ServiceProviderError extends Error {
+ override name = "ServiceProviderError";
+
+ constructor() {
+ super(
+ "service startup requires a host provider; install runtime.service middleware before execution",
+ );
+ }
+}
+
+export class ServiceProtocolMalformedError extends Error {
+ override name = "ServiceProtocolMalformedError";
+
+ constructor() {
+ super("service emitted a malformed cooperative readiness record");
+ }
+}
+
+export class ServiceProtocolIncompatibleError extends Error {
+ override name = "ServiceProtocolIncompatibleError";
+
+ constructor() {
+ super("service emitted an incompatible cooperative readiness record");
+ }
+}
+
+export class ServiceProtocolTokenMismatchError extends Error {
+ override name = "ServiceProtocolTokenMismatchError";
+
+ constructor() {
+ super("service readiness authentication failed");
+ }
+}
+
+export class ServiceProtocolHostnameMismatchError extends Error {
+ override name = "ServiceProtocolHostnameMismatchError";
+
+ constructor() {
+ super("service readiness hostname is not authorized");
+ }
+}
+
+export class ServiceProtocolDuplicateError extends Error {
+ override name = "ServiceProtocolDuplicateError";
+
+ constructor() {
+ super("service emitted more than one cooperative readiness record");
+ }
+}
+
+export class ServiceStartupTimeoutError extends Error {
+ override name = "ServiceStartupTimeoutError";
+
+ constructor(timeout: number) {
+ super(`service did not become ready within ${timeout}ms`);
+ }
+}
+
+interface ServiceExitStatus {
+ readonly code?: number;
+ readonly signal?: string;
+}
+
+function exitDescription(status: ServiceExitStatus): string {
+ if (status.signal !== undefined) {
+ return `signal ${status.signal}`;
+ }
+ if (status.code !== undefined) {
+ return `exit code ${status.code}`;
+ }
+ return "an unknown exit status";
+}
+
+export class ServiceProcessExitBeforeReadyError extends Error {
+ override name = "ServiceProcessExitBeforeReadyError";
+
+ constructor(status: ServiceExitStatus) {
+ super(`service process exited before readiness with ${exitDescription(status)}`);
+ }
+}
+
+export class ServiceUnexpectedExitError extends Error {
+ override name = "ServiceUnexpectedExitError";
+
+ constructor(status: ServiceExitStatus) {
+ super(`service process exited after readiness with ${exitDescription(status)}`);
+ }
+}
+
+export class ServiceTeardownError extends Error {
+ override name = "ServiceTeardownError";
+
+ constructor(options?: { cause?: unknown }) {
+ super("service process failed to terminate cleanly", options);
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function hasExactMembers(record: Record): boolean {
+ const members = Object.keys(record);
+ return (
+ members.length === 4 &&
+ members.includes("version") &&
+ members.includes("token") &&
+ members.includes("hostname") &&
+ members.includes("port")
+ );
+}
+
+/** Parse and authenticate one prefix-stripped v1 readiness payload. */
+export function parseServiceReadyRecord(payload: string, expectedToken: string): ServiceEndpoint {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(payload);
+ } catch {
+ throw new ServiceProtocolMalformedError();
+ }
+
+ if (!isRecord(parsed) || !hasExactMembers(parsed)) {
+ throw new ServiceProtocolMalformedError();
+ }
+ if (parsed.version !== 1) {
+ throw new ServiceProtocolIncompatibleError();
+ }
+ if (typeof parsed.token !== "string" || parsed.token !== expectedToken) {
+ throw new ServiceProtocolTokenMismatchError();
+ }
+ if (parsed.hostname !== SERVICE_HOSTNAME) {
+ throw new ServiceProtocolHostnameMismatchError();
+ }
+ if (
+ typeof parsed.port !== "number" ||
+ !Number.isInteger(parsed.port) ||
+ parsed.port < 1 ||
+ parsed.port > 65_535
+ ) {
+ throw new ServiceProtocolMalformedError();
+ }
+
+ return Object.freeze({ hostname: SERVICE_HOSTNAME, port: parsed.port });
+}
+
+export const Service: Api = createApi("runtime.service", {
+ // deno-lint-ignore require-yield
+ *start(_options: ServiceStartOptions): Operation {
+ throw new ServiceProviderError();
+ },
+});
+
+export const startService: Operations["start"] = Service.operations.start;
diff --git a/packages/runtime/test/README.md b/packages/runtime/test/README.md
index 7ee59ede..41fcef0b 100644
--- a/packages/runtime/test/README.md
+++ b/packages/runtime/test/README.md
@@ -12,6 +12,7 @@ Use these helpers when a test needs:
- an in-memory filesystem instead of real files
- a simple `exec` stub for `echo`-style command output
- a predictable failing `exec` for error-path assertions
+- a scoped cooperative-service endpoint without a real host process
Use raw `API.*.around()` directly when a test needs custom behavior that the
shared helpers do not provide.
@@ -76,6 +77,13 @@ import { useFailingExec } from "@executablemd/runtime/test";
yield * useFailingExec(127, "command not found");
```
+### `useStubService(endpoint)`
+
+Installs a provider-neutral scoped `API.Service` stub. It reconstructs an exact
+frozen loopback endpoint and rejects any non-loopback hostname or invalid port.
+Use the production host adapters when testing protocol, process, output or
+teardown behavior.
+
## Composition
The helpers are designed to compose.
diff --git a/packages/runtime/test/mod.ts b/packages/runtime/test/mod.ts
index 9cc8e8d3..98a3361b 100644
--- a/packages/runtime/test/mod.ts
+++ b/packages/runtime/test/mod.ts
@@ -6,6 +6,7 @@
* - `useStubFs(files)` — in-memory filesystem
* - `useEchoExec()` — simple echo-based exec
* - `useFailingExec(exitCode, stderr)` — always-failing exec
+ * - `useStubService(endpoint)` — scoped provider-neutral service endpoint
*/
-export { useStubFs, useEchoExec, useFailingExec } from "./stubs.ts";
+export { useStubFs, useEchoExec, useFailingExec, useStubService } from "./stubs.ts";
diff --git a/packages/runtime/test/stubs.ts b/packages/runtime/test/stubs.ts
index 6520e702..807880db 100644
--- a/packages/runtime/test/stubs.ts
+++ b/packages/runtime/test/stubs.ts
@@ -23,6 +23,25 @@
import type { Operation } from "effection";
import { API } from "../apis.ts";
import type { StatResult } from "../apis.ts";
+import { SERVICE_HOSTNAME } from "../service.ts";
+import type { ServiceEndpoint } from "../service.ts";
+
+/** Install a provider-neutral scoped service endpoint stub. */
+export function* useStubService(endpoint: ServiceEndpoint): Operation {
+ if (endpoint.hostname !== SERVICE_HOSTNAME) {
+ throw new Error("stub service endpoint must use 127.0.0.1");
+ }
+ if (!Number.isInteger(endpoint.port) || endpoint.port < 1 || endpoint.port > 65_535) {
+ throw new Error("stub service endpoint port must be an integer from 1 through 65535");
+ }
+ const exact = Object.freeze({ hostname: SERVICE_HOSTNAME, port: endpoint.port });
+ yield* API.Service.around({
+ // deno-lint-ignore require-yield
+ *start() {
+ return { endpoint: exact };
+ },
+ });
+}
/**
* Install an in-memory filesystem stub.
diff --git a/packages/runtime/tests/service.test.ts b/packages/runtime/tests/service.test.ts
new file mode 100644
index 00000000..a4b19040
--- /dev/null
+++ b/packages/runtime/tests/service.test.ts
@@ -0,0 +1,119 @@
+import { describe, it } from "@executablemd/test-support/bdd";
+import { expect } from "@executablemd/test-support/expect";
+import { scoped } from "effection";
+import {
+ API,
+ SERVICE_HOSTNAME,
+ ServiceProtocolHostnameMismatchError,
+ ServiceProtocolIncompatibleError,
+ ServiceProtocolMalformedError,
+ ServiceProtocolTokenMismatchError,
+ ServiceProviderError,
+ parseServiceReadyRecord,
+ startService,
+} from "../mod.ts";
+
+const TOKEN = "ab".repeat(32);
+
+function record(overrides: Record = {}): string {
+ return JSON.stringify({
+ version: 1,
+ token: TOKEN,
+ hostname: SERVICE_HOSTNAME,
+ port: 49_152,
+ ...overrides,
+ });
+}
+
+describe("runtime.service", () => {
+ it("fails when no provider is installed", function* () {
+ let failure: unknown;
+ try {
+ yield* startService({ command: "server" });
+ } catch (error) {
+ failure = error;
+ }
+ expect(failure).toBeInstanceOf(ServiceProviderError);
+ });
+
+ it("accepts the exact authenticated v1 record and freezes the endpoint", function* () {
+ const endpoint = parseServiceReadyRecord(record(), TOKEN);
+ expect(endpoint).toEqual({ hostname: SERVICE_HOSTNAME, port: 49_152 });
+ expect(Object.keys(endpoint)).toEqual(["hostname", "port"]);
+ expect(Object.isFrozen(endpoint)).toBe(true);
+ });
+
+ it("rejects malformed records without retaining their input", function* () {
+ const unsafe = `unsafe-${TOKEN}`;
+ const malformed = [
+ unsafe,
+ "null",
+ "[]",
+ "{}",
+ JSON.stringify({ version: 1, token: TOKEN, hostname: SERVICE_HOSTNAME }),
+ record({ extra: unsafe }),
+ record({ port: 0 }),
+ record({ port: 65_536 }),
+ record({ port: 1.5 }),
+ record({ port: "49152" }),
+ ];
+
+ for (const candidate of malformed) {
+ let failure: unknown;
+ try {
+ parseServiceReadyRecord(candidate, TOKEN);
+ } catch (error) {
+ failure = error;
+ }
+ expect(failure).toBeInstanceOf(ServiceProtocolMalformedError);
+ expect(String(failure)).not.toContain(TOKEN);
+ expect(JSON.stringify(failure)).not.toContain(TOKEN);
+ expect(String(failure)).not.toContain(candidate);
+ }
+ });
+
+ it("categorizes incompatible, forged, and unauthorized records safely", function* () {
+ const cases: Array<[string, new () => Error]> = [
+ [record({ version: 2 }), ServiceProtocolIncompatibleError],
+ [record({ token: "cd".repeat(32) }), ServiceProtocolTokenMismatchError],
+ [record({ hostname: "0.0.0.0" }), ServiceProtocolHostnameMismatchError],
+ ];
+
+ for (const [candidate, ErrorType] of cases) {
+ let failure: unknown;
+ try {
+ parseServiceReadyRecord(candidate, TOKEN);
+ } catch (error) {
+ failure = error;
+ }
+ expect(failure).toBeInstanceOf(ErrorType);
+ expect(String(failure)).not.toContain(TOKEN);
+ expect(JSON.stringify(failure)).not.toContain(candidate);
+ }
+ });
+
+ it("uses a scoped contextual provider without leaking it", function* () {
+ const endpoint = Object.freeze({ hostname: SERVICE_HOSTNAME, port: 7001 });
+ const provided = yield* scoped(function* () {
+ yield* API.Service.around(
+ {
+ *start() {
+ return { endpoint };
+ },
+ },
+ { at: "min" },
+ );
+ return yield* startService({ command: "server" });
+ });
+
+ expect(provided.endpoint).toBe(endpoint);
+
+ let failure: unknown;
+ try {
+ yield* startService({ command: "server" });
+ } catch (error) {
+ failure = error;
+ }
+ expect(failure).toBeInstanceOf(ServiceProviderError);
+ });
+});
From ac55d004016fbfd3ed728bfb35698b956497fa49 Mon Sep 17 00:00:00 2001
From: Taras Mankovski <74687+taras@users.noreply.github.com>
Date: Fri, 7 Aug 2026 19:05:27 -0400
Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8=20Add=20replay-safe=20service=20m?=
=?UTF-8?q?odifiers?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
packages/cli/src/bun.ts | 3 +-
packages/cli/src/cli.ts | 38 +-
packages/cli/src/compiled.ts | 3 +-
packages/cli/src/deno.ts | 3 +-
packages/cli/src/node.ts | 3 +-
packages/cli/tests/service-document.test.ts | 145 +++
packages/core/components/LlamafileProvider.md | 72 --
packages/core/mod.ts | 9 +-
packages/core/src/data-uri-compiler.ts | 1 -
packages/core/src/eval-handler.ts | 57 +
packages/core/src/execute.ts | 20 +-
packages/core/src/expand.ts | 65 +-
packages/core/src/live-env.ts | 155 +++
packages/core/src/modifiers.ts | 22 +
packages/core/src/modifiers/ephemeral.ts | 16 +
packages/core/src/modifiers/service.ts | 47 +
packages/core/src/temp-file-compiler.ts | 1 -
packages/core/tests/ephemeral-service.test.ts | 265 +++++
packages/core/tests/find-free-port.test.ts | 383 -------
.../core/tests/provider-integration.test.ts | 992 ------------------
packages/runtime/find-free-port.ts | 40 -
packages/runtime/mod.ts | 1 -
packages/testing/tests/smoke.test.ts | 8 +-
packages/workflow/mod.ts | 1 +
packages/workflow/src/service-denial.ts | 24 +
.../workflow/tests/service-denial.test.ts | 39 +
smoke-test/CooperativeProvider.md | 19 +
smoke-test/Guide/Daemons.md | 43 +-
smoke-test/Guide/Evaluation.md | 29 +-
smoke-test/Guide/Summary.md | 8 +-
30 files changed, 923 insertions(+), 1589 deletions(-)
create mode 100644 packages/cli/tests/service-document.test.ts
delete mode 100644 packages/core/components/LlamafileProvider.md
create mode 100644 packages/core/src/live-env.ts
create mode 100644 packages/core/src/modifiers/ephemeral.ts
create mode 100644 packages/core/src/modifiers/service.ts
create mode 100644 packages/core/tests/ephemeral-service.test.ts
delete mode 100644 packages/core/tests/find-free-port.test.ts
delete mode 100644 packages/core/tests/provider-integration.test.ts
delete mode 100644 packages/runtime/find-free-port.ts
create mode 100644 packages/workflow/src/service-denial.ts
create mode 100644 packages/workflow/tests/service-denial.test.ts
create mode 100644 smoke-test/CooperativeProvider.md
diff --git a/packages/cli/src/bun.ts b/packages/cli/src/bun.ts
index 76cbc582..5a72f684 100644
--- a/packages/cli/src/bun.ts
+++ b/packages/cli/src/bun.ts
@@ -11,6 +11,7 @@ import process from "node:process";
import { API } from "@executablemd/runtime";
import { compileDataUri } from "@executablemd/core";
import { runXmd } from "./cli.ts";
+import { useBunService } from "./bun-service.ts";
const ENTRYPOINT = fileURLToPath(import.meta.url);
@@ -29,5 +30,5 @@ await main(function* (args) {
},
{ at: "min" },
);
- yield* runXmd(args);
+ yield* runXmd(args, useBunService);
});
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index 3f5bd814..e3617a4f 100755
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -407,6 +407,8 @@ interface DocumentMode {
props?: Record;
}
+export type HostServiceInstaller = () => Operation;
+
/**
* Run one document and report how it finished.
*
@@ -415,7 +417,11 @@ interface DocumentMode {
* what the process status is. Rendered output, the --verbose journal echo, and
* a value root's JSON line are the document's own output and stay.
*/
-function* runDocument(config: DocumentConfig, mode: DocumentMode): Operation> {
+function* runDocument(
+ config: DocumentConfig,
+ mode: DocumentMode,
+ installService: HostServiceInstaller,
+): Operation> {
const { root, componentDir, verbose, journal, raw, secretDetection } = config;
// Every CLI invocation starts from an empty stream. --journal writes
@@ -497,6 +503,10 @@ function* runDocument(config: DocumentConfig, mode: DocumentMode): Operation> {
+function* runScopedDocument(
+ config: DocumentConfig,
+ mode: DocumentMode,
+ installService: HostServiceInstaller,
+): Operation> {
try {
- return yield* scoped(() => runDocument(config, mode));
+ return yield* scoped(() => runDocument(config, mode, installService));
} catch (error) {
return Err(error instanceof Error ? error : new Error(String(error)));
}
@@ -592,7 +606,11 @@ interface TestConfig extends Omit {
* end. A single document behaves exactly as it always has: one reported
* failure, no heading, no summary.
*/
-function* test(config: TestConfig, args: string[]): Operation {
+function* test(
+ config: TestConfig,
+ args: string[],
+ installService: HostServiceInstaller,
+): Operation {
const patterns = readPatternFlags(args);
if (patterns.missingValue) {
console.error(
@@ -621,7 +639,11 @@ function* test(config: TestConfig, args: string[]): Operation {
return;
}
announceSecretDetection(config.secretDetection);
- const result = yield* runScopedDocument({ ...config, root: { path } }, { testing: true });
+ const result = yield* runScopedDocument(
+ { ...config, root: { path } },
+ { testing: true },
+ installService,
+ );
if (!result.ok) {
reportFailure(result.error);
yield* exit(1);
@@ -660,6 +682,7 @@ function* test(config: TestConfig, args: string[]): Operation {
componentDir: componentSearchPath(document, target.root, config.componentDir),
},
{ testing: true },
+ installService,
);
if (!result.ok) {
reportFailure(result.error, document.relativePath);
@@ -935,7 +958,7 @@ function* resolveRunProps(
* `process.stdout` and `node:fs/promises`. Routing those through contextual
* APIs is #156.
*/
-export function* runXmd(args: string[]): Operation {
+export function* runXmd(args: string[], installService: HostServiceInstaller): Operation {
// First, so that no later scanner — help, properties, agent flags — can
// mistake the inline document's own text for an option.
const evalFlags = readEvalFlags(args);
@@ -1025,6 +1048,7 @@ export function* runXmd(args: string[]): Operation {
denyAll: config.denyAll,
},
},
+ installService,
);
if (!result.ok) {
reportFailure(result.error);
@@ -1049,7 +1073,7 @@ export function* runXmd(args: string[]): Operation {
yield* exit(1);
break;
}
- yield* test(command.config, evalFlags.rest);
+ yield* test(command.config, evalFlags.rest, installService);
break;
}
case "test-agent":
diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts
index a38146c5..3ac11182 100644
--- a/packages/cli/src/compiled.ts
+++ b/packages/cli/src/compiled.ts
@@ -10,6 +10,7 @@ import process from "node:process";
import { API } from "@executablemd/runtime";
import { compileDataUri } from "@executablemd/core";
import { runXmd } from "./cli.ts";
+import { useCompiledService } from "./compiled-service.ts";
await main(function* (args) {
// The base providers for this host. `at: "min"` puts them beneath ordinary
@@ -26,5 +27,5 @@ await main(function* (args) {
},
{ at: "min" },
);
- yield* runXmd(args);
+ yield* runXmd(args, useCompiledService);
});
diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts
index 8f870d33..8d0ff3ea 100644
--- a/packages/cli/src/deno.ts
+++ b/packages/cli/src/deno.ts
@@ -13,6 +13,7 @@ import process from "node:process";
import { API } from "@executablemd/runtime";
import { compileDataUri } from "@executablemd/core";
import { runXmd } from "./cli.ts";
+import { useDenoService } from "./deno-service.ts";
const ENTRYPOINT = fileURLToPath(import.meta.url);
@@ -31,5 +32,5 @@ await main(function* (args) {
},
{ at: "min" },
);
- yield* runXmd(args);
+ yield* runXmd(args, useDenoService);
});
diff --git a/packages/cli/src/node.ts b/packages/cli/src/node.ts
index 8c58f128..01d20f05 100755
--- a/packages/cli/src/node.ts
+++ b/packages/cli/src/node.ts
@@ -17,6 +17,7 @@ import process from "node:process";
import { API } from "@executablemd/runtime";
import { compileTempFile } from "@executablemd/core";
import { runXmd } from "./cli.ts";
+import { useNodeService } from "./node-service.ts";
const ENTRYPOINT = fileURLToPath(import.meta.url);
@@ -36,5 +37,5 @@ await main(function* (args) {
},
{ at: "min" },
);
- yield* runXmd(args);
+ yield* runXmd(args, useNodeService);
});
diff --git a/packages/cli/tests/service-document.test.ts b/packages/cli/tests/service-document.test.ts
new file mode 100644
index 00000000..47d60060
--- /dev/null
+++ b/packages/cli/tests/service-document.test.ts
@@ -0,0 +1,145 @@
+import { beforeAll, describe, it } from "@executablemd/test-support/bdd";
+import { expect } from "@executablemd/test-support/expect";
+import { once } from "@effectionx/node/events";
+import { race, resource, scoped, type Operation } from "effection";
+import { createServer } from "node:http";
+import process from "node:process";
+import { InMemoryStream } from "@executablemd/durable-streams";
+import { collect, execute, useTempFileCompiler } from "@executablemd/core";
+import { SERVICE_HOSTNAME } from "@executablemd/runtime";
+import { useStubFs } from "@executablemd/runtime/test";
+import { inheritedEnvironment, installHostService } from "../src/service-host.ts";
+
+const fixture = new URL("./fixtures/cooperative-service.mjs", import.meta.url).pathname;
+const command = `node ${JSON.stringify(fixture)} normal document`;
+
+const SAMPLE = `---
+meta:
+ componentName: Sample
+props:
+ type: object
+ properties: {}
+ additionalProperties: false
+---
+
+\`\`\`js persist eval
+const result = yield* Sample.operations.sample({ content: "", componentName: "Sample" });
+return result;
+\`\`\`
+`;
+
+const PROVIDER = `---
+meta:
+ componentName: Provider
+---
+
+\`\`\`bash service=server exec
+${command}
+\`\`\`
+
+\`\`\`js persist ephemeral eval
+const endpoint = server;
+yield* Sample.around({
+ *sample() {
+ const response = yield* fetch(\`http://\${endpoint.hostname}:\${endpoint.port}\`).expect();
+ return \`\${yield* response.text()}:\${endpoint.port}\`;
+ },
+});
+\`\`\`
+
+
+`;
+
+function* runDocument(stream: InMemoryStream): Operation {
+ return String(
+ yield* scoped(function* () {
+ return yield* collect(
+ yield* execute({
+ path: "doc.md",
+ stream,
+ componentDirs: ["components", "."],
+ }),
+ );
+ }),
+ );
+}
+
+function responsePort(output: string): number {
+ const match = /service:document:(\d+)/.exec(output);
+ if (!match) {
+ throw new Error(`service response missing from output: ${output}`);
+ }
+ return Number(match[1]);
+}
+
+function occupy(port: number): Operation {
+ return resource(function* (provide) {
+ const server = createServer((_request, response) => response.end("foreign"));
+ const listening = once(server, "listening");
+ const failed = once<[Error]>(server, "error");
+ server.listen(port, SERVICE_HOSTNAME);
+ yield* race([
+ listening,
+ (function* () {
+ const [error] = yield* failed;
+ throw error;
+ })(),
+ ]);
+ try {
+ yield* provide();
+ } finally {
+ server.close();
+ }
+ });
+}
+
+describe("cooperative service document integration", () => {
+ beforeAll(() => useTempFileCompiler());
+
+ it("reconstructs a real service on partial replay and skips completed replay", function* () {
+ const full = new InMemoryStream();
+ let tokenCalls = 0;
+
+ yield* scoped(function* () {
+ yield* installHostService({
+ token() {
+ tokenCalls += 1;
+ return tokenCalls.toString(16).padStart(64, "0");
+ },
+ environment: () => inheritedEnvironment(process.env),
+ stdout() {},
+ stderr() {},
+ });
+ yield* useStubFs({
+ "doc.md": "\n",
+ "components/Provider.md": PROVIDER,
+ "components/Sample.md": SAMPLE,
+ });
+
+ const first = yield* runDocument(full);
+ const firstPort = responsePort(first);
+ expect(tokenCalls).toBe(1);
+ const journal = JSON.stringify(full.snapshot());
+ expect(journal).not.toContain("service stdout");
+ expect(journal).not.toContain("service stderr");
+ expect(journal).not.toContain("XMD_SERVICE_READY");
+ expect(journal).not.toContain(
+ "0000000000000000000000000000000000000000000000000000000000000001",
+ );
+
+ yield* occupy(firstPort);
+ const events = full.snapshot();
+ const firstYield = events.findIndex((event) => event.type === "yield");
+ const partial = new InMemoryStream(events.slice(0, firstYield + 1));
+ const resumed = yield* runDocument(partial);
+
+ expect(responsePort(resumed)).not.toBe(firstPort);
+ expect(resumed).not.toContain("foreign");
+ expect(tokenCalls).toBe(2);
+
+ const completed = yield* runDocument(full);
+ expect(completed).toBe(first);
+ expect(tokenCalls).toBe(2);
+ });
+ });
+});
diff --git a/packages/core/components/LlamafileProvider.md b/packages/core/components/LlamafileProvider.md
deleted file mode 100644
index 611adc45..00000000
--- a/packages/core/components/LlamafileProvider.md
+++ /dev/null
@@ -1,72 +0,0 @@
----
-meta:
- componentName: LlamafileProvider
-
-props:
- type: object
- properties:
- model:
- type: string
- description: >
- Model identifier. Serves two purposes: it is passed as the `model` field
- in every /v1/chat/completions request, and it is the routing key that
- sample calls use to target this provider. Must be unique among all
- LlamafileProvider instances active simultaneously in the same document run.
- Example: "phi3-mini", "qwen3-0.6b"
- command:
- type: string
- description: >
- Shell command to start the llamafile or llama.cpp server.
- {port} is substituted with the allocated port number before execution.
- Example: "./phi3-mini.llamafile --nobrowser"
- required: [model, command]
- additionalProperties: false
----
-
-```ts eval
-const port = yield * findFreePort();
-const baseUrl = `http://127.0.0.1:${port}`;
-```
-
-```bash daemon exec
-{command} --port {port}
-```
-
-```ts eval
-yield *
- when(function* () {
- yield* fetch(`${baseUrl}/health`).expect();
- });
-```
-
-```ts persist eval
-yield *
- Sample.around(
- {
- *sample([context], next) {
- if (context.model !== undefined && context.model !== model) {
- return yield* next(context);
- }
-
- const messages = [];
- if (context.system) {
- messages.push({ role: "system", content: context.system });
- }
- messages.push({ role: "user", content: context.content });
-
- const result = yield* fetch(`${baseUrl}/v1/chat/completions`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model, messages, temperature: 0, max_tokens: 2048 }),
- })
- .expect()
- .json();
-
- return result.choices[0].message.content;
- },
- },
- { at: "min" },
- );
-```
-
-
diff --git a/packages/core/mod.ts b/packages/core/mod.ts
index a5eb7e95..d1c66473 100644
--- a/packages/core/mod.ts
+++ b/packages/core/mod.ts
@@ -105,11 +105,16 @@ export { evalFactory } from "./src/eval-handler.ts";
export { persistFactory } from "./src/modifiers/persist.ts";
export { timeoutFactory, parseDuration } from "./src/modifiers/timeout.ts";
export { daemonFactory } from "./src/modifiers/daemon.ts";
+export { EphemeralEvalOutputError, ephemeralFactory } from "./src/modifiers/ephemeral.ts";
+export { serviceFactory } from "./src/modifiers/service.ts";
+export {
+ InvalidServiceBindingError,
+ LiveBindingCollisionError,
+ ServiceBindingCollisionError,
+} from "./src/live-env.ts";
export { interpolateEvalBindings } from "./src/eval-interpolate.ts";
-export { findFreePort } from "@executablemd/runtime";
-
export type { TransformResult } from "./src/eval-transform.ts";
export { transformBlock, serializeExports, isJson } from "./src/eval-transform.ts";
diff --git a/packages/core/src/data-uri-compiler.ts b/packages/core/src/data-uri-compiler.ts
index 50c25e98..5fdf5be1 100644
--- a/packages/core/src/data-uri-compiler.ts
+++ b/packages/core/src/data-uri-compiler.ts
@@ -30,7 +30,6 @@ const STANDARD_IMPORTS = [
'import { when } from "@effectionx/converge";',
'import { fetch } from "@effectionx/fetch";',
'import { Sample, Elicitation } from "@executablemd/core";',
- 'import { findFreePort } from "@executablemd/runtime";',
];
/** Compile one eval block by importing it as a data: URI. */
diff --git a/packages/core/src/eval-handler.ts b/packages/core/src/eval-handler.ts
index f6198277..91a50a23 100644
--- a/packages/core/src/eval-handler.ts
+++ b/packages/core/src/eval-handler.ts
@@ -18,6 +18,58 @@ import { ErrorMode } from "./errors.ts";
import { commitExports, evaluationEnv } from "./eval-env.ts";
import { compileBlock } from "./eval-context.ts";
import { transformBlock, serializeExports } from "./eval-transform.ts";
+import {
+ commitLiveExports,
+ liveEnvironment,
+ validateLiveExports,
+ validateLiveOverlay,
+} from "./live-env.ts";
+import { EphemeralEval, EphemeralEvalOutputError } from "./modifiers/ephemeral.ts";
+import type { CodeBlockContext, CodeBlockResult, EvalEnv } from "./types.ts";
+
+function* runEphemeralEval(
+ ctx: CodeBlockContext,
+ evalEnv: EvalEnv,
+ persist: boolean,
+ mode: import("./errors.ts").ErrorMode,
+): Operation {
+ const live = liveEnvironment(evalEnv);
+ validateLiveOverlay(evalEnv, live);
+ let outputAttempted = false;
+ const merged: Record = { ...evalEnv.values, ...live.values };
+ merged.output = (_value: unknown): never => {
+ outputAttempted = true;
+ throw new EphemeralEvalOutputError("ephemeral eval cannot produce document output");
+ };
+
+ const transformed = transformBlock(ctx.content, ctx.blockId, Object.keys(merged));
+ validateLiveExports(transformed.exports, evalEnv);
+ const fn = yield* compileBlock(transformed.code, transformed.userImports ?? []);
+ const blockEnv = evaluationEnv(merged, mode);
+
+ let returnValue: unknown;
+ if (persist) {
+ const scope = yield* evalScope;
+ if (!scope) {
+ throw new Error(
+ `persist ephemeral eval block "${ctx.blockId}" requires a component eval scope; none is in scope.`,
+ );
+ }
+ returnValue = unbox(yield* scope.eval(() => fn(blockEnv)));
+ } else {
+ returnValue = yield* scoped(() => fn(blockEnv));
+ }
+
+ if (outputAttempted) {
+ throw new EphemeralEvalOutputError("ephemeral eval cannot produce document output");
+ }
+ if (returnValue !== undefined && returnValue !== null) {
+ throw new EphemeralEvalOutputError("ephemeral eval cannot return document output");
+ }
+
+ commitLiveExports(live, blockEnv, transformed.exports);
+ return { output: "", exitCode: 0, stderr: "" };
+}
/**
* Refuse `retain()` for the scope this is installed in.
@@ -70,11 +122,16 @@ export const evalFactory: ModifierFactory = (_params) => (_args, _next) =>
);
}
const persist = yield* ephemeral(persistent);
+ const reconstruct = yield* ephemeral(EphemeralEval.get());
// Captured here, on the expansion frame, where the block's documentation or
//
diff --git a/site/routes/docs/providers.tsx b/site/routes/docs/providers.tsx
index 8ecbd5fe..d4bfbd06 100644
--- a/site/routes/docs/providers.tsx
+++ b/site/routes/docs/providers.tsx
@@ -43,9 +43,6 @@ export default define.page(function Providers() {
OllamaProvider.md{" "}
— local models via a running Ollama server.
-
- LlamafileProvider.md — local models via llamafile.
-
Sample.md — the sampling call itself.
diff --git a/specs/code-review-agent-spec.md b/specs/code-review-agent-spec.md
index 9376e8c8..53b9f01c 100644
--- a/specs/code-review-agent-spec.md
+++ b/specs/code-review-agent-spec.md
@@ -61,7 +61,7 @@ All executable.md core changes and the full agent implementation are complete:
- **Simplified `SampleContext`** to `{content, model?, params?, system?, componentName?}` (PR #35)
- **Removed `sample` modifier** — all LLM calls via `` component (PR #35)
- **Renamed `Instruction.md` input** `text` → `system` for clarity
-- **Fixed broken providers** — `OllamaProvider`, `LlamafileProvider`,
+- **Fixed broken providers** — `OllamaProvider`,
`AnthropicProvider` updated to use direct `fetch()` calls
- **Component resolution** — review components resolved via
`--component-dir .reviews/components --component-dir packages/core/components`
diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md
index 5c2977ee..dbfa5990 100644
--- a/specs/executable-mdx-spec.md
+++ b/specs/executable-mdx-spec.md
@@ -484,6 +484,8 @@ registry.set("eval", evalFactory);
registry.set("persist", persistFactory);
registry.set("timeout", timeoutFactory);
registry.set("daemon", daemonFactory);
+registry.set("ephemeral", ephemeralFactory);
+registry.set("service", serviceFactory);
```
Custom factories can be provided via `ExecuteOptions.modifiers`.
@@ -538,6 +540,35 @@ Observable behavior of an `eval` block:
- The block's rendered output is the `output()` text, or the coerced
return value when `output()` was not called (§4.7).
+**`ephemeral eval`** evaluates the compiled block without a durable operation
+or journal entry. It reconstructs execution-owned state during partial replay,
+so it reads the component's durable bindings plus its live binding overlay and
+publishes new live bindings atomically after successful completion. It accepts
+only a nullish return value, and calling `output()` is an error. The modifier is
+valid only directly around the terminal `eval`; `persist ephemeral eval` keeps
+installed middleware in the invocation eval scope.
+
+**`service=`** is a terminal service-acquisition modifier. The code
+block content is the shell command passed to `startService()`, and the required
+parameter is the live binding that receives the frozen
+`{ hostname: "127.0.0.1", port }` endpoint. Binding syntax and collisions are
+validated before process acquisition. The modifier produces no output or
+journal entry, and the acquired service remains supervised until the component
+invocation closes.
+
+````markdown
+```bash service=server exec
+./server --cooperative
+```
+
+```ts persist ephemeral eval
+import { callProvider } from "./client.ts";
+
+const endpoint = server;
+yield* Sample.around({ /* provider middleware using endpoint */ });
+```
+````
+
**`daemon`** — spawns a long-running subprocess and immediately
returns control to the document. The process is alive for the
duration of the component invocation and killed when that invocation's
@@ -560,6 +591,10 @@ The `exec` modifier appears in the chain but is never invoked —
the info string is purely syntactic: it satisfies the detection rule
and signals to readers that this block runs a command.
+`daemon` is for fixed-configuration background processes. It does not allocate
+or publish a dynamic endpoint and does not establish readiness; cooperative
+network services use `service=`.
+
| Property | `exec` | `daemon` |
|---|---|---|
| Waits for exit | Yes | No |
@@ -745,8 +780,8 @@ const Sample = createApi("Sample", {
```
Sample Api calls route through the `EvalScope` so that middleware
-installed by `persist eval` blocks (e.g., `LlamafileProvider`'s
-`Sample.around()`) is visible — `evalScope.eval()` runs the operation in
+installed by `persist ephemeral eval` blocks in provider components is
+visible — `evalScope.eval()` runs the operation in
the same spawned task where the middleware was installed.
#### Sample middleware examples
@@ -1096,13 +1131,11 @@ import { sleep, spawn, call, resource, useScope, createChannel, each, suspend, c
import { when } from "@effectionx/converge";
import { fetch } from "@effectionx/fetch";
import { Sample } from "@executablemd/core";
-import { findFreePort } from "@executablemd/runtime";
```
These imports resolve through Deno's import map (`deno.json`).
`@executablemd/core` re-exports executable.md-specific APIs from its root
-barrel (`packages/core/mod.ts`); `findFreePort` comes from `@executablemd/runtime`
-(and is also re-exported by `packages/core/mod.ts`).
+barrel (`packages/core/mod.ts`).
`useContent` is **not** among them. It projects content, and a projection
settles its errors under the error mode of the block that started it, which the
@@ -1119,73 +1152,54 @@ of the same name cannot collide.
The exact list lives in the `STANDARD_IMPORTS` constant, which both
compilers share (`src/data-uri-compiler.ts`, `src/temp-file-compiler.ts`).
-#### `findFreePort`
+#### Cooperative service API
-`findFreePort` is available in eval blocks as a standard import
-(`@executablemd/runtime`). It is an Effection
-`Operation`. It binds a `node:net` TCP server to
-port 0 (OS-assigned), reads the port number, and closes the server.
-It uses Effection's structured concurrency primitives (`once` from
-`@effectionx/node` for event bridging, `race` for error handling):
+`@executablemd/runtime` exports provider-neutral `API.Service` under the stable
+context-api name `runtime.service`, and its ordinary operation as
+`startService()`:
```typescript
-import { race } from "effection";
-import { once } from "@effectionx/node";
-import { createServer } from "node:net";
-
-export function* findFreePort(): Operation {
- const server = createServer();
+interface ServiceEndpoint {
+ readonly hostname: string;
+ readonly port: number;
+}
- const listening = once(server, "listening");
- const error = once<[Error]>(server, "error");
+interface ServiceStartOptions {
+ readonly command: string;
+ readonly cwd?: string;
+ readonly startupTimeout?: number;
+}
- server.listen(0);
+interface ServiceResource {
+ readonly endpoint: Readonly;
+}
- try {
- const rethrowError: Operation = {
- *[Symbol.iterator]() {
- const [err] = yield* error;
- throw err;
- },
- } as Operation;
-
- yield* race([listening, rethrowError]);
-
- const addr = server.address();
- if (!addr || typeof addr !== "object") {
- throw new Error("findFreePort: unexpected address format");
- }
- return addr.port;
- } finally {
- server.close();
- }
+interface ServiceHandler {
+ start(options: ServiceStartOptions): Operation;
}
```
-The returned port number is a JSON-serializable primitive. When used in an
-eval block, it is exported to `env.values` and included in that block's
-diagnostic result. Each new run calls `findFreePort()` again.
-
-There is a small race window between closing the server and the
-caller binding the port — acceptable in practice, since daemon
-processes are expected to bind immediately after allocation.
+The endpoint is exactly a newly constructed frozen `{ hostname, port }` object.
+The terminal handler throws `ServiceProviderError`; shared runtime code never
+detects a host or silently starts a native process. Runtime-named CLI adapters
+provide the authenticated loopback implementation described in §6.7.
#### `when`
`when` from `@effectionx/converge` retries an inner operation with
-backoff until it completes without throwing. It is the idiomatic way
-to poll a readiness endpoint:
+backoff until it completes without throwing. It is useful for transient
+application-level conditions after an endpoint already exists:
```typescript
yield* when(function* () {
- yield* fetch(`http://127.0.0.1:${port}/health`).expect();
+ yield* fetch(`${baseUrl}/health`).expect();
});
```
-`fetch().expect()` from `@effectionx/fetch` throws `HttpError` on
-non-2xx responses. Network-level errors (connection refused before the
-daemon is listening) throw natively. `when` catches both and retries
-until the assertion passes or the timeout expires.
+`fetch().expect()` from `@effectionx/fetch` throws `HttpError` on non-2xx
+responses. `when` catches it and retries until the assertion passes or the
+timeout expires. Cooperative-service startup readiness is established by the
+host protocol, not by polling from a document.
#### Compiling blocks
@@ -1224,7 +1238,7 @@ The env preamble (`const { x, y } = env;`) is already in the
Each run compiles and imports the current transformed source.
-### 4.3 Binding environment
+### 4.3 Durable and live binding environments
```typescript
// src/types.ts
@@ -1241,6 +1255,26 @@ scope-locally around each component body, so eval blocks within a
component share bindings without leaking into parent or sibling
components.
+`EvalEnv.values` is the **durable binding environment**. A private live overlay
+belongs to the same component environment but is not part of the public
+`EvalEnv` shape. The overlay holds service endpoints and values exported by
+`ephemeral eval`. Content projection switches back to the caller's environment,
+so a component's live overlay remains isolated from its parent, siblings and
+projected caller content.
+
+| Consumer | Durable bindings | Live bindings |
+| --- | --- | --- |
+| ordinary `eval` | yes | no |
+| `ephemeral eval` | yes | yes |
+| code-block and prose interpolation | yes | no |
+| journal serialization and replay restore | yes | no |
+
+The two namespaces may not overlap. `service=` validates the binding
+name and checks both environments before acquiring a process. `ephemeral eval`
+validates all exports against durable and existing live names before committing
+any of them. A failed block publishes nothing. Values from the live overlay are
+never substituted into a durable effect's source and never serialized.
+
**Each evaluation runs against a snapshot, and commits its exports.** A block
receives a plain object holding the bindings as they stood when it started. Its
declared exports are published to the shared record once it completes
@@ -1456,6 +1490,7 @@ run but are absent from the diagnostic trace.
| `src/expansion.ts` | `Expansion`, `getExpansion()` — what an executable element knows about its own expansion (§5.6) |
| `src/projection.ts` | `ProjectionHandle`, `ProjectionRequest`, `ActiveProjection` — content projection (§6.3) |
| `src/eval-env.ts` | `evaluationEnv()`, `commitExports()` — per-evaluation binding snapshot and commit (§4.3) |
+| `src/live-env.ts` | execution-owned live binding overlay, collision validation and atomic export commit (§4.3) |
| `src/errors.ts` | `ErrorMode`, `settle()`, `DocumentationError`, `ContentError` — the error-mode decision (§6.9) and the function-content failure boundary (§5.1.2) |
| `packages/test-support/bdd.ts` | Cross-runtime Effection BDD adapter — drives `@std/testing/bdd`, `node:test`, and `bun:test` |
| `src/eval-handler.ts` | `evalFactory` |
@@ -1463,15 +1498,20 @@ run but are absent from the diagnostic trace.
| `src/modifiers/persist.ts` | `persistFactory` |
| `src/modifiers/timeout.ts` | `timeoutFactory`, `parseDuration()` |
| `src/modifiers/daemon.ts` | `daemonFactory` — long-running subprocess terminal modifier |
+| `src/modifiers/ephemeral.ts` | `ephemeralFactory` — replay-safe live eval wrapper |
+| `src/modifiers/service.ts` | `serviceFactory` — scoped cooperative-service acquisition |
| `src/sample-api.ts` | `Sample` Api definition (§3.4) — LLM middleware surface |
-| `packages/runtime/find-free-port.ts` | `findFreePort()` — OS port allocation via `node:net` (separate `runtime` workspace package) |
+| `packages/runtime/service.ts` | provider-neutral `API.Service`, readiness protocol types and `startService()` resource |
| `src/api.ts` | Document Output Api definition, exports `output` (§9.2) |
| `src/collect.ts` | `collect()` — stream consumption helper, returns `Result` |
| `src/output/mod.ts` | Barrel export for output middleware |
| `src/output/normalize.ts` | `useNormalizedOutput()` — whitespace normalization middleware (§9.4) |
| `src/output/terminal.ts` | `useTerminalOutput()` — terminal ANSI formatting middleware (§9.5) |
| `packages/cli/src/cli.ts` | Runtime-neutral CLI (separate `cli` workspace package) with `--verbose`, `--journal`, and `--raw` flags; Output Api stream consumption (§9.6) |
-| `packages/cli/src/{deno,node,bun,compiled}.ts` | Entrypoints — each installs its `API.Env.command` adapter and compiler, then calls `runXmd` |
+| `packages/cli/src/service-host.ts` | shared authenticated readiness observer and supervised host-process adapter |
+| `packages/cli/src/{deno,node,bun,compiled}-service.ts` | runtime-named service adapters for token, environment and stdio behavior |
+| `packages/cli/src/{deno,node,bun,compiled}.ts` | Entrypoints — each installs matching `API.Env` and `API.Service` adapters, then calls `runXmd` |
+| `packages/workflow/src/service-denial.ts` | non-delegating workflow service denial middleware |
| `packages/cli/src/file-stream.ts` | `FileStream` — JSONL-backed `DurableStream` implementation |
Dependencies: `@effectionx/scope-eval`, `@effectionx/timebox`,
@@ -2982,14 +3022,14 @@ into surrounding prose naturally:
````markdown
```ts eval
-const port = yield* findFreePort();
-const baseUrl = `http://127.0.0.1:${port}`;
+const environment = "staging";
+const dashboard = "https://status.example.test/staging";
```
-Server running at {baseUrl} on port {port}.
+{environment} status: {dashboard}.
````
-Renders: `Server running at http://127.0.0.1:49821 on port 49821.`
+Renders: `staging status: https://status.example.test/staging.`
**Precedence:** `{meta.*}` and `{props.*}` resolve first because they
are the component's declared interface. If a component declares
@@ -3653,15 +3693,15 @@ segment interpolation pipeline).
````markdown
```ts eval
-const port = yield* findFreePort();
+const outputDirectory = "./build";
```
-```bash daemon exec
-./server --port {port}
+```bash exec
+mkdir -p {outputDirectory}
```
````
-`{port}` resolves to the number exported by the first block. The
+`{outputDirectory}` resolves to the string exported by the first block. The
substituted content is used to build the subprocess command.
#### Interpolation syntax and precedence
@@ -3738,221 +3778,75 @@ and strings.
### 6.7 Provider component pattern
-A **provider component** is a regular markdown component whose body
-follows a structured pattern that manages background process lifecycle
-for its subtree. It composes `eval` + `daemon` + `eval` (readiness)
-+ `eval` (middleware install) + `` into a reusable
-component — no framework-level configuration, no `ExecuteOptions`
-changes.
+A **provider component** is a regular markdown component whose body acquires a
+cooperative service and installs middleware for its subtree. It composes
+`service=` + `persist ephemeral eval` + ``; the host, not
+the document, owns endpoint allocation and authenticated readiness.
#### Structure
-1. An `eval` block that allocates resources and exports bindings
- (port, URLs).
-2. A `daemon` block that starts the background process using those
- bindings.
-3. An `eval` block that polls for readiness using `when`.
-4. An `eval` block that installs Sample Api middleware, closing over
- `baseUrl` and `model`.
-5. `` — the subtree that uses the running process.
-
-#### `LlamafileProvider.md` — standard library component
-
-**File:** `components/LlamafileProvider.md`
+1. A `service=` block starts the cooperative command and waits for an
+ authenticated readiness record.
+2. A `persist ephemeral eval` block reads the live endpoint and installs
+ provider middleware in the component eval scope.
+3. `` expands the subtree while the supervised process and
+ middleware are active.
-This file is part of the executable.md standard library and is distributed
-alongside the executable.md package. It is a regular markdown component — no
-code changes to the executable.md runtime are required to add it.
+#### Example
````markdown
---
props:
type: object
properties:
- model:
- type: string
- description: >
- Model identifier. Serves two purposes: it is passed as the `model` field
- in every /v1/chat/completions request, and it is the routing key that
- sample calls use to target this provider. Must be unique among all
- LlamafileProvider instances active simultaneously in the same document run.
- Example: "phi3-mini", "qwen3-0.6b"
command:
type: string
- description: >
- Shell command to start the llamafile or llama.cpp server.
- {port} is substituted with the allocated port number before execution.
- Example: "./phi3-mini.llamafile --nobrowser"
- required: [model, command]
+ required: [command]
additionalProperties: false
---
-```ts eval
-const port = yield* findFreePort();
-const baseUrl = `http://127.0.0.1:${port}`;
-```
-
-```bash daemon exec
-{command} --port {port}
-```
-
-```ts eval
-yield* when(function* () {
- yield* (yield* fetch(`${baseUrl}/health`)).expect();
-});
+```bash service=server exec
+{command}
```
-```ts eval
-// Install Sample Api middleware on the current component scope.
-// baseUrl and model are closed over here — no context lookup at call time.
-// Routing: if context.model matches our model (or is unspecified), handle it.
-// Otherwise pass through to the next handler (an outer provider or the default).
-const scope = yield* useScope();
-scope.around(Sample, function* ([context], next) {
- if (context.model !== undefined && context.model !== model) {
- return yield* next(context);
- }
-
- const messages = [];
- if (context.system) {
- messages.push({ role: "system", content: context.system });
- }
- messages.push({ role: "user", content: context.content });
-
- const result = yield* fetch(`${baseUrl}/v1/chat/completions`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model, messages, temperature: 0, max_tokens: 2048 }),
- })
- .expect()
- .json();
-
- return result.choices[0].message.content;
+```ts persist ephemeral eval
+const endpoint = server;
+yield* Sample.around({
+ *sample([context], next) {
+ if (context.model !== undefined && context.model !== "local") {
+ return yield* next(context);
+ }
+ return yield* callProvider(endpoint, context);
+ },
});
```
-
+
````
-#### Prop-to-binding requirement (DEC-EX-09)
-
-Code block content uses bare `{name}` binding interpolation from
-`env.values` (§6.6). `{command}` in the daemon block and `model` in
-the middleware eval block must be present in `env.values` when those
-blocks run.
-
-Both are declared props, not eval results — so they are not
-automatically in `env.values`. The expansion engine must pre-populate
-`env.values` with all declared prop values at component invocation
-time, before any block executes:
-
-```typescript
-// In expandComponent(), before block execution:
-const componentEnv: EvalEnv = { values: { ...validatedProps } };
-```
-
-This makes all props available as bare bindings without any explicit
-capture step in the component body. It is consistent with how
-`findFreePort()` results enter `env.values`.
-
-#### Execution sequence
-
-**Block 1 — resource allocation:**
-`findFreePort()` is available as a VM global. The eval block exports
-`port` and `baseUrl` to `env.values`. The eval operation journals the result.
-
-**Block 2 — daemon spawn:**
-`{port}` is substituted from `env.values` into the command content
-before `buildCommand` runs. `{command}` is also substituted from
-`env.values` (populated from props via DEC-EX-09). The resulting
-command is forked into the eval scope. Control returns immediately.
-No journal entry.
-
-**Block 3 — readiness:**
-`when` polls with retries until the server responds. The eval operation
-journals the result.
-
-**Block 4 — middleware install:**
-`Sample` and `fetch` are standard imports in the generated
-eval module (via `@executablemd/core` and `@effectionx/fetch`, §4.2). The
-middleware closes over `baseUrl` and `model` at install time and issues the
-`/v1/chat/completions` request inline. Routing:
-if `context.model` matches the provider's model (or is unspecified),
-handle it; otherwise pass through via `next()`.
-
-**``:**
-Child expansion runs with the server alive and ready. `sample` calls
-in children reach the server at `baseUrl`.
-
-**Component scope closes:**
-The eval scope closes. The daemon task is cancelled. The subprocess
-is terminated.
-
-#### Usage examples
-
-**Single provider:**
-
-```markdown
-
-
-
-```
-
-**Multiple models, sequential:**
-
-```markdown
-
-
-
-
-
-
-
-
-```
-
-Each provider spawns its own process on its own port and executes
-sequentially — the second provider's process is not started until the
-first provider's scope closes.
-
-**Multiple models, simultaneous (nested):**
-
-```markdown
-
-
-
-
-
-```
-
-Both processes are alive simultaneously during ``
-expansion. Sample calls route by `model`:
-
-````markdown
-
-
-```bash sample exec
-classify this output
-```
-
+The executable receives `XMD_SERVICE_HOST`, `XMD_SERVICE_PORT` and a
+cryptographically random `XMD_SERVICE_TOKEN`. It binds exactly the supplied
+loopback host and port, then writes one newline-terminated readiness record to
+stdout:
-```bash sample[model=phi3-mini] exec
-summarize this
+```text
+XMD_SERVICE_READY:{"version":1,"token":"","hostname":"127.0.0.1","port":43210}
```
-
-```bash sample[model=qwen3-0.6b] exec
-extract entities
-```
-
-````
+The host installs its byte-level stdout observer before spawn. It suppresses
+the matching record, forwards every other stdout byte unchanged, and accepts
+readiness only when the JSON object has exactly those fields and matches the
+expected version, token, host and port. Malformed, forged, duplicate or late
+records fail the service without exposing the token or raw protocol line.
+Startup races readiness against process exit, protocol failure and the
+contextual startup timeout. After readiness the host continues supervising
+process exit and duplicate records until acquisition ends.
-Routing works because the inner provider's middleware is installed
-later and therefore sits higher in the middleware chain (traversed
-first). When `context.model` is `"phi3-mini"`, the inner handler
-accepts it. When `context.model` is `"qwen3-0.6b"`, the inner handler
-calls `next()` and the outer handler accepts it. When `context.model`
-is undefined, the innermost accepting handler wins.
+The service binding is live, so only the `ephemeral eval` block can read it.
+The block installs middleware in the invocation scope through `persist`; plain
+eval, interpolation and the journal cannot observe the endpoint. Partial replay
+runs both blocks again to reconstruct a current process and middleware chain.
+A completed replay expands nothing and starts no process.
#### Nesting providers
@@ -3960,15 +3854,16 @@ Provider components nest naturally — each establishes its own eval
scope boundary:
```markdown
-
+
-
+
```
-Both providers' scopes are nested — the inner provider is torn down
-before the outer, in standard structured concurrency order.
+Each acquisition receives a distinct host-selected endpoint and token. Both
+services remain live while the nested report expands; the inner service tears
+down before the outer in standard structured-concurrency order.
### 6.8 Sample component
@@ -5634,9 +5529,11 @@ export function* useTerminalOutput(): Operation {
**File:** `packages/cli/src/cli.ts` (separate `cli` workspace package)
`cli.ts` makes no host-specific decision about **how this xmd is re-invoked,
-how an eval block compiles, or which runtime it is on**. A runtime-named
+how an eval block compiles, how a service process is hosted, or which runtime
+it is on**. A runtime-named
entrypoint — `deno.ts`, `node.ts`, `bun.ts`, `compiled.ts` — installs its
-`API.Env` providers with `{ at: "min" }` and then calls `runXmd(args)`:
+`API.Env` providers with `{ at: "min" }` and passes the matching service
+installer to `runXmd(args, installService)`:
```typescript
yield* API.Env.around(
@@ -5651,9 +5548,15 @@ yield* API.Env.around(
},
{ at: "min" },
);
-yield* runXmd(args);
+yield* runXmd(args, useDenoService);
```
+The installer is invoked only for `xmd run` and `xmd test`, immediately before
+`execute()`. Help, inspection and agent-worker paths never install or acquire a
+service. Each adapter supplies host randomness, inherited environment and
+stdout/stderr writers to the shared service host; production adapters reject a
+non-loopback requested host before spawning.
+
Each entrypoint owns its own argument order; there is no shared builder for
them to forward to. `cli.ts` still reaches the host directly for terminal and
journal I/O (`process.stdout`, `node:fs/promises`); routing those through
@@ -5784,9 +5687,9 @@ Given a document:
```markdown
# Title
-
+
-
+
## Footer
```
@@ -6231,7 +6134,7 @@ visible warning blocks, gather into a separate error report).
|---|------|--------|
| H1 | Missing-provider printed errors | `importComponent`, `applyModifiers`, `codeBlock`, and `content` report clear missing-provider errors when no provider is installed |
| H2 | Effection globals available | `sleep`, `spawn`, `createChannel` accessible in compiled block via standard imports |
-| H3 | executable.md globals available | `findFreePort`, `Sample`, `when` accessible in compiled block via `@executablemd/core` |
+| H3 | executable.md globals available | `Sample` and `when` accessible in compiled block via `@executablemd/core` |
| H5 | `compileBlock` returns generator function | `yield* compileBlock(code, [])` returns a callable generator function |
| H6 | Distinct modules per block | Each `compileBlock` call produces a separate module — no shared state between blocks |
| H7 | `data:` URI encoding | Module source with special characters is correctly URI-encoded |
@@ -6713,20 +6616,20 @@ Defined in [Workflow runs](./workflow-spec.md) §9.5–§9.6.
| Q6 | Process terminated on component error | If child expansion throws, process still terminated |
| Q7 | Root cancellation resolves promptly | Cancelling the root resolves instead of waiting on the daemon's blocks; invocation teardown order is covered by Tier O |
| Q8 | Premature exit propagates as error | Process exits during expansion → `daemon()` throws → `ErrorSegment` in output |
-| Q9 | `{port}` interpolation in daemon content | Binding from preceding `eval` block substituted into command |
+| Q9 | Durable interpolation in daemon content | Binding from preceding `eval` block substituted into fixed command configuration |
| Q10 | `daemon` without eval scope | No eval scope in scope → clear error |
| Q11 | Modifier chain: `bash daemon exec` | `daemon` is outermost terminal; `exec` present but never called |
| Q12 | Repeated run: daemon starts and stops | Process is spawned and terminated again |
-| Q13 | Repeated run: current port used | Eval allocates a current port; daemon binds it |
+| Q13 | Repeated run: process restarts | Fixed daemon command is spawned on each document execution |
| Q14 | Projected daemon terminated with the invocation | A daemon the caller wrote and the component only projected is gone in a block after the component; inside `` it is signalled while the directory still exists |
-### Tier R — VM globals
+### Tier R — VM globals and live eval
| # | Test | Verify |
|---|------|--------|
-| R1 | `findFreePort` accessible in eval block | `yield* findFreePort()` succeeds, returns a number |
-| R2 | `findFreePort` returns usable port | Returned port is bindable (no EADDRINUSE) |
-| R3 | `findFreePort` called on each run | No port is restored from an earlier trace |
+| R1 | Live overlay hidden from plain eval | A service binding is absent from the ordinary eval preamble |
+| R2 | Live overlay hidden from interpolation | `{server}` remains literal rather than becoming an endpoint string |
+| R3 | `ephemeral eval` executes during partial replay | Live bindings and middleware are reconstructed without a journal entry |
| R4 | `when` accessible in eval block | `yield* when(fn)` retries until fn succeeds |
| R5 | `when` retries on throw | Inner function throws twice, then succeeds → `when` resolves |
| R6 | `when` propagates timeout | Inner function never succeeds → `when` throws after limit |
@@ -6735,20 +6638,20 @@ Defined in [Workflow runs](./workflow-spec.md) §9.5–§9.6.
| # | Test | Verify |
|---|------|--------|
-| S1 | Full provider golden run | eval → daemon → when → children → cleanup |
-| S2 | Port flows from eval to daemon | `{port}` in daemon content matches `findFreePort()` result |
-| S3 | Children can call sample after daemon ready | `sample` calls in children reach daemon endpoint |
-| S4 | Daemon terminated after children expand | After `execute` completes, process not running |
-| S5 | Provider crash during `when` | Daemon exits before ready → `when` fails → `ErrorSegment` |
-| S6 | Provider crash during children | Daemon exits mid-child-expansion → error propagated |
+| S1 | Full provider golden run | service → persistent ephemeral middleware → children → cleanup |
+| S2 | Endpoint flows to ephemeral middleware | `server` is an exact frozen loopback endpoint available only to ephemeral eval |
+| S3 | Children can call sample after protocol readiness | `sample` calls in children reach the acquired endpoint |
+| S4 | Service terminated after children expand | After `execute` completes, process is not running |
+| S5 | Process exits before readiness | Startup fails with a dedicated exit-before-ready error |
+| S6 | Provider crashes during children | Supervision failure propagates and teardown completes |
| S7 | Nested providers | Outer + inner provider → both start, inner tears down first |
| S8 | Nested providers, no model | Innermost provider handles sample call |
| S9 | Nested providers, explicit model matching outer | Inner passes through, outer handles |
| S10 | Nested providers, explicit model matching inner | Inner handles regardless of nesting depth |
| S11 | Unmatched model | Chain exhausted → descriptive error naming the model |
-| S12 | Repeated provider run | Eval, daemon, readiness, and HTTP calls execute again |
-| S13 | Interrupted provider run | Partial diagnostic trace is not accepted as resume input |
-| S14 | Multiple provider instances | Two provider siblings → two processes, different ports |
+| S12 | Partial replay | Service acquisition and ephemeral middleware execute again after the recorded prefix |
+| S13 | Completed replay | Completed document returns without process spawn or token allocation |
+| S14 | Multiple provider instances | Two provider siblings → two processes, distinct endpoints and tokens |
### Tier EO — eval output() function
@@ -7132,28 +7035,28 @@ must preserve the trace for diagnosis or remove it before starting a new run.
| 38 | `daemon` produces no journal entry | The process is an ephemeral resource and starts on every run |
| 39 | Eval binding interpolation uses bare `{name}` syntax | Distinct from `{meta.key}` and `{props.key}` namespaces; local eval bindings are local variables, not namespaced data; regex excludes names containing `.` to avoid conflicts |
| 40 | Eval binding interpolation runs in the expansion engine, not inside modifier factories | Modifiers transform execution results — they are not responsible for preparing source text; one interpolation site in `expandSegments` is consistent with how text segment interpolation already works, and keeps modifier factories free of knowledge about the binding environment |
-| 41 | `findFreePort` is a standalone VM global using `node:net` | Port allocation is platform I/O; the function uses Effection's `once` + `race` for event handling and `try/finally` for guaranteed cleanup; exposed in the eval sandbox alongside other Effection globals |
-| 42 | `findFreePort` result journaled with its eval block | The port number is a scalar export; no separate journal-entry type is needed |
+| 41 | Service allocation belongs to a host adapter | Holding an OS-selected port across spawn is host process and networking behavior; shared runtime and document code use provider-neutral `API.Service` |
+| 42 | Service endpoints are live bindings | The endpoint identifies an execution-owned process and is reconstructed during partial replay, so it cannot enter durable eval, interpolation or the journal |
| 43 | `when` (from `@effectionx/converge`) is the polling VM global | `when` is the exported name from the package; the sandbox already contains it; no rename or addition needed |
| 44 | Provider lifecycle expressed as a component, not an `ExecuteOptions` field | Scope boundary is visible in the document tree; composable — multiple providers nest naturally via structured concurrency; no framework-level lifecycle hooks required |
-| 45 | Readiness check is a separate `eval` block, not internal to `daemon` | Auditable — strategy visible in the document; replaceable — different daemons have different readiness signals; composable with `when`'s configurable backoff |
-| 46 | Sample middleware reads `baseUrl` from `env.values` | Avoids a dedicated inference server context key; the binding environment is already the shared state carrier for within-component coordination; scope-correct because a fresh environment is provided per component expansion |
+| 45 | Cooperative readiness is an authenticated stdout protocol | The host observes from before spawn, verifies version/token/host/port exactly and supervises the process continuously without a close-and-rebind race |
+| 46 | Provider middleware reads an endpoint from the live overlay | The current endpoint is available to `ephemeral eval` while remaining invisible to durable effects and interpolation |
| 47 | Each component gets a fresh `EvalEnv` | The component's environment is installed as a scope-local `env` provider around body expansion, so eval blocks within a component share bindings but don't leak into parent or sibling components; critical for provider isolation |
| 48 | `output()` is a plain function, not `yield*` | Output is a synchronous side effect (mutating a ref), not an Effection operation; making it a function keeps the API simple and avoids requiring generator context just to set output text |
| 49 | `__output` stored alongside exports in journal | Avoids a separate journal entry; `__output` is extracted before merging into `env.values` to prevent namespace pollution |
| 50 | `renderChildren`/`render` are closures in `env.values`, not an Api | A Render Api would require middleware installation per component; closures are simpler and capture the expansion context at the injection point; they are non-serializable and silently omitted from the journal |
| 51 | `renderChildren`/`render` install the caller's environment and `parentEvalScope` as scope-local providers | Children are caller-provided content and expand in the caller's scope context; the component's `childEvalScope` sequential channel is for its own `persist eval` blocks, not for expanding caller content; children may create resources (nested components, daemons) but their lifecycle is bound by their place in the expansion tree; installing providers inside the closure ensures the correct context is visible regardless of which task it runs in |
-| 52 | `durableSample` routes through `EvalScope` | Sample Api middleware installed by `persist eval` blocks (e.g., `LlamafileProvider`'s `Sample.around()`) lives in the eval scope's task hierarchy; routing through `evalScope.eval()` ensures the middleware chain is found |
+| 52 | `durableSample` routes through `EvalScope` | Sample Api middleware installed by provider components with `persist ephemeral eval` lives in the eval scope's task hierarchy; routing through `evalScope.eval()` ensures the middleware chain is found |
| 53 | Sample component calls `Sample.operations.sample()` directly | The enclosing eval operation journals the complete block result |
| 54 | Sample component props default to empty string, not undefined | `validateProps` omits optional props with no default from `env.values`, causing `ReferenceError` in eval blocks; empty-string defaults ensure the variables exist; `model \|\| undefined` converts empty to undefined for routing semantics |
| 55 | `daemon()` uses `shell: true` | Matches `bash exec` block semantics — the same command string passed to `bash -c` is passed to the shell; handles shell expansions and PATH lookups correctly |
-| 56 | Provider installs its own middleware, not a global `useLlamafileSample()` | A single global handler installed before `execute()` would execute in the outer scope at call time, where the binding environment has no `baseUrl`; middleware must close over `baseUrl` and `model` at the moment the provider becomes active |
+| 56 | Provider installs middleware inside its invocation | Middleware closes over the current live endpoint and remains lexically scoped to the subtree that owns the service |
| 57 | Routing key is `model`, not a separate `name` prop | Model identity is the natural key — it unifies "which server to route to" with "which model to request"; a separate `name` prop would require keeping two values in sync with no added expressiveness |
| 58 | `context.model === undefined` routes to innermost provider | Omitting a model is the common case for single-provider documents; innermost-wins matches how middleware chains work — handlers installed later sit higher in the chain and are traversed first |
-| 59 | `callLlamafile()` is a standard import in generated eval modules | Provider components are markdown files — eval blocks are compiled into `data:` URI modules that import executable.md globals from `@executablemd/core`; functions like `callLlamafile`, `callOllama`, `callAnthropic`, `Sample`, `findFreePort`, and `useContent` are available via this import |
+| 59 | Provider components use ordinary generated-module imports | Provider-specific client functions may be imported explicitly; executable.md supplies `Sample`, `when`, `fetch` and the contextual document bindings |
| 60 | Props pre-populated into `env.values` at component invocation | Code block content uses bare `{name}` binding interpolation from `env.values`; props must enter `env.values` at invocation time to be accessible in code blocks; consistent with how eval bindings work |
-| 61 | `callLlamafile()` uses `@effectionx/fetch` | The HTTP call is an Effection operation executed once per document run |
-| 62 | `LlamafileProvider.md` hardcodes `/health` endpoint | All major llamafile/llama.cpp-compatible servers use `/health`; the hardcoded path covers the supported targets |
+| 61 | Provider HTTP calls use `@effectionx/fetch` | Calls remain Effection operations under structured cancellation and the provider's lexical middleware |
+| 62 | Protocol readiness and application health are separate | The readiness record proves the cooperative process owns the assigned endpoint; an application may still use `when` for a later domain-specific condition |
| 63 | `stdio: "inherit"` is the default for `daemon()` | During development, seeing server logs in the terminal is valuable; production deployments can pass `stdio: "ignore"`; the executable.md `daemonFactory` passes no stdio option, defaulting to `"inherit"` |
| 64 | `DocumentOutput` Api with single `output` operation | Extensible to progress/printed errors; middleware-composable via `scope.around`; single Api surface for all output concerns |
| 65 | Whitespace normalization is middleware, not post-processing | Stateful across calls; composes with other middleware; can be disabled via `--raw`; mutable closure state scoped per `useNormalizedOutput()` call |
@@ -7180,7 +7083,7 @@ must preserve the trace for diagnosis or remove it before starting a new run.
| 89 | `{meta.*}` / `{props.*}` resolve before bare `{name}` | Component contract (frontmatter) takes precedence over internal eval state; dotted vs bare syntax prevents actual collisions |
| 90 | `\{` escaping applies to both passes | Consistent escaping behavior regardless of which pass would match; pre-existing gap in §6.6 fixed for both code blocks and text segments |
| 85 | Eval block `return` as rendered output | Eval blocks can produce output via `return "text"` in addition to `output("text")`; `output()` wins if both used; null/undefined returns produce no output; lets a component's whole body be one conditional expression |
-| 86 | `sample` modifier removed | All LLM calls go through the `` component; removes `sampleFactory`, `durableSample`, `callLlamafile`, `callOllama`, `callAnthropic`; simplifies the modifier chain to pure exec/eval concerns |
+| 86 | `sample` modifier removed | All LLM calls go through the `` component; provider-specific call helpers are not built-in modifier behavior |
| 87 | `SampleContext` simplified to content-centric shape | Changed from exec-centric `{stdout, stderr, exitCode, command, language}` to content-centric `{content, model?, params?, system?, componentName?}`; providers build their own messages directly instead of relying on `buildDefaultMessages` |
| 91 | Projected children carry caller's eval env | Children substituted via `` are tagged with `projectedEnv`. Expression props on projected children resolve against merged env (caller + component), with component bindings taking precedence. Follows React's lexical scoping model. |
| 92 | Multi-level projection env propagation | When `expandComponent` receives `projectedEnv`, it merges it with the current context env before tagging the next level's children. Creates a cumulative chain: Root → Provider → Instruction → ReviewBody all carry root bindings. Innermost-wins on collision. |
From 006a4cbee7d734e874e41f83d8d307563cabdade Mon Sep 17 00:00:00 2001
From: Taras Mankovski <74687+taras@users.noreply.github.com>
Date: Sat, 8 Aug 2026 04:39:40 -0400
Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=90=9B=20Fix=20cooperative=20service?=
=?UTF-8?q?=20lifecycle=20blockers?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
packages/cli/src/service-host.ts | 173 ++++++++---
.../tests/fixtures/cooperative-service.mjs | 14 +
packages/cli/tests/service-document.test.ts | 231 ++++++++++++++-
packages/cli/tests/service-host.test.ts | 278 +++++++++++++++++-
packages/core/src/eval-handler.ts | 4 +
packages/core/src/live-env.ts | 10 +
packages/core/tests/ephemeral-service.test.ts | 200 ++++++++++++-
packages/durable-streams/effect.ts | 26 +-
specs/executable-mdx-spec.md | 55 ++--
9 files changed, 909 insertions(+), 82 deletions(-)
diff --git a/packages/cli/src/service-host.ts b/packages/cli/src/service-host.ts
index e128ca95..1174461f 100644
--- a/packages/cli/src/service-host.ts
+++ b/packages/cli/src/service-host.ts
@@ -1,7 +1,7 @@
/** Shared mechanics for the runtime-named cooperative service adapters. */
-import { ensure, race, resource, withResolvers, type Operation } from "effection";
-import { daemon, Stdio } from "@effectionx/process";
+import { ensure, race, resource, scoped, withResolvers, type Operation } from "effection";
+import { daemon, Stdio, type Daemon } from "@effectionx/process";
import { timebox } from "@effectionx/timebox";
import {
API,
@@ -11,6 +11,7 @@ import {
ServiceProtocolDuplicateError,
ServiceProtocolMalformedError,
ServiceStartupTimeoutError,
+ ServiceTeardownError,
ServiceUnexpectedExitError,
parseServiceReadyRecord,
timeout,
@@ -24,47 +25,33 @@ interface HostServiceAdapter {
stderr(bytes: Uint8Array): void;
}
-interface ProtocolObserver {
+export interface ProtocolObserver {
stdout(bytes: Uint8Array): Operation;
flush(): Operation;
}
const encoder = new TextEncoder();
const prefixBytes = encoder.encode(SERVICE_READY_PREFIX);
+const MAX_PROTOCOL_RECORD_BYTES = 1_024;
-function concat(left: Uint8Array, right: Uint8Array): Uint8Array {
- const joined = new Uint8Array(left.byteLength + right.byteLength);
- joined.set(left);
- joined.set(right, left.byteLength);
- return joined;
-}
-
-function startsWithPrefix(line: Uint8Array): boolean {
- if (line.byteLength < prefixBytes.byteLength) {
- return false;
- }
- for (let index = 0; index < prefixBytes.byteLength; index += 1) {
- if (line[index] !== prefixBytes[index]) {
- return false;
- }
- }
- return true;
-}
-
-function createProtocolObserver(options: {
+export function createProtocolObserver(options: {
token: string;
ready(endpoint: ServiceEndpoint): void;
fail(error: Error): void;
forward(bytes: Uint8Array): void;
}): ProtocolObserver {
- let pending: Uint8Array = new Uint8Array();
+ let state: "prefix" | "ordinary" | "protocol" | "suppressed" = "prefix";
+ let possiblePrefix: number[] = [];
+ let protocolRecord: number[] = [];
let readinessSeen = false;
- function consume(line: Uint8Array): void {
- if (!startsWithPrefix(line)) {
- options.forward(line);
- return;
- }
+ function beginLine(): void {
+ state = "prefix";
+ possiblePrefix = [];
+ protocolRecord = [];
+ }
+
+ function consumeProtocolRecord(): void {
if (readinessSeen) {
options.fail(new ServiceProtocolDuplicateError());
return;
@@ -73,7 +60,7 @@ function createProtocolObserver(options: {
let payload: string;
try {
payload = new TextDecoder("utf-8", { fatal: true }).decode(
- line.subarray(prefixBytes.byteLength, line.byteLength - 1),
+ Uint8Array.from(protocolRecord.slice(prefixBytes.byteLength)),
);
} catch {
options.fail(new ServiceProtocolMalformedError());
@@ -89,27 +76,91 @@ function createProtocolObserver(options: {
}
}
+ function appendProtocol(bytes: Uint8Array, start: number, end: number): boolean {
+ if (protocolRecord.length + end - start > MAX_PROTOCOL_RECORD_BYTES) {
+ protocolRecord = [];
+ state = "suppressed";
+ options.fail(new ServiceProtocolMalformedError());
+ return false;
+ }
+ for (let index = start; index < end; index += 1) {
+ protocolRecord.push(bytes[index]!);
+ }
+ return true;
+ }
+
return {
*stdout(bytes: Uint8Array): Operation {
- pending = concat(pending, bytes);
- let newline = pending.indexOf(10);
- while (newline !== -1) {
- const line = pending.slice(0, newline + 1);
- pending = pending.slice(newline + 1);
- consume(line);
- newline = pending.indexOf(10);
+ let index = 0;
+ while (index < bytes.byteLength) {
+ if (state === "prefix") {
+ const byte = bytes[index]!;
+ if (byte === prefixBytes[possiblePrefix.length]) {
+ possiblePrefix.push(byte);
+ index += 1;
+ if (possiblePrefix.length === prefixBytes.byteLength) {
+ protocolRecord = [...possiblePrefix];
+ possiblePrefix = [];
+ state = "protocol";
+ }
+ continue;
+ }
+
+ if (possiblePrefix.length > 0) {
+ options.forward(Uint8Array.from(possiblePrefix));
+ possiblePrefix = [];
+ }
+ state = "ordinary";
+ continue;
+ }
+
+ if (state === "ordinary") {
+ const newline = bytes.indexOf(10, index);
+ if (newline === -1) {
+ options.forward(bytes.slice(index));
+ index = bytes.byteLength;
+ } else {
+ options.forward(bytes.slice(index, newline + 1));
+ index = newline + 1;
+ beginLine();
+ }
+ continue;
+ }
+
+ if (state === "protocol") {
+ const newline = bytes.indexOf(10, index);
+ const end = newline === -1 ? bytes.byteLength : newline;
+ if (!appendProtocol(bytes, index, end)) {
+ index = end;
+ continue;
+ }
+ if (newline === -1) {
+ index = bytes.byteLength;
+ } else {
+ consumeProtocolRecord();
+ index = newline + 1;
+ beginLine();
+ }
+ continue;
+ }
+
+ const newline = bytes.indexOf(10, index);
+ if (newline === -1) {
+ index = bytes.byteLength;
+ } else {
+ index = newline + 1;
+ beginLine();
+ }
}
},
*flush(): Operation {
- if (pending.byteLength > 0) {
- if (startsWithPrefix(pending)) {
- pending = new Uint8Array();
- throw new ServiceProtocolMalformedError();
- } else {
- options.forward(pending);
- pending = new Uint8Array();
- }
+ if (state === "prefix" && possiblePrefix.length > 0) {
+ options.forward(Uint8Array.from(possiblePrefix));
+ } else if (state === "protocol") {
+ beginLine();
+ throw new ServiceProtocolMalformedError();
}
+ beginLine();
},
};
}
@@ -157,6 +208,32 @@ function* waitForStartup(options: {
return result.value;
}
+function serviceProcess(options: {
+ command: string;
+ cwd?: string;
+ environment: Record;
+}): Operation {
+ return resource(function* (provide) {
+ let published = false;
+ try {
+ yield* scoped(function* () {
+ const process = yield* daemon(options.command, {
+ shell: true,
+ cwd: options.cwd,
+ env: options.environment,
+ });
+ published = true;
+ yield* provide(process);
+ });
+ } catch (error) {
+ if (!published || error instanceof ServiceTeardownError) {
+ throw error;
+ }
+ throw new ServiceTeardownError({ cause: error });
+ }
+ });
+}
+
function startHostService(
options: ServiceStartOptions,
adapter: HostServiceAdapter,
@@ -196,10 +273,10 @@ function startHostService(
XMD_SERVICE_HOST: SERVICE_HOSTNAME,
XMD_SERVICE_PORT: "0",
};
- const process = yield* daemon(options.command, {
- shell: true,
+ const process = yield* serviceProcess({
+ command: options.command,
cwd: options.cwd,
- env: environment,
+ environment,
});
const endpoint = yield* waitForStartup({
ready: ready.operation,
diff --git a/packages/cli/tests/fixtures/cooperative-service.mjs b/packages/cli/tests/fixtures/cooperative-service.mjs
index 25eeca41..aa4089c0 100644
--- a/packages/cli/tests/fixtures/cooperative-service.mjs
+++ b/packages/cli/tests/fixtures/cooperative-service.mjs
@@ -18,7 +18,16 @@ if (mode === "non-cooperative") {
process.stderr.write("service stderr before readiness\n");
const server = createServer((_request, response) => {
+ process.stderr.write(`service request:${nonce}\n`);
response.end(`service:${nonce}`);
+ if (mode === "exit-on-request") {
+ setTimeout(() => process.exit(19), 10);
+ }
+ });
+
+ process.on("SIGTERM", () => {
+ process.stderr.write(`service stopping:${nonce}\n`);
+ server.close(() => process.exit(0));
});
server.listen(requestedPort, host, () => {
@@ -74,6 +83,11 @@ if (mode === "non-cooperative") {
process.stdout.write("service stdout after readiness\n");
process.stderr.write("service stderr after readiness\n");
+ if (mode === "unterminated-live-output") {
+ process.stdout.write("unterminated-live-output");
+ process.stderr.write("unterminated live output written\n");
+ }
+
if (mode === "duplicate") {
setTimeout(() => process.stdout.write(line), 10);
}
diff --git a/packages/cli/tests/service-document.test.ts b/packages/cli/tests/service-document.test.ts
index 47d60060..fd75d112 100644
--- a/packages/cli/tests/service-document.test.ts
+++ b/packages/cli/tests/service-document.test.ts
@@ -1,17 +1,22 @@
import { beforeAll, describe, it } from "@executablemd/test-support/bdd";
import { expect } from "@executablemd/test-support/expect";
+import { when } from "@effectionx/converge";
import { once } from "@effectionx/node/events";
-import { race, resource, scoped, type Operation } from "effection";
+import { timebox } from "@effectionx/timebox";
+import { race, resource, scoped, sleep, type Operation } from "effection";
import { createServer } from "node:http";
import process from "node:process";
import { InMemoryStream } from "@executablemd/durable-streams";
-import { collect, execute, useTempFileCompiler } from "@executablemd/core";
-import { SERVICE_HOSTNAME } from "@executablemd/runtime";
+import { collect, execute, InvocationTeardownError, useTempFileCompiler } from "@executablemd/core";
+import { SERVICE_HOSTNAME, ServiceUnexpectedExitError } from "@executablemd/runtime";
import { useStubFs } from "@executablemd/runtime/test";
import { inheritedEnvironment, installHostService } from "../src/service-host.ts";
const fixture = new URL("./fixtures/cooperative-service.mjs", import.meta.url).pathname;
-const command = `node ${JSON.stringify(fixture)} normal document`;
+
+function command(mode: string, nonce: string): string {
+ return `node ${JSON.stringify(fixture)} ${mode} ${nonce}`;
+}
const SAMPLE = `---
meta:
@@ -34,7 +39,7 @@ meta:
---
\`\`\`bash service=server exec
-${command}
+${command("normal", "document")}
\`\`\`
\`\`\`js persist ephemeral eval
@@ -93,6 +98,43 @@ function occupy(port: number): Operation {
});
}
+function fixturePids(stderr: string[]): number[] {
+ return [...stderr.join("").matchAll(/service pid:(\d+)/g)].map((match) => Number(match[1]));
+}
+
+function isAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function* expectGone(pids: number[]): Operation {
+ const result = yield* timebox(2_000, () =>
+ when(function* () {
+ if (pids.some(isAlive)) {
+ throw new Error("service child has not exited yet");
+ }
+ }),
+ );
+ expect(result.timeout).toBe(false);
+}
+
+function hasUnexpectedExit(error: unknown): boolean {
+ if (error instanceof ServiceUnexpectedExitError) {
+ return true;
+ }
+ if (error instanceof AggregateError) {
+ return error.errors.some(hasUnexpectedExit);
+ }
+ if (error instanceof InvocationTeardownError) {
+ return error.causes.some(hasUnexpectedExit);
+ }
+ return error instanceof Error && error.cause !== undefined && hasUnexpectedExit(error.cause);
+}
+
describe("cooperative service document integration", () => {
beforeAll(() => useTempFileCompiler());
@@ -142,4 +184,183 @@ describe("cooperative service document integration", () => {
expect(tokenCalls).toBe(2);
});
});
+
+ it("supervises a ready service that exits during projected content without restarting it", function* () {
+ const stderr: string[] = [];
+ let tokenCalls = 0;
+ yield* scoped(function* () {
+ yield* installHostService({
+ token() {
+ tokenCalls += 1;
+ return tokenCalls.toString(16).padStart(64, "0");
+ },
+ environment: () => inheritedEnvironment(process.env),
+ stdout() {},
+ stderr(bytes) {
+ stderr.push(new TextDecoder().decode(bytes));
+ },
+ });
+ yield* useStubFs({
+ "doc.md": "\n",
+ "components/Provider.md": `---
+meta:
+ componentName: Provider
+---
+
+\`\`\`bash service=server exec
+${command("exit-on-request", "projected-exit")}
+\`\`\`
+
+\`\`\`js persist ephemeral eval
+const endpoint = server;
+yield* Sample.around({
+ *sample() {
+ const response = yield* fetch(\`http://\${endpoint.hostname}:\${endpoint.port}\`).expect();
+ const text = yield* response.text();
+ yield* sleep(100);
+ return text;
+ },
+});
+\`\`\`
+
+
+`,
+ "components/Sample.md": SAMPLE,
+ });
+
+ let failure: unknown;
+ try {
+ yield* runDocument(new InMemoryStream());
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(stderr.join("")).toContain("service request:projected-exit");
+ expect(hasUnexpectedExit(failure)).toBe(true);
+ expect(tokenCalls).toBe(1);
+ });
+ yield* expectGone(fixturePids(stderr));
+ });
+
+ it("fails projected content promptly, tears down retained services, and does not restart", function* () {
+ const stderr: string[] = [];
+ let tokenCalls = 0;
+ yield* scoped(function* () {
+ yield* installHostService({
+ token() {
+ tokenCalls += 1;
+ return tokenCalls.toString(16).padStart(64, "0");
+ },
+ environment: () => inheritedEnvironment(process.env),
+ stdout() {},
+ stderr(bytes) {
+ stderr.push(new TextDecoder().decode(bytes));
+ },
+ });
+ yield* useStubFs({
+ "doc.md": "\n",
+ "components/Provider.md": `---
+meta:
+ componentName: Provider
+---
+
+\`\`\`bash service=firstServer exec
+${command("normal", "first-retained")}
+\`\`\`
+
+\`\`\`bash service=secondServer exec
+${command("normal", "second-retained")}
+\`\`\`
+
+
+`,
+ "components/Broken.md": `---
+meta:
+ componentName: Broken
+---
+
+\`\`\`js eval
+throw new Error("projected content failure");
+\`\`\`
+`,
+ });
+
+ const outcome = yield* timebox(2_000, function* () {
+ try {
+ yield* runDocument(new InMemoryStream());
+ } catch (error) {
+ return error;
+ }
+ return undefined;
+ });
+ expect(outcome.timeout).toBe(false);
+ if (outcome.timeout) {
+ throw new Error("document failure did not complete promptly");
+ }
+ expect(String(outcome.value)).toContain("projected content failure");
+ expect(tokenCalls).toBe(2);
+ yield* sleep(50);
+ expect(tokenCalls).toBe(2);
+ });
+
+ const pids = fixturePids(stderr);
+ expect(pids).toHaveLength(2);
+ expect(stderr.join("")).toContain("service stopping:first-retained");
+ expect(stderr.join("")).toContain("service stopping:second-retained");
+ yield* expectGone(pids);
+ });
+
+ it("tears down nested provider services from inner lifetime to outer lifetime", function* () {
+ const stderr: string[] = [];
+ let tokenCalls = 0;
+ yield* scoped(function* () {
+ yield* installHostService({
+ token() {
+ tokenCalls += 1;
+ return tokenCalls.toString(16).padStart(64, "0");
+ },
+ environment: () => inheritedEnvironment(process.env),
+ stdout() {},
+ stderr(bytes) {
+ stderr.push(new TextDecoder().decode(bytes));
+ },
+ });
+ yield* useStubFs({
+ "doc.md": "nested content\n",
+ "components/Outer.md": `---
+meta:
+ componentName: Outer
+---
+
+\`\`\`bash service=outerServer exec
+${command("normal", "outer")}
+\`\`\`
+
+
+`,
+ "components/Inner.md": `---
+meta:
+ componentName: Inner
+---
+
+\`\`\`bash service=innerServer exec
+${command("normal", "inner")}
+\`\`\`
+
+
+`,
+ });
+
+ const output = yield* runDocument(new InMemoryStream());
+ expect(output).toContain("nested content");
+ expect(tokenCalls).toBe(2);
+ });
+
+ const log = stderr.join("");
+ expect(log.indexOf("service stopping:inner")).toBeGreaterThan(-1);
+ expect(log.indexOf("service stopping:outer")).toBeGreaterThan(
+ log.indexOf("service stopping:inner"),
+ );
+ yield* expectGone(fixturePids(stderr));
+ });
});
diff --git a/packages/cli/tests/service-host.test.ts b/packages/cli/tests/service-host.test.ts
index 836cc204..921d6954 100644
--- a/packages/cli/tests/service-host.test.ts
+++ b/packages/cli/tests/service-host.test.ts
@@ -1,8 +1,21 @@
import { describe, it } from "@executablemd/test-support/bdd";
import { expect } from "@executablemd/test-support/expect";
-import { scoped, spawn, suspend, until, withResolvers, type Operation } from "effection";
+import {
+ createSignal,
+ race,
+ resource,
+ scoped,
+ spawn,
+ suspend,
+ until,
+ withResolvers,
+ type Operation,
+} from "effection";
import { when } from "@effectionx/converge";
+import { once } from "@effectionx/node/events";
import { timebox } from "@effectionx/timebox";
+import { ProcessApi, Stdio, type Daemon } from "@effectionx/process";
+import { withInvocation, InvocationTeardownError } from "@executablemd/core";
import {
ServiceProcessExitBeforeReadyError,
ServiceProtocolDuplicateError,
@@ -11,10 +24,16 @@ import {
ServiceProtocolMalformedError,
ServiceProtocolTokenMismatchError,
ServiceStartupTimeoutError,
+ ServiceTeardownError,
ServiceUnexpectedExitError,
startService,
} from "@executablemd/runtime";
-import { inheritedEnvironment, installHostService } from "../src/service-host.ts";
+import {
+ createProtocolObserver,
+ inheritedEnvironment,
+ installHostService,
+} from "../src/service-host.ts";
+import { createServer } from "node:http";
import process from "node:process";
const TOKEN = "12".repeat(32);
@@ -62,31 +81,172 @@ function* expectGone(pids: number[]): Operation {
expect(result.timeout).toBe(false);
}
+function occupy(port: number): Operation {
+ return resource(function* (provide) {
+ const server = createServer((_request, response) => response.end("foreign"));
+ const listening = once(server, "listening");
+ const failed = once<[Error]>(server, "error");
+ server.listen(port, "127.0.0.1");
+ yield* race([
+ listening,
+ (function* () {
+ const [error] = yield* failed;
+ throw error;
+ })(),
+ ]);
+ try {
+ yield* provide();
+ } finally {
+ server.close();
+ }
+ });
+}
+
+function* useTeardownFailure(failure: Error): Operation {
+ yield* ProcessApi.around({
+ *daemon() {
+ return yield* resource(function* (provide) {
+ const stdout = createSignal();
+ const stderr = createSignal();
+ const exited = withResolvers<{ code?: number; signal?: string }>();
+ const process = {
+ pid: 42,
+ stdin: { send(_data: string) {} },
+ stdout,
+ stderr,
+ *join() {
+ return yield* exited.operation;
+ },
+ *expect() {
+ return yield* exited.operation;
+ },
+ *around(...args: Parameters): ReturnType {
+ return yield* Stdio.around(...args);
+ },
+ *[Symbol.iterator]() {
+ yield* suspend();
+ },
+ } satisfies Daemon;
+ const record = JSON.stringify({
+ version: 1,
+ token: TOKEN,
+ hostname: "127.0.0.1",
+ port: 41_234,
+ });
+ yield* Stdio.operations.stdout(new TextEncoder().encode(`XMD_SERVICE_READY:${record}\n`));
+ try {
+ yield* provide(process);
+ } finally {
+ stdout.close();
+ stderr.close();
+ throw failure;
+ }
+ });
+ },
+ });
+}
+
describe("cooperative host service adapter", () => {
- it("starts real isolated services, forwards live output, and suppresses readiness", function* () {
+ it("forwards split ordinary bytes immediately and suppresses split protocol records", function* () {
+ const forwarded: number[] = [];
+ const endpoints: Array<{ hostname: string; port: number }> = [];
+ const failures: Error[] = [];
+ const observer = createProtocolObserver({
+ token: TOKEN,
+ ready: (endpoint) => endpoints.push(endpoint),
+ fail: (error) => failures.push(error),
+ forward: (bytes) => forwarded.push(...bytes),
+ });
+ const ordinary = Uint8Array.from([88, 77, 111, 114, 100, 105, 110, 97, 114, 121, 0, 255]);
+ yield* observer.stdout(ordinary.slice(0, 2));
+ yield* observer.stdout(ordinary.slice(2, 7));
+ yield* observer.stdout(ordinary.slice(7));
+ expect(forwarded).toEqual([...ordinary]);
+
+ forwarded.length = 0;
+ const record = new TextEncoder().encode(
+ `\nXMD_SERVICE_READY:${JSON.stringify({
+ version: 1,
+ token: TOKEN,
+ hostname: "127.0.0.1",
+ port: 41_235,
+ })}\n`,
+ );
+ yield* observer.stdout(record.slice(0, 5));
+ yield* observer.stdout(record.slice(5, 19));
+ yield* observer.stdout(record.slice(19, 47));
+ yield* observer.stdout(record.slice(47));
+
+ expect(forwarded).toEqual([10]);
+ expect(endpoints).toEqual([{ hostname: "127.0.0.1", port: 41_235 }]);
+ expect(failures).toEqual([]);
+
+ yield* observer.stdout(record.slice(1));
+ expect(failures).toHaveLength(1);
+ expect(failures[0]).toBeInstanceOf(ServiceProtocolDuplicateError);
+ expect(forwarded).toEqual([10]);
+ });
+
+ it("bounds and suppresses an invalid protocol candidate", function* () {
+ const forwarded: number[] = [];
+ const failures: Error[] = [];
+ const observer = createProtocolObserver({
+ token: TOKEN,
+ ready() {},
+ fail: (error) => failures.push(error),
+ forward: (bytes) => forwarded.push(...bytes),
+ });
+ const secret = `${TOKEN}${"x".repeat(1_024)}`;
+ const bytes = new TextEncoder().encode(`XMD_SERVICE_READY:${secret}\nordinary`);
+
+ yield* observer.stdout(bytes);
+
+ expect(failures).toHaveLength(1);
+ expect(failures[0]).toBeInstanceOf(ServiceProtocolMalformedError);
+ expect(new TextDecoder().decode(Uint8Array.from(forwarded))).toBe("ordinary");
+ expect(String(failures[0])).not.toContain(TOKEN);
+ });
+
+ it("starts genuinely concurrent isolated services and suppresses readiness", function* () {
const stdout: string[] = [];
const stderr: string[] = [];
yield* scoped(function* () {
yield* installHostService(adapter(stdout, stderr));
- const first = yield* startService({ command: command("normal", "first") });
- const second = yield* startService({ command: command("normal", "second") });
+ const release = withResolvers();
+ const firstReady = withResolvers<{ hostname: string; port: number }>();
+ const secondReady = withResolvers<{ hostname: string; port: number }>();
+ const firstOwner = yield* spawn(function* () {
+ const service = yield* startService({ command: command("normal", "first") });
+ firstReady.resolve(service.endpoint);
+ yield* release.operation;
+ });
+ const secondOwner = yield* spawn(function* () {
+ const service = yield* startService({ command: command("normal", "second") });
+ secondReady.resolve(service.endpoint);
+ yield* release.operation;
+ });
+ const first = yield* firstReady.operation;
+ const second = yield* secondReady.operation;
- expect(first.endpoint.port).not.toBe(second.endpoint.port);
- expect(Object.isFrozen(first.endpoint)).toBe(true);
+ expect(first.port).not.toBe(second.port);
+ expect(Object.isFrozen(first)).toBe(true);
const firstResponse = yield* until(
globalThis
- .fetch(`http://${first.endpoint.hostname}:${first.endpoint.port}`)
+ .fetch(`http://${first.hostname}:${first.port}`)
.then((response) => response.text()),
);
const secondResponse = yield* until(
globalThis
- .fetch(`http://${second.endpoint.hostname}:${second.endpoint.port}`)
+ .fetch(`http://${second.hostname}:${second.port}`)
.then((response) => response.text()),
);
expect(firstResponse).toBe("service:first");
expect(secondResponse).toBe("service:second");
+ release.resolve();
+ yield* firstOwner;
+ yield* secondOwner;
});
expect(stdout.join("")).toContain("service stdout before readiness");
@@ -158,6 +318,106 @@ describe("cooperative host service adapter", () => {
yield* expectGone([pid]);
});
+ it("cancels after readiness and releases the child listener", function* () {
+ const stderr: string[] = [];
+ const ready = withResolvers<{ hostname: string; port: number }>();
+ let endpoint = { hostname: "127.0.0.1", port: 0 };
+
+ yield* scoped(function* () {
+ yield* installHostService(adapter([], stderr));
+ const owner = yield* spawn(function* () {
+ const service = yield* startService({ command: command("normal", "cancel-ready") });
+ ready.resolve(service.endpoint);
+ yield* suspend();
+ });
+ endpoint = yield* ready.operation;
+ const response = yield* until(
+ globalThis
+ .fetch(`http://${endpoint.hostname}:${endpoint.port}`)
+ .then((result) => result.text()),
+ );
+ expect(response).toBe("service:cancel-ready");
+ yield* owner.halt();
+ yield* expectGone(fixturePids(stderr));
+ yield* scoped(() => occupy(endpoint.port));
+ });
+ });
+
+ it("forwards unterminated ordinary stdout while the service is still active", function* () {
+ const stdout: string[] = [];
+ const stderr: string[] = [];
+
+ yield* scoped(function* () {
+ yield* installHostService(adapter(stdout, stderr));
+ yield* startService({ command: command("unterminated-live-output") });
+ const observed = yield* timebox(2_000, () =>
+ when(function* () {
+ if (!stderr.join("").includes("unterminated live output written")) {
+ throw new Error("fixture has not written its unterminated stdout yet");
+ }
+ }),
+ );
+ expect(observed.timeout).toBe(false);
+ expect(stdout.join("")).toContain("unterminated-live-output");
+ });
+ });
+
+ it("translates an observable process teardown failure", function* () {
+ const planted = new Error("injected process teardown failure");
+ let failure: unknown;
+ try {
+ yield* scoped(function* () {
+ yield* installHostService(adapter([], []));
+ yield* useTeardownFailure(planted);
+ yield* startService({ command: "injected-service" });
+ });
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(failure).toBeInstanceOf(ServiceTeardownError);
+ if (!(failure instanceof ServiceTeardownError)) {
+ throw new Error("expected ServiceTeardownError");
+ }
+ expect(failure.cause).toBe(planted);
+ });
+
+ it("preserves an execution failure beside a translated teardown failure", function* () {
+ const execution = new Error("active execution failure");
+ const teardown = new Error("injected process teardown failure");
+ let failure: unknown;
+ try {
+ yield* scoped(function* () {
+ yield* installHostService(adapter([], []));
+ yield* useTeardownFailure(teardown);
+ yield* withInvocation(function* () {
+ yield* startService({ command: "injected-service" });
+ throw execution;
+ });
+ });
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(failure).toBeInstanceOf(AggregateError);
+ if (!(failure instanceof AggregateError)) {
+ throw new Error("expected AggregateError");
+ }
+ expect(failure.errors[0]).toBe(execution);
+ expect(failure.errors[1]).toBeInstanceOf(InvocationTeardownError);
+ const invocationTeardown = failure.errors[1];
+ if (!(invocationTeardown instanceof InvocationTeardownError)) {
+ throw new Error("expected InvocationTeardownError");
+ }
+ expect(invocationTeardown.causes).toHaveLength(1);
+ expect(invocationTeardown.causes[0]).toBeInstanceOf(ServiceTeardownError);
+ const serviceTeardown = invocationTeardown.causes[0];
+ if (!(serviceTeardown instanceof ServiceTeardownError)) {
+ throw new Error("expected ServiceTeardownError");
+ }
+ expect(serviceTeardown.cause).toBe(teardown);
+ });
+
it("fails the owning scope when a ready process exits or repeats readiness", function* () {
const cases: Array<[string, { prototype: Error }]> = [
["exit-after", ServiceUnexpectedExitError],
diff --git a/packages/core/src/eval-handler.ts b/packages/core/src/eval-handler.ts
index 91a50a23..ede9e05f 100644
--- a/packages/core/src/eval-handler.ts
+++ b/packages/core/src/eval-handler.ts
@@ -21,6 +21,7 @@ import { transformBlock, serializeExports } from "./eval-transform.ts";
import {
commitLiveExports,
liveEnvironment,
+ validateDurableExports,
validateLiveExports,
validateLiveOverlay,
} from "./live-env.ts";
@@ -208,6 +209,9 @@ export const evalFactory: ModifierFactory = (_params) => (_args, _next) =>
return { value: exports as unknown as Json } as Json;
},
+ {
+ validate: () => validateDurableExports(transformed.exports, liveEnvironment(evalEnv)),
+ },
)) as unknown as { value: Json };
if (result.value && typeof result.value === "object") {
diff --git a/packages/core/src/live-env.ts b/packages/core/src/live-env.ts
index a4cff45d..dbc1896c 100644
--- a/packages/core/src/live-env.ts
+++ b/packages/core/src/live-env.ts
@@ -127,6 +127,16 @@ export function validateLiveExports(exports: string[], durable: EvalEnv): void {
}
}
+/** Refuse a durable operation before it can execute or restore a colliding export. */
+export function validateDurableExports(exports: string[], live: LiveEnv): void {
+ const collision = exports.find((name) => name in live.values);
+ if (collision !== undefined) {
+ throw new LiveBindingCollisionError(
+ `durable eval export "${collision}" collides with a live binding`,
+ );
+ }
+}
+
/** Refuse a durable value added after a live binding of the same name. */
export function validateLiveOverlay(durable: EvalEnv, live: LiveEnv): void {
const collision = Object.keys(live.values).find((name) => name in durable.values);
diff --git a/packages/core/tests/ephemeral-service.test.ts b/packages/core/tests/ephemeral-service.test.ts
index 401465a1..d6859b29 100644
--- a/packages/core/tests/ephemeral-service.test.ts
+++ b/packages/core/tests/ephemeral-service.test.ts
@@ -1,7 +1,11 @@
import { beforeAll, describe, it } from "@executablemd/test-support/bdd";
import { expect } from "@executablemd/test-support/expect";
import { resource, scoped, type Operation } from "effection";
-import { InMemoryStream } from "@executablemd/durable-streams";
+import {
+ InMemoryStream,
+ parseDurableEvent,
+ serializeDurableEvent,
+} from "@executablemd/durable-streams";
import { API, SERVICE_HOSTNAME } from "@executablemd/runtime";
import type { ServiceEndpoint } from "@executablemd/runtime";
import { useStubFs } from "@executablemd/runtime/test";
@@ -262,4 +266,198 @@ done
expect(lifecycle.stops).toBe(expectedStarts);
}
});
+
+ it("rejects a durable eval export that collides with an existing live binding", function* () {
+ const lifecycle = { starts: 0, stops: 0 };
+ const stream = new InMemoryStream();
+ yield* useServiceStub(Object.freeze({ hostname: SERVICE_HOSTNAME, port: 40_100 }), lifecycle);
+ yield* useStubFs({
+ "doc.md": `
+
+\`\`\`bash service=server exec
+cooperative-server
+\`\`\`
+
+\`\`\`js eval
+const partial = "must-not-commit";
+const server = "must-not-execute";
+\`\`\`
+
+
+`,
+ });
+
+ let failure: unknown;
+ try {
+ yield* scoped(function* () {
+ yield* collect(yield* execute({ path: "doc.md", stream }));
+ });
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(String(failure)).toContain("collides with a live binding");
+ expect(
+ stream
+ .snapshot()
+ .some((event) => event.type === "yield" && event.description.type === "eval"),
+ ).toBe(false);
+ expect(lifecycle).toEqual({ starts: 1, stops: 1 });
+ });
+
+ it("rejects a colliding durable export before partial replay restoration", function* () {
+ const endpoint = Object.freeze({ hostname: SERVICE_HOSTNAME, port: 40_101 });
+ const lifecycle = { starts: 0, stops: 0 };
+ const legacy = new InMemoryStream();
+ yield* useServiceStub(endpoint, lifecycle);
+ yield* useStubFs({
+ "doc.md": `
+
+\`\`\`bash service=other exec
+cooperative-server
+\`\`\`
+
+\`\`\`js eval
+const server = "legacy-durable-value";
+\`\`\`
+
+tail
+
+
+`,
+ });
+ yield* scoped(function* () {
+ yield* collect(yield* execute({ path: "doc.md", stream: legacy }));
+ });
+
+ const events = legacy.snapshot().map((event) => {
+ const parsed = parseDurableEvent(
+ serializeDurableEvent(event).replaceAll("service=other", "service=server"),
+ );
+ if (!parsed.ok) {
+ throw parsed.error;
+ }
+ return parsed.value;
+ });
+ const evalYield = events.findIndex(
+ (event) => event.type === "yield" && event.description.type === "eval",
+ );
+ expect(evalYield).toBeGreaterThan(-1);
+ const partial = new InMemoryStream(events.slice(0, evalYield + 1));
+ const beforeEvalEvents = partial
+ .snapshot()
+ .filter((event) => event.type === "yield" && event.description.type === "eval").length;
+ yield* useStubFs({
+ "doc.md": `
+
+\`\`\`bash service=server exec
+cooperative-server
+\`\`\`
+
+\`\`\`js eval
+const server = "legacy-durable-value";
+\`\`\`
+
+tail
+
+
+`,
+ });
+
+ let failure: unknown;
+ try {
+ yield* scoped(function* () {
+ yield* collect(yield* execute({ path: "doc.md", stream: partial }));
+ });
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(String(failure)).toContain("collides with a live binding");
+ expect(
+ partial
+ .snapshot()
+ .filter((event) => event.type === "yield" && event.description.type === "eval").length,
+ ).toBe(beforeEvalEvents);
+ expect(lifecycle).toEqual({ starts: 2, stops: 2 });
+ });
+
+ it("rejects ephemeral exports that collide with durable names before execution", function* () {
+ const stream = new InMemoryStream();
+ yield* useStubFs({
+ "doc.md": `
+
+\`\`\`js eval
+const shared = "durable";
+\`\`\`
+
+\`\`\`js ephemeral eval
+const partial = "must-not-commit";
+const shared = "must-not-execute";
+\`\`\`
+
+
+`,
+ });
+
+ let failure: unknown;
+ try {
+ yield* scoped(function* () {
+ yield* collect(yield* execute({ path: "doc.md", stream }));
+ });
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(String(failure)).toContain("collides with a durable binding");
+ expect(
+ stream
+ .snapshot()
+ .filter((event) => event.type === "yield" && event.description.type === "eval"),
+ ).toHaveLength(1);
+ });
+
+ it("allows a later ephemeral eval to update an existing live binding", function* () {
+ const stream = new InMemoryStream();
+ yield* useStubFs({
+ "doc.md": `\n`,
+ "components/Provider.md": `---
+meta:
+ componentName: Provider
+---
+
+\`\`\`js ephemeral eval
+const live = "first";
+\`\`\`
+
+\`\`\`js ephemeral eval
+const live = "second";
+\`\`\`
+
+\`\`\`js persist ephemeral eval
+const captured = live;
+yield* Sample.around({
+ *sample() { return captured; },
+});
+\`\`\`
+
+
+`,
+ "components/Sample.md": SAMPLE,
+ });
+
+ const output = String(
+ yield* scoped(function* () {
+ return yield* collect(
+ yield* execute({
+ path: "doc.md",
+ stream,
+ componentDirs: ["components", "."],
+ }),
+ );
+ }),
+ );
+
+ expect(output).toContain("second");
+ });
});
diff --git a/packages/durable-streams/effect.ts b/packages/durable-streams/effect.ts
index 2455251d..3883d830 100644
--- a/packages/durable-streams/effect.ts
+++ b/packages/durable-streams/effect.ts
@@ -81,6 +81,7 @@ function checkReplay(
resolve: Resolve>,
routine: CoroutineView,
ctx: DurableContext,
+ validate?: () => void,
): ReplayResult {
const entry = ctx.replayIndex.peekYield(ctx.coroutineId);
@@ -144,6 +145,16 @@ function checkReplay(
// All guards approved — consume the entry and advance cursor
ctx.replayIndex.consumeYield(ctx.coroutineId);
+ try {
+ validate?.();
+ } catch (error) {
+ resolve({
+ ok: false,
+ error: error instanceof Error ? error : new Error(String(error)),
+ });
+ return { path: "replayed", teardown: (exit) => exit(VOID_OK) };
+ }
+
// Feed stored result synchronously
resolve(protocolToEffection(entry.result));
return { path: "replayed", teardown: (exit) => exit(VOID_OK) };
@@ -293,10 +304,13 @@ export function createDurableEffect(
*
* @param desc Structured description for the journal and divergence detection
* @param execute Returns an Operation to run during live execution
+ * @param options.validate Runs before live execution or replay restoration; a
+ * thrown error is not persisted as this operation's result
*/
export function createDurableOperation(
desc: EffectDescription,
execute: () => Operation,
+ options: { validate?: () => void } = {},
): DurableEffect {
return {
description: `${desc.type}(${desc.name})`,
@@ -307,12 +321,22 @@ export function createDurableOperation(
routine,
): (resolve: Resolve>) => void {
const ctx = routine.scope.expect(DurableCtx);
- const replay = checkReplay(desc, resolve, routine, ctx);
+ const replay = checkReplay(desc, resolve, routine, ctx, options.validate);
if (replay.path === "replayed") {
return replay.teardown;
}
// ── LIVE PATH ──
+ try {
+ options.validate?.();
+ } catch (error) {
+ resolve({
+ ok: false,
+ error: error instanceof Error ? error : new Error(String(error)),
+ });
+ return (exit) => exit(VOID_OK);
+ }
+
// Run the entire execute → capture → persist → resolve sequence
// as a structured operation in the routine's scope.
routine.scope.run(function* () {
diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md
index dbfa5990..c179c1fb 100644
--- a/specs/executable-mdx-spec.md
+++ b/specs/executable-mdx-spec.md
@@ -1270,10 +1270,13 @@ projected caller content.
| journal serialization and replay restore | yes | no |
The two namespaces may not overlap. `service=` validates the binding
-name and checks both environments before acquiring a process. `ephemeral eval`
-validates all exports against durable and existing live names before committing
-any of them. A failed block publishes nothing. Values from the live overlay are
-never substituted into a durable effect's source and never serialized.
+name and checks both environments before acquiring a process. Ordinary durable
+eval validates its declared exports against live names before execution or
+replay restoration and before appending an eval event. `ephemeral eval`
+validates its exports against durable names before execution; it may atomically
+replace an existing live binding. A failed block publishes none of its exports.
+Values from the live overlay are never substituted into a durable effect's
+source and never serialized.
**Each evaluation runs against a snapshot, and commits its exports.** A block
receives a plain object holding the bindings as they stood when it started. Its
@@ -3833,14 +3836,22 @@ stdout:
XMD_SERVICE_READY:{"version":1,"token":"","hostname":"127.0.0.1","port":43210}
```
-The host installs its byte-level stdout observer before spawn. It suppresses
-the matching record, forwards every other stdout byte unchanged, and accepts
-readiness only when the JSON object has exactly those fields and matches the
-expected version, token, host and port. Malformed, forged, duplicate or late
-records fail the service without exposing the token or raw protocol line.
+The host installs its byte-level stdout observer before spawn. At each line
+start it retains only bytes that can still match `XMD_SERVICE_READY:`; on the
+first mismatch it forwards those bytes and all subsequent ordinary bytes
+immediately, without waiting for a newline. Only an actual protocol candidate
+is buffered, under a finite bound. The observer suppresses valid and invalid
+protocol records and accepts readiness only when the JSON object has exactly
+those fields and matches the expected version, token, host and port. Malformed,
+forged, duplicate or late records fail the service without exposing the token
+or raw protocol line.
Startup races readiness against process exit, protocol failure and the
contextual startup timeout. After readiness the host continues supervising
-process exit and duplicate records until acquisition ends.
+process exit and duplicate records until acquisition ends. An observable host
+process teardown failure becomes `ServiceTeardownError`; when execution is
+already failing, the invocation teardown aggregate preserves that execution
+failure first and keeps the service failure reachable through its teardown
+member.
The service binding is live, so only the `ephemeral eval` block can read it.
The block installs middleware in the invocation scope through `persist`; plain
@@ -6630,9 +6641,13 @@ Defined in [Workflow runs](./workflow-spec.md) §9.5–§9.6.
| R1 | Live overlay hidden from plain eval | A service binding is absent from the ordinary eval preamble |
| R2 | Live overlay hidden from interpolation | `{server}` remains literal rather than becoming an endpoint string |
| R3 | `ephemeral eval` executes during partial replay | Live bindings and middleware are reconstructed without a journal entry |
-| R4 | `when` accessible in eval block | `yield* when(fn)` retries until fn succeeds |
-| R5 | `when` retries on throw | Inner function throws twice, then succeeds → `when` resolves |
-| R6 | `when` propagates timeout | Inner function never succeeds → `when` throws after limit |
+| R4 | Service publication collision | A durable or live binding with the requested name refuses acquisition before spawn |
+| R5 | Durable export collides with a service | Live execution and partial replay both reject before execution or restoration and append no eval event |
+| R6 | Ephemeral export collides with durable state | The block is rejected before execution and publishes no partial export |
+| R7 | Ephemeral update of a live binding | A later ephemeral block may atomically replace an existing live name |
+| R8 | `when` accessible in eval block | `yield* when(fn)` retries until fn succeeds |
+| R9 | `when` retries on throw | Inner function throws twice, then succeeds → `when` resolves |
+| R10 | `when` propagates timeout | Inner function never succeeds → `when` throws after limit |
### Tier S — Provider component pattern (integration)
@@ -6641,17 +6656,21 @@ Defined in [Workflow runs](./workflow-spec.md) §9.5–§9.6.
| S1 | Full provider golden run | service → persistent ephemeral middleware → children → cleanup |
| S2 | Endpoint flows to ephemeral middleware | `server` is an exact frozen loopback endpoint available only to ephemeral eval |
| S3 | Children can call sample after protocol readiness | `sample` calls in children reach the acquired endpoint |
-| S4 | Service terminated after children expand | After `execute` completes, process is not running |
-| S5 | Process exits before readiness | Startup fails with a dedicated exit-before-ready error |
-| S6 | Provider crashes during children | Supervision failure propagates and teardown completes |
-| S7 | Nested providers | Outer + inner provider → both start, inner tears down first |
+| S4 | Cancellation after readiness | The child exits and its listener can be rebound after its owning task is halted |
+| S5 | Startup failures | Exit, timeout and invalid readiness records produce dedicated errors without leaking protocol data |
+| S6 | Provider exits during projected content | The projected request reaches the ready process, its unexpected exit fails the document, and it is not restarted |
+| S7 | Nested real providers | Outer + inner processes both start and inner teardown finishes before outer teardown |
| S8 | Nested providers, no model | Innermost provider handles sample call |
| S9 | Nested providers, explicit model matching outer | Inner passes through, outer handles |
| S10 | Nested providers, explicit model matching inner | Inner handles regardless of nesting depth |
| S11 | Unmatched model | Chain exhausted → descriptive error naming the model |
| S12 | Partial replay | Service acquisition and ephemeral middleware execute again after the recorded prefix |
| S13 | Completed replay | Completed document returns without process spawn or token allocation |
-| S14 | Multiple provider instances | Two provider siblings → two processes, distinct endpoints and tokens |
+| S14 | Concurrent service acquisitions | Two owners acquire at the same time and receive distinct live endpoints |
+| S15 | Incremental ordinary stdout | Unterminated and chunk-split ordinary bytes are forwarded before teardown, byte for byte |
+| S16 | Incremental protocol records | Split readiness is suppressed, duplicate supervision remains active, and invalid candidates are bounded and suppressed |
+| S17 | Service teardown failures | A lone observable process teardown failure becomes `ServiceTeardownError`; an active execution failure remains first in the invocation aggregate |
+| S18 | Projected failure cleanup | A prompt projected-content failure tears down both retained real services and starts neither again |
### Tier EO — eval output() function
From 010637c49d35b4ff4065e3f7518f8efd5d5db05c Mon Sep 17 00:00:00 2001
From: Taras Mankovski <74687+taras@users.noreply.github.com>
Date: Sat, 8 Aug 2026 05:24:00 -0400
Subject: [PATCH 5/6] =?UTF-8?q?=E2=9C=85=20Add=20cooperative=20ping-pong?=
=?UTF-8?q?=20smoke=20coverage?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../tests/fixtures/cooperative-service.mjs | 45 ++++-
packages/cli/tests/service-document.test.ts | 157 ++++++++++++++++++
2 files changed, 200 insertions(+), 2 deletions(-)
diff --git a/packages/cli/tests/fixtures/cooperative-service.mjs b/packages/cli/tests/fixtures/cooperative-service.mjs
index aa4089c0..74d66cf6 100644
--- a/packages/cli/tests/fixtures/cooperative-service.mjs
+++ b/packages/cli/tests/fixtures/cooperative-service.mjs
@@ -1,4 +1,4 @@
-import { createServer } from "node:http";
+import { createServer, get } from "node:http";
const [mode = "normal", nonce = "none"] = process.argv.slice(2);
const host = process.env.XMD_SERVICE_HOST;
@@ -17,8 +17,48 @@ if (mode === "non-cooperative") {
process.stdout.write("service stdout before readiness\n");
process.stderr.write("service stderr before readiness\n");
- const server = createServer((_request, response) => {
+ const server = createServer((request, response) => {
process.stderr.write(`service request:${nonce}\n`);
+
+ if (mode === "ping-pong") {
+ const requestUrl = new URL(request.url ?? "/", `http://${host}`);
+ const peerHostname = requestUrl.searchParams.get("peerHostname");
+ const peerPort = Number(requestUrl.searchParams.get("peerPort"));
+ const origin = requestUrl.searchParams.get("origin");
+
+ if (peerHostname && Number.isInteger(peerPort) && peerPort > 0 && origin) {
+ const peerRequest = get(
+ {
+ hostname: peerHostname,
+ port: peerPort,
+ path: `/?origin=${encodeURIComponent(origin)}`,
+ },
+ (peerResponse) => {
+ let peerBody = "";
+ peerResponse.setEncoding("utf8");
+ peerResponse.on("data", (chunk) => {
+ peerBody += chunk;
+ });
+ peerResponse.on("end", () => response.end(`${nonce}→${peerBody}`));
+ },
+ );
+ peerRequest.on("error", (error) => {
+ response.statusCode = 502;
+ response.end(`peer request failed: ${error.message}`);
+ });
+ return;
+ }
+
+ if (origin) {
+ response.end(`${nonce}→${origin}`);
+ return;
+ }
+
+ response.statusCode = 400;
+ response.end("missing ping-pong peer");
+ return;
+ }
+
response.end(`service:${nonce}`);
if (mode === "exit-on-request") {
setTimeout(() => process.exit(19), 10);
@@ -42,6 +82,7 @@ if (mode === "non-cooperative") {
hostname: host,
port: address.port,
};
+ process.stderr.write(`service endpoint:${nonce}:${host}:${address.port}\n`);
if (mode === "malformed") {
process.stdout.write(`XMD_SERVICE_READY:{not-json}\n`);
return;
diff --git a/packages/cli/tests/service-document.test.ts b/packages/cli/tests/service-document.test.ts
index fd75d112..a2a86322 100644
--- a/packages/cli/tests/service-document.test.ts
+++ b/packages/cli/tests/service-document.test.ts
@@ -102,6 +102,60 @@ function fixturePids(stderr: string[]): number[] {
return [...stderr.join("").matchAll(/service pid:(\d+)/g)].map((match) => Number(match[1]));
}
+function fixtureEndpoints(stderr: string[]): Array<{
+ nonce: string;
+ hostname: string;
+ port: number;
+}> {
+ return [...stderr.join("").matchAll(/service endpoint:([^:\n]+):([^:\n]+):(\d+)/g)].map(
+ (match) => {
+ const [, nonce, hostname, port] = match;
+ if (nonce === undefined || hostname === undefined || port === undefined) {
+ throw new Error("malformed cooperative-service endpoint log");
+ }
+ return { nonce, hostname, port: Number(port) };
+ },
+ );
+}
+
+function endpointAt(
+ endpoints: Array<{ nonce: string; hostname: string; port: number }>,
+ index: number,
+): { nonce: string; hostname: string; port: number } {
+ const endpoint = endpoints[index];
+ if (endpoint === undefined) {
+ throw new Error(`missing cooperative-service endpoint at index ${index}`);
+ }
+ return endpoint;
+}
+
+function expectPingPongJournal(
+ stream: InMemoryStream,
+ tokens: string[],
+ endpoints: Array<{ nonce: string; hostname: string; port: number }>,
+): void {
+ const journal = JSON.stringify(stream.snapshot());
+ expect(journal).toContain("ping→pong→ping");
+ for (const forbidden of [
+ "XMD_SERVICE_READY",
+ "service pid:",
+ "service endpoint:",
+ "service request:",
+ "service stdout",
+ "service stderr",
+ "service stopping:",
+ ]) {
+ expect(journal).not.toContain(forbidden);
+ }
+ for (const token of tokens) {
+ expect(journal).not.toContain(token);
+ }
+ for (const endpoint of endpoints) {
+ expect(journal).not.toContain(String(endpoint.port));
+ expect(journal).not.toContain(`${endpoint.hostname}:${endpoint.port}`);
+ }
+}
+
function isAlive(pid: number): boolean {
try {
process.kill(pid, 0);
@@ -185,6 +239,109 @@ describe("cooperative service document integration", () => {
});
});
+ it("keeps a two-service ping-pong chain live across partial replay", function* () {
+ const full = new InMemoryStream();
+ const stderr: string[] = [];
+ const tokens: string[] = [];
+
+ yield* scoped(function* () {
+ yield* installHostService({
+ token() {
+ const token = (tokens.length + 1).toString(16).padStart(64, "0");
+ tokens.push(token);
+ return token;
+ },
+ environment: () => inheritedEnvironment(process.env),
+ stdout() {},
+ stderr(bytes) {
+ stderr.push(new TextDecoder().decode(bytes));
+ },
+ });
+ yield* useStubFs({
+ "doc.md": "\n",
+ "components/Provider.md": `---
+meta:
+ componentName: Provider
+---
+
+\`\`\`bash service=ping exec
+${command("ping-pong", "ping")}
+\`\`\`
+
+\`\`\`bash service=pong exec
+${command("ping-pong", "pong")}
+\`\`\`
+
+\`\`\`js persist ephemeral eval
+const pingEndpoint = ping;
+const pongEndpoint = pong;
+if (pingEndpoint.port === pongEndpoint.port) {
+ throw new Error("ping and pong must have distinct endpoints");
+}
+yield* Sample.around({
+ *sample() {
+ const peerHostname = encodeURIComponent(pongEndpoint.hostname);
+ const peerPort = encodeURIComponent(String(pongEndpoint.port));
+ const response = yield* fetch(
+ \`http://\${pingEndpoint.hostname}:\${pingEndpoint.port}/?peerHostname=\${peerHostname}&peerPort=\${peerPort}&origin=ping\`,
+ ).expect();
+ return yield* response.text();
+ },
+});
+\`\`\`
+
+
+`,
+ "components/Sample.md": SAMPLE,
+ });
+
+ const first = yield* runDocument(full);
+ expect(first).toContain("ping→pong→ping");
+ expect(tokens).toHaveLength(2);
+ const firstEndpoints = fixtureEndpoints(stderr);
+ expect(firstEndpoints).toHaveLength(2);
+ expect(firstEndpoints.map(({ nonce }) => nonce)).toEqual(["ping", "pong"]);
+ const firstPing = endpointAt(firstEndpoints, 0);
+ const firstPong = endpointAt(firstEndpoints, 1);
+ expect(firstPing.port).not.toBe(firstPong.port);
+ expect(stderr.join("")).toContain("service request:ping");
+ expect(stderr.join("")).toContain("service request:pong");
+ yield* expectGone(fixturePids(stderr));
+
+ expectPingPongJournal(full, tokens, firstEndpoints);
+
+ yield* occupy(firstPing.port);
+ yield* occupy(firstPong.port);
+ const events = full.snapshot();
+ const firstYield = events.findIndex((event) => event.type === "yield");
+ const partial = new InMemoryStream(events.slice(0, firstYield + 1));
+ const resumed = yield* runDocument(partial);
+
+ expect(resumed).toBe(first);
+ expect(tokens).toHaveLength(4);
+ const allEndpoints = fixtureEndpoints(stderr);
+ expect(allEndpoints).toHaveLength(4);
+ const resumedEndpoints = allEndpoints.slice(2);
+ expect(resumedEndpoints.map(({ nonce }) => nonce)).toEqual(["ping", "pong"]);
+ const resumedPing = endpointAt(resumedEndpoints, 0);
+ const resumedPong = endpointAt(resumedEndpoints, 1);
+ expect(resumedPing.port).not.toBe(resumedPong.port);
+ expect(resumedEndpoints.map(({ port }) => port)).not.toContain(firstPing.port);
+ expect(resumedEndpoints.map(({ port }) => port)).not.toContain(firstPong.port);
+ expect(stderr.join("").match(/service request:ping/g)).toHaveLength(2);
+ expect(stderr.join("").match(/service request:pong/g)).toHaveLength(2);
+ yield* expectGone(fixturePids(stderr));
+
+ expectPingPongJournal(partial, tokens, allEndpoints);
+
+ const completed = yield* runDocument(full);
+ expect(completed).toBe(first);
+ expect(tokens).toHaveLength(4);
+ expect(fixtureEndpoints(stderr)).toHaveLength(4);
+ expect(fixturePids(stderr)).toHaveLength(4);
+ });
+ });
+
it("supervises a ready service that exits during projected content without restarting it", function* () {
const stderr: string[] = [];
let tokenCalls = 0;
From e8114ae36b1f140ae52f19063db89398f2c3993b Mon Sep 17 00:00:00 2001
From: Taras Mankovski <74687+taras@users.noreply.github.com>
Date: Sat, 8 Aug 2026 06:52:14 -0400
Subject: [PATCH 6/6] =?UTF-8?q?=E2=9C=85=20Add=20attached-service=20compil?=
=?UTF-8?q?ed=20smoke=20coverage?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/ci.yml | 7 ++
README.md | 8 +-
architecture.md | 18 ++--
packages/cli/src/service-host.ts | 74 ++++++++-------
...ative-service.mjs => attached-service.mjs} | 10 +-
packages/cli/tests/service-document.test.ts | 47 ++++++---
packages/cli/tests/service-host.test.ts | 30 +++---
packages/core/src/execute.ts | 2 +-
packages/core/src/modifiers/service.ts | 16 ++--
packages/core/tests/ephemeral-service.test.ts | 18 ++--
packages/runtime/apis.ts | 4 +-
packages/runtime/mod.ts | 4 +-
packages/runtime/service.ts | 35 +++----
packages/runtime/test/README.md | 6 +-
packages/runtime/test/mod.ts | 2 +-
packages/runtime/test/stubs.ts | 2 +-
packages/testing/tests/smoke.test.ts | 2 +-
packages/workflow/src/service-denial.ts | 4 +-
site/routes/docs/exec-eval.tsx | 9 +-
smoke-test/AttachedPingPongProvider.md | 35 +++++++
smoke-test/AttachedServiceProvider.md | 19 ++++
smoke-test/CooperativeProvider.md | 19 ----
smoke-test/Guide/Daemons.md | 16 ++--
smoke-test/Guide/Summary.md | 4 +-
smoke-test/attached-service-ping-pong.test.md | 10 ++
specs/executable-mdx-spec.md | 95 ++++++++++---------
26 files changed, 291 insertions(+), 205 deletions(-)
rename packages/cli/tests/fixtures/{cooperative-service.mjs => attached-service.mjs} (93%)
create mode 100644 smoke-test/AttachedPingPongProvider.md
create mode 100644 smoke-test/AttachedServiceProvider.md
delete mode 100644 smoke-test/CooperativeProvider.md
create mode 100644 smoke-test/attached-service-ping-pong.test.md
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 102ae2a5..3cdffcba 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -105,6 +105,13 @@ jobs:
--component-dir packages/core/components \
--raw
+ - name: Smoke test attached-service ping-pong with the compiled binary
+ run: |
+ ./dist/xmd test smoke-test/attached-service-ping-pong.test.md \
+ --component-dir smoke-test \
+ --component-dir packages/core/components \
+ --raw
+
# The script installs a second copy of core beside a repository component.
# The declaration must cross into the bundled engine so the failure prints
# and execution continues.
diff --git a/README.md b/README.md
index 008f43de..2ceeec84 100644
--- a/README.md
+++ b/README.md
@@ -169,7 +169,7 @@ Built-in modifiers:
- `persist` - keep resources created by an eval block alive for the component lifetime.
- `timeout=30s` - cancel a long-running block.
- `daemon` - start an arbitrary fixed-configuration subprocess tied to the component scope.
-- `service=name` - start a cooperative service and publish its live loopback endpoint.
+- `service=name` - start an attached service and publish its live loopback endpoint.
- `ephemeral` - reconstruct live eval state without writing a journal event.
LLM sampling is not a fence modifier — it happens through the `` component installed by provider middleware (see [Provider components](#provider-components)).
@@ -180,7 +180,7 @@ Plain `eval` blocks run in a shared durable binding environment for the current
````md
```bash service=server exec
-node cooperative-server.js
+node handshake-compatible-server.js
```
```ts persist ephemeral eval
@@ -202,7 +202,7 @@ Highlights:
- `output("...")` lets an eval block render text into the document.
- `renderChildren()` and `render(markdown)` let eval blocks render nested content intentionally.
- `ephemeral eval` reruns during live execution and partial replay, exports only invocation-local live bindings, and cannot render output.
-- Live service endpoints are available only to `ephemeral eval`; they never enter interpolation, durable effect descriptions, or the journal.
+- Attached-service endpoints are available only to `ephemeral eval`; they never enter interpolation, durable effect descriptions, or the journal.
## Provider components
@@ -213,7 +213,7 @@ The repo includes reusable markdown components (in `packages/core/components/`)
- `Sample.md`
- `Instruction.md`
-These components combine eval and `Sample` middleware so a document can talk to a cloud or already-running local model server without custom runtime wiring. A local process provider uses authenticated cooperative startup through `service=`.
+These components combine eval and `Sample` middleware so a document can talk to a cloud or already-running local model server without custom runtime wiring. A local process provider attaches a handshake-compatible command through `service=` and authenticates it with the XMD service handshake protocol.
[`packages/core/examples/hello-world.md`](packages/core/examples/hello-world.md) shows the pattern combining a cloud model (Claude) and a local model (Ollama). Provider docs currently need the built-in components on the search path:
diff --git a/architecture.md b/architecture.md
index 8c3002d8..3ca34a7e 100644
--- a/architecture.md
+++ b/architecture.md
@@ -44,7 +44,7 @@ Existing documents and code get aligned to this section retroactively.
| Workspace | the provider-neutral, run-owned environment that supplies retained filesystem, repository, process and working-directory capabilities to a workflow |
| ephemeral | a replay classification for an operation, context or attachment that runs again to reconstruct live execution; its result is not substituted from the journal and it owns no durable workflow state |
| live binding | an execution-owned value reconstructed ephemerally for the current document execution; it is visible only to constructs that explicitly consume the live binding overlay and never enters interpolation or the journal |
-| cooperative service | a scoped host process that publishes its authenticated loopback endpoint through the executable.md service-readiness protocol and remains supervised for the lifetime of its acquiring operation |
+| attached service | a scoped host process that publishes its authenticated loopback endpoint through the XMD service handshake protocol and remains supervised for the lifetime of its service attachment |
| effect transaction | the single atomic SQLite transaction that publishes one Workspace-local mutation together with that effect's journal result |
| external effect | an effect whose provider-owned outcome cannot participate in the Workspace SQLite transaction and therefore requires a stable identity and provider reconciliation |
| checkpoint | a completed journal boundary associated with the logical Workspace root visible after that effect |
@@ -587,11 +587,11 @@ execution. No middleware sees it; it is never the document's own outcome.
## Replay-safe live services
-A cooperative service belongs to the document execution that acquires it. The
-host owns port selection, process spawning, protocol authentication, startup
-supervision and teardown. Shared runtime code reaches that behavior through the
-provider-neutral `API.Service`; it never imports a host process or networking
-API.
+An attached service belongs to the document execution that attaches it. The
+host owns port selection, process spawning, XMD service handshake
+authentication, supervision and teardown. Shared runtime code reaches that
+behavior through the provider-neutral `API.Service`; it never imports a host
+process or networking API.
The service publishes a frozen loopback endpoint as a live binding. Live
bindings form an overlay on the component's durable eval bindings: `ephemeral
@@ -600,7 +600,7 @@ code-block interpolation and journal serialization read durable bindings only.
A live binding cannot shadow a durable binding, and a durable value cannot be
replaced by a live one.
-Service acquisition and `ephemeral eval` execute again during partial replay so
+Service attachment and `ephemeral eval` execute again during partial replay so
the current process and middleware chain are reconstructed. A completed
document replay returns its recorded result without expanding the document and
therefore starts no service. Workflow execution installs a non-delegating
@@ -662,8 +662,8 @@ Status is measured against main.
| `Git.revParse()` | verifies and resolves one Git revision expression contextually | built on main |
| workflow run storage | creates or compatibly finds one run by public run ID, and retains its identity, state, document executions and filtered journal | built on main |
| caller-owned storage transaction | publishes several changes, including journal events, in one transaction nothing else enlists in | built on main |
-| `API.Service` / `startService()` | acquires an authenticated, supervised loopback process as a scoped provider-neutral resource | built on main |
-| `service=` | publishes the acquired endpoint into the live binding overlay for its invocation | built on main |
+| `API.Service` / `startService()` | creates an authenticated, supervised loopback service attachment through a provider-neutral operation | built on main |
+| `service=` | publishes the attachment's endpoint into the live binding overlay for its invocation | built on main |
| `ephemeral eval` | reconstructs live middleware and bindings without a journal entry | built on main |
| workflow service denial | prevents workflow documents from inheriting an ordinary host service adapter | built on main |
| `xmd workflow start` / `xmd workflow resume` | starts or resumes a workflow run from the CLI | defined in `specs/workflow-workspace-spec.md`, unbuilt; the lookup it resumes through is built |
diff --git a/packages/cli/src/service-host.ts b/packages/cli/src/service-host.ts
index 1174461f..eda90222 100644
--- a/packages/cli/src/service-host.ts
+++ b/packages/cli/src/service-host.ts
@@ -1,4 +1,4 @@
-/** Shared mechanics for the runtime-named cooperative service adapters. */
+/** Shared mechanics for the runtime-named attached-service adapters. */
import { ensure, race, resource, scoped, withResolvers, type Operation } from "effection";
import { daemon, Stdio, type Daemon } from "@effectionx/process";
@@ -16,7 +16,11 @@ import {
parseServiceReadyRecord,
timeout,
} from "@executablemd/runtime";
-import type { ServiceEndpoint, ServiceResource, ServiceStartOptions } from "@executablemd/runtime";
+import type {
+ ServiceAttachment,
+ ServiceEndpoint,
+ ServiceStartOptions,
+} from "@executablemd/runtime";
interface HostServiceAdapter {
token(): string;
@@ -25,34 +29,34 @@ interface HostServiceAdapter {
stderr(bytes: Uint8Array): void;
}
-export interface ProtocolObserver {
+export interface HandshakeObserver {
stdout(bytes: Uint8Array): Operation;
flush(): Operation;
}
const encoder = new TextEncoder();
const prefixBytes = encoder.encode(SERVICE_READY_PREFIX);
-const MAX_PROTOCOL_RECORD_BYTES = 1_024;
+const MAX_HANDSHAKE_RECORD_BYTES = 1_024;
export function createProtocolObserver(options: {
token: string;
ready(endpoint: ServiceEndpoint): void;
fail(error: Error): void;
forward(bytes: Uint8Array): void;
-}): ProtocolObserver {
- let state: "prefix" | "ordinary" | "protocol" | "suppressed" = "prefix";
+}): HandshakeObserver {
+ let state: "prefix" | "ordinary" | "handshake" | "suppressed" = "prefix";
let possiblePrefix: number[] = [];
- let protocolRecord: number[] = [];
- let readinessSeen = false;
+ let handshakeRecord: number[] = [];
+ let handshakeSeen = false;
function beginLine(): void {
state = "prefix";
possiblePrefix = [];
- protocolRecord = [];
+ handshakeRecord = [];
}
- function consumeProtocolRecord(): void {
- if (readinessSeen) {
+ function consumeHandshakeRecord(): void {
+ if (handshakeSeen) {
options.fail(new ServiceProtocolDuplicateError());
return;
}
@@ -60,7 +64,7 @@ export function createProtocolObserver(options: {
let payload: string;
try {
payload = new TextDecoder("utf-8", { fatal: true }).decode(
- Uint8Array.from(protocolRecord.slice(prefixBytes.byteLength)),
+ Uint8Array.from(handshakeRecord.slice(prefixBytes.byteLength)),
);
} catch {
options.fail(new ServiceProtocolMalformedError());
@@ -69,22 +73,22 @@ export function createProtocolObserver(options: {
try {
const endpoint = parseServiceReadyRecord(payload, options.token);
- readinessSeen = true;
+ handshakeSeen = true;
options.ready(endpoint);
} catch (error) {
options.fail(error instanceof Error ? error : new ServiceProtocolMalformedError());
}
}
- function appendProtocol(bytes: Uint8Array, start: number, end: number): boolean {
- if (protocolRecord.length + end - start > MAX_PROTOCOL_RECORD_BYTES) {
- protocolRecord = [];
+ function appendHandshake(bytes: Uint8Array, start: number, end: number): boolean {
+ if (handshakeRecord.length + end - start > MAX_HANDSHAKE_RECORD_BYTES) {
+ handshakeRecord = [];
state = "suppressed";
options.fail(new ServiceProtocolMalformedError());
return false;
}
for (let index = start; index < end; index += 1) {
- protocolRecord.push(bytes[index]!);
+ handshakeRecord.push(bytes[index]!);
}
return true;
}
@@ -99,9 +103,9 @@ export function createProtocolObserver(options: {
possiblePrefix.push(byte);
index += 1;
if (possiblePrefix.length === prefixBytes.byteLength) {
- protocolRecord = [...possiblePrefix];
+ handshakeRecord = [...possiblePrefix];
possiblePrefix = [];
- state = "protocol";
+ state = "handshake";
}
continue;
}
@@ -127,17 +131,17 @@ export function createProtocolObserver(options: {
continue;
}
- if (state === "protocol") {
+ if (state === "handshake") {
const newline = bytes.indexOf(10, index);
const end = newline === -1 ? bytes.byteLength : newline;
- if (!appendProtocol(bytes, index, end)) {
+ if (!appendHandshake(bytes, index, end)) {
index = end;
continue;
}
if (newline === -1) {
index = bytes.byteLength;
} else {
- consumeProtocolRecord();
+ consumeHandshakeRecord();
index = newline + 1;
beginLine();
}
@@ -156,7 +160,7 @@ export function createProtocolObserver(options: {
*flush(): Operation {
if (state === "prefix" && possiblePrefix.length > 0) {
options.forward(Uint8Array.from(possiblePrefix));
- } else if (state === "protocol") {
+ } else if (state === "handshake") {
beginLine();
throw new ServiceProtocolMalformedError();
}
@@ -167,7 +171,7 @@ export function createProtocolObserver(options: {
function validTimeout(value: number): number {
if (!Number.isFinite(value) || value <= 0) {
- throw new Error("service startup timeout must be a positive finite number");
+ throw new Error("attached service startup timeout must be a positive finite number");
}
return value;
}
@@ -182,11 +186,11 @@ function exitFacts(status: { code?: number; signal?: string }): {
};
}
-function* waitForStartup(options: {
+function* waitForHandshake(options: {
ready: Operation;
- protocolFailure: Operation;
+ handshakeFailure: Operation;
process: { join(): Operation<{ code?: number; signal?: string }> };
- observer: ProtocolObserver;
+ observer: HandshakeObserver;
startupTimeout: number;
}): Operation {
const result = yield* timebox(options.startupTimeout, () =>
@@ -198,7 +202,7 @@ function* waitForStartup(options: {
throw new ServiceProcessExitBeforeReadyError(exitFacts(status));
})(),
(function* (): Operation {
- return yield* options.protocolFailure;
+ return yield* options.handshakeFailure;
})(),
]),
);
@@ -237,19 +241,19 @@ function serviceProcess(options: {
function startHostService(
options: ServiceStartOptions,
adapter: HostServiceAdapter,
-): Operation {
+): Operation {
return resource(function* (provide) {
const token = adapter.token();
if (!/^[0-9a-f]{64}$/.test(token)) {
- throw new Error("host service adapter returned an invalid authentication token");
+ throw new Error("attached service host returned an invalid handshake token");
}
const startupTimeout = validTimeout(options.startupTimeout ?? (yield* timeout));
const ready = withResolvers();
- const protocolFailure = withResolvers();
+ const handshakeFailure = withResolvers();
const observer = createProtocolObserver({
token,
ready: ready.resolve,
- fail: protocolFailure.reject,
+ fail: handshakeFailure.reject,
forward: adapter.stdout,
});
@@ -278,9 +282,9 @@ function startHostService(
cwd: options.cwd,
environment,
});
- const endpoint = yield* waitForStartup({
+ const endpoint = yield* waitForHandshake({
ready: ready.operation,
- protocolFailure: protocolFailure.operation,
+ handshakeFailure: handshakeFailure.operation,
process,
observer,
startupTimeout,
@@ -293,7 +297,7 @@ function startHostService(
yield* observer.flush();
throw new ServiceUnexpectedExitError(exitFacts(status));
})(),
- protocolFailure.operation,
+ handshakeFailure.operation,
]);
});
}
diff --git a/packages/cli/tests/fixtures/cooperative-service.mjs b/packages/cli/tests/fixtures/attached-service.mjs
similarity index 93%
rename from packages/cli/tests/fixtures/cooperative-service.mjs
rename to packages/cli/tests/fixtures/attached-service.mjs
index 74d66cf6..1a28f91a 100644
--- a/packages/cli/tests/fixtures/cooperative-service.mjs
+++ b/packages/cli/tests/fixtures/attached-service.mjs
@@ -11,11 +11,11 @@ if (mode === "exit-before") {
process.exit(17);
}
-if (mode === "non-cooperative") {
+if (mode === "not-handshake-compatible") {
setInterval(() => {}, 1_000);
} else {
- process.stdout.write("service stdout before readiness\n");
- process.stderr.write("service stderr before readiness\n");
+ process.stdout.write("service stdout before handshake\n");
+ process.stderr.write("service stderr before handshake\n");
const server = createServer((request, response) => {
process.stderr.write(`service request:${nonce}\n`);
@@ -121,8 +121,8 @@ if (mode === "non-cooperative") {
const line = `XMD_SERVICE_READY:${JSON.stringify(ready)}\n`;
process.stdout.write(line);
- process.stdout.write("service stdout after readiness\n");
- process.stderr.write("service stderr after readiness\n");
+ process.stdout.write("service stdout after handshake\n");
+ process.stderr.write("service stderr after handshake\n");
if (mode === "unterminated-live-output") {
process.stdout.write("unterminated-live-output");
diff --git a/packages/cli/tests/service-document.test.ts b/packages/cli/tests/service-document.test.ts
index a2a86322..2c6c6e4d 100644
--- a/packages/cli/tests/service-document.test.ts
+++ b/packages/cli/tests/service-document.test.ts
@@ -12,7 +12,7 @@ import { SERVICE_HOSTNAME, ServiceUnexpectedExitError } from "@executablemd/runt
import { useStubFs } from "@executablemd/runtime/test";
import { inheritedEnvironment, installHostService } from "../src/service-host.ts";
-const fixture = new URL("./fixtures/cooperative-service.mjs", import.meta.url).pathname;
+const fixture = new URL("./fixtures/attached-service.mjs", import.meta.url).pathname;
function command(mode: string, nonce: string): string {
return `node ${JSON.stringify(fixture)} ${mode} ${nonce}`;
@@ -111,7 +111,7 @@ function fixtureEndpoints(stderr: string[]): Array<{
(match) => {
const [, nonce, hostname, port] = match;
if (nonce === undefined || hostname === undefined || port === undefined) {
- throw new Error("malformed cooperative-service endpoint log");
+ throw new Error("malformed attached-service endpoint log");
}
return { nonce, hostname, port: Number(port) };
},
@@ -124,11 +124,32 @@ function endpointAt(
): { nonce: string; hostname: string; port: number } {
const endpoint = endpoints[index];
if (endpoint === undefined) {
- throw new Error(`missing cooperative-service endpoint at index ${index}`);
+ throw new Error(`missing attached-service endpoint at index ${index}`);
}
return endpoint;
}
+function containsEndpoint(value: unknown, endpoint: { hostname: string; port: number }): boolean {
+ if (typeof value === "string") {
+ return value.includes(`${endpoint.hostname}:${endpoint.port}`);
+ }
+ if (Array.isArray(value)) {
+ return value.some((member) => containsEndpoint(member, endpoint));
+ }
+ if (typeof value !== "object" || value === null) {
+ return false;
+ }
+ if (
+ "hostname" in value &&
+ "port" in value &&
+ value.hostname === endpoint.hostname &&
+ value.port === endpoint.port
+ ) {
+ return true;
+ }
+ return Object.values(value).some((member) => containsEndpoint(member, endpoint));
+}
+
function expectPingPongJournal(
stream: InMemoryStream,
tokens: string[],
@@ -151,8 +172,7 @@ function expectPingPongJournal(
expect(journal).not.toContain(token);
}
for (const endpoint of endpoints) {
- expect(journal).not.toContain(String(endpoint.port));
- expect(journal).not.toContain(`${endpoint.hostname}:${endpoint.port}`);
+ expect(containsEndpoint(stream.snapshot(), endpoint)).toBe(false);
}
}
@@ -189,10 +209,10 @@ function hasUnexpectedExit(error: unknown): boolean {
return error instanceof Error && error.cause !== undefined && hasUnexpectedExit(error.cause);
}
-describe("cooperative service document integration", () => {
+describe("attached service document integration", () => {
beforeAll(() => useTempFileCompiler());
- it("reconstructs a real service on partial replay and skips completed replay", function* () {
+ it("reconstructs a real attached service on partial replay and skips completed replay", function* () {
const full = new InMemoryStream();
let tokenCalls = 0;
@@ -239,7 +259,7 @@ describe("cooperative service document integration", () => {
});
});
- it("keeps a two-service ping-pong chain live across partial replay", function* () {
+ it("keeps a two-attachment ping-pong chain live across partial replay", function* () {
const full = new InMemoryStream();
const stderr: string[] = [];
const tokens: string[] = [];
@@ -275,7 +295,10 @@ ${command("ping-pong", "pong")}
\`\`\`js persist ephemeral eval
const pingEndpoint = ping;
const pongEndpoint = pong;
-if (pingEndpoint.port === pongEndpoint.port) {
+if (
+ pingEndpoint.hostname === pongEndpoint.hostname &&
+ pingEndpoint.port === pongEndpoint.port
+) {
throw new Error("ping and pong must have distinct endpoints");
}
yield* Sample.around({
@@ -342,7 +365,7 @@ yield* Sample.around({
});
});
- it("supervises a ready service that exits during projected content without restarting it", function* () {
+ it("supervises an attached service that exits during projected content without restarting it", function* () {
const stderr: string[] = [];
let tokenCalls = 0;
yield* scoped(function* () {
@@ -399,7 +422,7 @@ yield* Sample.around({
yield* expectGone(fixturePids(stderr));
});
- it("fails projected content promptly, tears down retained services, and does not restart", function* () {
+ it("fails projected content promptly, tears down retained service attachments, and does not restart", function* () {
const stderr: string[] = [];
let tokenCalls = 0;
yield* scoped(function* () {
@@ -467,7 +490,7 @@ throw new Error("projected content failure");
yield* expectGone(pids);
});
- it("tears down nested provider services from inner lifetime to outer lifetime", function* () {
+ it("tears down nested service attachments from inner lifetime to outer lifetime", function* () {
const stderr: string[] = [];
let tokenCalls = 0;
yield* scoped(function* () {
diff --git a/packages/cli/tests/service-host.test.ts b/packages/cli/tests/service-host.test.ts
index 921d6954..612380bb 100644
--- a/packages/cli/tests/service-host.test.ts
+++ b/packages/cli/tests/service-host.test.ts
@@ -37,7 +37,7 @@ import { createServer } from "node:http";
import process from "node:process";
const TOKEN = "12".repeat(32);
-const fixture = new URL("./fixtures/cooperative-service.mjs", import.meta.url).pathname;
+const fixture = new URL("./fixtures/attached-service.mjs", import.meta.url).pathname;
function command(mode: string, nonce = "nonce"): string {
return `node ${JSON.stringify(fixture)} ${mode} ${nonce}`;
@@ -146,8 +146,8 @@ function* useTeardownFailure(failure: Error): Operation {
});
}
-describe("cooperative host service adapter", () => {
- it("forwards split ordinary bytes immediately and suppresses split protocol records", function* () {
+describe("attached service host adapter", () => {
+ it("forwards split ordinary bytes immediately and suppresses split handshake records", function* () {
const forwarded: number[] = [];
const endpoints: Array<{ hostname: string; port: number }> = [];
const failures: Error[] = [];
@@ -187,7 +187,7 @@ describe("cooperative host service adapter", () => {
expect(forwarded).toEqual([10]);
});
- it("bounds and suppresses an invalid protocol candidate", function* () {
+ it("bounds and suppresses an invalid handshake candidate", function* () {
const forwarded: number[] = [];
const failures: Error[] = [];
const observer = createProtocolObserver({
@@ -207,7 +207,7 @@ describe("cooperative host service adapter", () => {
expect(String(failures[0])).not.toContain(TOKEN);
});
- it("starts genuinely concurrent isolated services and suppresses readiness", function* () {
+ it("starts genuinely concurrent service attachments and suppresses handshake records", function* () {
const stdout: string[] = [];
const stderr: string[] = [];
@@ -249,17 +249,17 @@ describe("cooperative host service adapter", () => {
yield* secondOwner;
});
- expect(stdout.join("")).toContain("service stdout before readiness");
- expect(stdout.join("")).toContain("service stdout after readiness");
- expect(stderr.join("")).toContain("service stderr before readiness");
- expect(stderr.join("")).toContain("service stderr after readiness");
+ expect(stdout.join("")).toContain("service stdout before handshake");
+ expect(stdout.join("")).toContain("service stdout after handshake");
+ expect(stderr.join("")).toContain("service stderr before handshake");
+ expect(stderr.join("")).toContain("service stderr after handshake");
expect(stdout.join("")).not.toContain("XMD_SERVICE_READY");
expect(stdout.join("")).not.toContain(TOKEN);
expect(fixturePids(stderr)).toHaveLength(2);
yield* expectGone(fixturePids(stderr));
});
- it("categorizes startup failures without exposing protocol records", function* () {
+ it("categorizes startup failures without exposing handshake records", function* () {
const cases: Array<[string, { prototype: Error }, number]> = [
["exit-before", ServiceProcessExitBeforeReadyError, 2_000],
["malformed", ServiceProtocolMalformedError, 2_000],
@@ -269,7 +269,7 @@ describe("cooperative host service adapter", () => {
["wrong-host", ServiceProtocolHostnameMismatchError, 2_000],
["extra-member", ServiceProtocolMalformedError, 2_000],
["partial-record", ServiceProtocolMalformedError, 2_000],
- ["non-cooperative", ServiceStartupTimeoutError, 75],
+ ["not-handshake-compatible", ServiceStartupTimeoutError, 75],
];
for (const [mode, ErrorType, startupTimeout] of cases) {
@@ -293,7 +293,7 @@ describe("cooperative host service adapter", () => {
}
});
- it("cancels startup and releases the child before readiness", function* () {
+ it("cancels startup and releases the child before the handshake", function* () {
const pidPublished = withResolvers();
const decoder = new TextDecoder();
let pid = 0;
@@ -318,7 +318,7 @@ describe("cooperative host service adapter", () => {
yield* expectGone([pid]);
});
- it("cancels after readiness and releases the child listener", function* () {
+ it("cancels after the handshake and releases the child listener", function* () {
const stderr: string[] = [];
const ready = withResolvers<{ hostname: string; port: number }>();
let endpoint = { hostname: "127.0.0.1", port: 0 };
@@ -343,7 +343,7 @@ describe("cooperative host service adapter", () => {
});
});
- it("forwards unterminated ordinary stdout while the service is still active", function* () {
+ it("forwards unterminated ordinary stdout while the attached service is active", function* () {
const stdout: string[] = [];
const stderr: string[] = [];
@@ -418,7 +418,7 @@ describe("cooperative host service adapter", () => {
expect(serviceTeardown.cause).toBe(teardown);
});
- it("fails the owning scope when a ready process exits or repeats readiness", function* () {
+ it("fails the owning scope when an attached process exits or repeats the handshake", function* () {
const cases: Array<[string, { prototype: Error }]> = [
["exit-after", ServiceUnexpectedExitError],
["duplicate", ServiceProtocolDuplicateError],
diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts
index 81936880..96037b22 100644
--- a/packages/core/src/execute.ts
+++ b/packages/core/src/execute.ts
@@ -811,7 +811,7 @@ function* executeDocument(options: ExecuteOptions): Operation
},
});
- // The discard provider is the base so a cooperative-service observer can
+ // The discard provider is the base so an attached-service observer can
// authenticate and forward its own process output without unsilencing
// unrelated document subprocesses.
yield* Stdio.around(
diff --git a/packages/core/src/modifiers/service.ts b/packages/core/src/modifiers/service.ts
index 142c3f8b..5bb8196f 100644
--- a/packages/core/src/modifiers/service.ts
+++ b/packages/core/src/modifiers/service.ts
@@ -1,4 +1,4 @@
-/** Cooperative service terminal modifier. */
+/** Attached service terminal modifier. */
import { ephemeral } from "@executablemd/durable-streams";
import { unbox } from "@effectionx/scope-eval";
@@ -18,27 +18,29 @@ export const serviceFactory: ModifierFactory = (params) => (_args, _next) =>
*[Symbol.iterator]() {
const durable = yield* env;
if (!durable) {
- throw new Error("service requires a component binding environment; none is in scope.");
+ throw new Error(
+ "attached service requires a component binding environment; none is in scope.",
+ );
}
const live = liveEnvironment(durable);
const binding = validateServiceBinding(params, durable, live);
const scope = yield* evalScope;
if (!scope) {
- throw new Error("service requires a component eval scope; none is in scope.");
+ throw new Error("attached service requires a component eval scope; none is in scope.");
}
const directory = yield* cwd();
const startupTimeout = yield* timeout;
- const acquired = yield* scope.eval(function* () {
- const service = yield* startService({
+ const attachment = yield* scope.eval(function* () {
+ const serviceAttachment = yield* startService({
command: ctx.content,
cwd: directory,
startupTimeout,
});
- return service.endpoint;
+ return serviceAttachment.endpoint;
});
- live.values[binding] = unbox(acquired);
+ live.values[binding] = unbox(attachment);
return { output: "", exitCode: 0, stderr: "" };
},
};
diff --git a/packages/core/tests/ephemeral-service.test.ts b/packages/core/tests/ephemeral-service.test.ts
index d6859b29..ba40e846 100644
--- a/packages/core/tests/ephemeral-service.test.ts
+++ b/packages/core/tests/ephemeral-service.test.ts
@@ -49,7 +49,7 @@ function* useServiceStub(
);
}
-describe("ephemeral eval and service bindings", () => {
+describe("ephemeral eval and attached-service bindings", () => {
beforeAll(() => useTempFileCompiler());
it("reconstructs live bindings without exposing them to durable consumers", function* () {
@@ -102,7 +102,7 @@ yield* Sample.around({
expect(evalEvents).toHaveLength(2);
});
- it("publishes the exact frozen service endpoint only to ephemeral eval", function* () {
+ it("publishes the exact frozen attached-service endpoint only to ephemeral eval", function* () {
const endpoint = Object.freeze({ hostname: SERVICE_HOSTNAME, port: 43_210 });
const lifecycle = { starts: 0, stops: 0 };
const stream = new InMemoryStream();
@@ -124,7 +124,7 @@ meta:
---
\`\`\`bash service=server exec
-cooperative-server
+handshake-compatible-server
\`\`\`
endpoint:{server.port}
@@ -164,13 +164,13 @@ yield* Sample.around({
).toBe(false);
});
- it("completed replay starts neither services nor ephemeral eval", function* () {
+ it("completed replay starts neither attached services nor ephemeral eval", function* () {
const endpoint = Object.freeze({ hostname: SERVICE_HOSTNAME, port: 41_001 });
const lifecycle = { starts: 0, stops: 0 };
const stream = new InMemoryStream();
const files = {
"doc.md": `\`\`\`bash service=server exec
-cooperative-server
+handshake-compatible-server
\`\`\`
\`\`\`js ephemeral eval
@@ -229,7 +229,7 @@ done
}
});
- it("validates service binding collisions before spawning", function* () {
+ it("validates attached-service binding collisions before spawning", function* () {
const cases: Array<[string, string, number]> = [
["service", "requires a binding name", 0],
["service=bad-name", "must be a valid JavaScript identifier", 0],
@@ -275,7 +275,7 @@ done
"doc.md": `
\`\`\`bash service=server exec
-cooperative-server
+handshake-compatible-server
\`\`\`
\`\`\`js eval
@@ -314,7 +314,7 @@ const server = "must-not-execute";
"doc.md": `
\`\`\`bash service=other exec
-cooperative-server
+handshake-compatible-server
\`\`\`
\`\`\`js eval
@@ -351,7 +351,7 @@ tail
"doc.md": `
\`\`\`bash service=server exec
-cooperative-server
+handshake-compatible-server
\`\`\`
\`\`\`js eval
diff --git a/packages/runtime/apis.ts b/packages/runtime/apis.ts
index 1dfd263a..2e02f311 100644
--- a/packages/runtime/apis.ts
+++ b/packages/runtime/apis.ts
@@ -42,8 +42,8 @@
* use `.around()` to mock platform/env for deterministic replay; an
* entrypoint installs its `command` and `compile` with `{ at: "min" }` so
* ordinary middleware can wrap them.
- * - **Service** — scoped cooperative-service acquisition. Its terminal handler
- * requires an explicit host provider and never detects or imports a runtime.
+ * - **Service** — scoped service attachment. Its terminal handler requires an
+ * explicit host provider and never detects or imports a runtime.
*
* ## Middleware
*
diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts
index ebe07fd4..8e468bee 100644
--- a/packages/runtime/mod.ts
+++ b/packages/runtime/mod.ts
@@ -13,7 +13,7 @@
* - `API.Env` — the host: variables, platform info, the command that invokes
* this xmd, and eval-block compilation
* (`cwd`, `env`, `platform`, `command`, `compile`)
- * - `API.Service` — scoped cooperative service startup (`startService`)
+ * - `API.Service` — scoped attached service startup (`startService`)
* - `Config` — shared execution config (`timeout`)
*
* See `apis.ts` for architecture rationale.
@@ -59,7 +59,7 @@ export {
export type {
ServiceEndpoint,
ServiceHandler,
- ServiceResource,
+ ServiceAttachment,
ServiceStartOptions,
} from "./service.ts";
export { Config, timeout } from "./config.ts";
diff --git a/packages/runtime/service.ts b/packages/runtime/service.ts
index 2a6891fe..8011166b 100644
--- a/packages/runtime/service.ts
+++ b/packages/runtime/service.ts
@@ -1,8 +1,9 @@
/**
- * Provider-neutral cooperative service lifecycle.
+ * Provider-neutral attached-service lifecycle.
*
- * The shared runtime owns the protocol shape and validation. A runtime-named
- * host adapter supplies process startup through `API.Service` middleware.
+ * The shared runtime owns the XMD service handshake shape and validation. A
+ * runtime-named host adapter supplies process startup through `API.Service`
+ * middleware.
*/
import { type Api, createApi, type Operations } from "@effectionx/context-api";
@@ -22,12 +23,12 @@ export interface ServiceStartOptions {
readonly startupTimeout?: number;
}
-export interface ServiceResource {
+export interface ServiceAttachment {
readonly endpoint: Readonly;
}
export interface ServiceHandler {
- start(options: ServiceStartOptions): Operation;
+ start(options: ServiceStartOptions): Operation;
}
export class ServiceProviderError extends Error {
@@ -35,7 +36,7 @@ export class ServiceProviderError extends Error {
constructor() {
super(
- "service startup requires a host provider; install runtime.service middleware before execution",
+ "attached service startup requires a host provider; install runtime.service middleware before execution",
);
}
}
@@ -44,7 +45,7 @@ export class ServiceProtocolMalformedError extends Error {
override name = "ServiceProtocolMalformedError";
constructor() {
- super("service emitted a malformed cooperative readiness record");
+ super("attached service emitted a malformed XMD service handshake record");
}
}
@@ -52,7 +53,7 @@ export class ServiceProtocolIncompatibleError extends Error {
override name = "ServiceProtocolIncompatibleError";
constructor() {
- super("service emitted an incompatible cooperative readiness record");
+ super("attached service emitted an incompatible XMD service handshake record");
}
}
@@ -60,7 +61,7 @@ export class ServiceProtocolTokenMismatchError extends Error {
override name = "ServiceProtocolTokenMismatchError";
constructor() {
- super("service readiness authentication failed");
+ super("XMD service handshake authentication failed");
}
}
@@ -68,7 +69,7 @@ export class ServiceProtocolHostnameMismatchError extends Error {
override name = "ServiceProtocolHostnameMismatchError";
constructor() {
- super("service readiness hostname is not authorized");
+ super("XMD service handshake hostname is not authorized");
}
}
@@ -76,7 +77,7 @@ export class ServiceProtocolDuplicateError extends Error {
override name = "ServiceProtocolDuplicateError";
constructor() {
- super("service emitted more than one cooperative readiness record");
+ super("attached service emitted more than one XMD service handshake record");
}
}
@@ -84,7 +85,7 @@ export class ServiceStartupTimeoutError extends Error {
override name = "ServiceStartupTimeoutError";
constructor(timeout: number) {
- super(`service did not become ready within ${timeout}ms`);
+ super(`attached service handshake did not complete within ${timeout}ms`);
}
}
@@ -107,7 +108,7 @@ export class ServiceProcessExitBeforeReadyError extends Error {
override name = "ServiceProcessExitBeforeReadyError";
constructor(status: ServiceExitStatus) {
- super(`service process exited before readiness with ${exitDescription(status)}`);
+ super(`attached service process exited before handshake with ${exitDescription(status)}`);
}
}
@@ -115,7 +116,7 @@ export class ServiceUnexpectedExitError extends Error {
override name = "ServiceUnexpectedExitError";
constructor(status: ServiceExitStatus) {
- super(`service process exited after readiness with ${exitDescription(status)}`);
+ super(`attached service process exited after handshake with ${exitDescription(status)}`);
}
}
@@ -123,7 +124,7 @@ export class ServiceTeardownError extends Error {
override name = "ServiceTeardownError";
constructor(options?: { cause?: unknown }) {
- super("service process failed to terminate cleanly", options);
+ super("attached service process failed to terminate cleanly", options);
}
}
@@ -142,7 +143,7 @@ function hasExactMembers(record: Record): boolean {
);
}
-/** Parse and authenticate one prefix-stripped v1 readiness payload. */
+/** Parse and authenticate one prefix-stripped v1 handshake payload. */
export function parseServiceReadyRecord(payload: string, expectedToken: string): ServiceEndpoint {
let parsed: unknown;
try {
@@ -177,7 +178,7 @@ export function parseServiceReadyRecord(payload: string, expectedToken: string):
export const Service: Api = createApi("runtime.service", {
// deno-lint-ignore require-yield
- *start(_options: ServiceStartOptions): Operation {
+ *start(_options: ServiceStartOptions): Operation {
throw new ServiceProviderError();
},
});
diff --git a/packages/runtime/test/README.md b/packages/runtime/test/README.md
index 41fcef0b..484ddbd3 100644
--- a/packages/runtime/test/README.md
+++ b/packages/runtime/test/README.md
@@ -12,7 +12,7 @@ Use these helpers when a test needs:
- an in-memory filesystem instead of real files
- a simple `exec` stub for `echo`-style command output
- a predictable failing `exec` for error-path assertions
-- a scoped cooperative-service endpoint without a real host process
+- a scoped service attachment without a real host process
Use raw `API.*.around()` directly when a test needs custom behavior that the
shared helpers do not provide.
@@ -81,8 +81,8 @@ yield * useFailingExec(127, "command not found");
Installs a provider-neutral scoped `API.Service` stub. It reconstructs an exact
frozen loopback endpoint and rejects any non-loopback hostname or invalid port.
-Use the production host adapters when testing protocol, process, output or
-teardown behavior.
+Use the production host adapters when testing the XMD service handshake,
+process, output or teardown behavior.
## Composition
diff --git a/packages/runtime/test/mod.ts b/packages/runtime/test/mod.ts
index 98a3361b..a6610dc5 100644
--- a/packages/runtime/test/mod.ts
+++ b/packages/runtime/test/mod.ts
@@ -6,7 +6,7 @@
* - `useStubFs(files)` — in-memory filesystem
* - `useEchoExec()` — simple echo-based exec
* - `useFailingExec(exitCode, stderr)` — always-failing exec
- * - `useStubService(endpoint)` — scoped provider-neutral service endpoint
+ * - `useStubService(endpoint)` — scoped provider-neutral service attachment
*/
export { useStubFs, useEchoExec, useFailingExec, useStubService } from "./stubs.ts";
diff --git a/packages/runtime/test/stubs.ts b/packages/runtime/test/stubs.ts
index 807880db..2048d1d2 100644
--- a/packages/runtime/test/stubs.ts
+++ b/packages/runtime/test/stubs.ts
@@ -26,7 +26,7 @@ import type { StatResult } from "../apis.ts";
import { SERVICE_HOSTNAME } from "../service.ts";
import type { ServiceEndpoint } from "../service.ts";
-/** Install a provider-neutral scoped service endpoint stub. */
+/** Install a provider-neutral scoped service attachment stub. */
export function* useStubService(endpoint: ServiceEndpoint): Operation {
if (endpoint.hostname !== SERVICE_HOSTNAME) {
throw new Error("stub service endpoint must use 127.0.0.1");
diff --git a/packages/testing/tests/smoke.test.ts b/packages/testing/tests/smoke.test.ts
index 7b788d9a..fa73f971 100644
--- a/packages/testing/tests/smoke.test.ts
+++ b/packages/testing/tests/smoke.test.ts
@@ -53,7 +53,7 @@ const EMBEDDED_TESTS = [
"Eval bindings interpolate into exec blocks",
"Ephemeral eval reconstructs live bindings without rendering",
"A daemon stays alive until its scope closes",
- "A cooperative service publishes a scoped live endpoint",
+ "An attached service publishes a scoped live endpoint",
"A standalone Thing's resource outlives it",
"An empty paired Thing renders nothing and keeps nothing",
"A paired Thing's resource is live only while its content expands",
diff --git a/packages/workflow/src/service-denial.ts b/packages/workflow/src/service-denial.ts
index 81201ebb..335cbee0 100644
--- a/packages/workflow/src/service-denial.ts
+++ b/packages/workflow/src/service-denial.ts
@@ -1,4 +1,4 @@
-/** Workflow authority boundary for native service startup. */
+/** Workflow authority boundary for native service attachment. */
import type { Operation } from "effection";
import { API } from "@executablemd/runtime";
@@ -7,7 +7,7 @@ export class WorkflowServiceDeniedError extends Error {
override name = "WorkflowServiceDeniedError";
constructor() {
- super("workflow execution is not authorized to start a native service");
+ super("workflow execution is not authorized to attach a native service");
}
}
diff --git a/site/routes/docs/exec-eval.tsx b/site/routes/docs/exec-eval.tsx
index 5d99b639..1c8e5a83 100644
--- a/site/routes/docs/exec-eval.tsx
+++ b/site/routes/docs/exec-eval.tsx
@@ -5,7 +5,7 @@ import { NextCard } from "../../components/NextCard.tsx";
const CHAIN = "```bash silent timeout=30s exec\ngit diff --stat\n```";
const EVAL = `\`\`\`bash service=server exec
-node cooperative-server.js
+node handshake-compatible-server.js
\`\`\`
\`\`\`ts persist ephemeral eval
@@ -57,8 +57,7 @@ export default define.page(function ExecEval() {
service=name{" "}
- — start a cooperative service and publish its invocation-local
- endpoint.
+ — start an attached service and publish its invocation-local endpoint.
ephemeral{" "}
@@ -106,7 +105,9 @@ export default define.page(function ExecEval() {
down by structured concurrency when the component invocation completes —
no manual cleanup. It remains the primitive for processes whose fixed
configuration the document or host manages explicitly. Dynamic service
- endpoints use the authenticated service=name protocol.
+ endpoints use service=name{" "}
+ with a handshake-compatible command and the XMD service handshake
+ protocol.
diff --git a/smoke-test/AttachedPingPongProvider.md b/smoke-test/AttachedPingPongProvider.md
new file mode 100644
index 00000000..205519a8
--- /dev/null
+++ b/smoke-test/AttachedPingPongProvider.md
@@ -0,0 +1,35 @@
+---
+meta:
+ componentName: AttachedPingPongProvider
+---
+
+```bash service=ping exec
+node packages/cli/tests/fixtures/attached-service.mjs ping-pong ping
+```
+
+```bash service=pong exec
+node packages/cli/tests/fixtures/attached-service.mjs ping-pong pong
+```
+
+```js persist ephemeral eval
+const pingEndpoint = ping;
+const pongEndpoint = pong;
+if (
+ pingEndpoint.hostname === pongEndpoint.hostname &&
+ pingEndpoint.port === pongEndpoint.port
+) {
+ throw new Error("ping and pong attachments must have distinct endpoints");
+}
+yield* Sample.around({
+ *sample() {
+ const peerHostname = encodeURIComponent(pongEndpoint.hostname);
+ const peerPort = encodeURIComponent(String(pongEndpoint.port));
+ const response = yield* fetch(
+ `http://${pingEndpoint.hostname}:${pingEndpoint.port}/?peerHostname=${peerHostname}&peerPort=${peerPort}&origin=ping`,
+ ).expect();
+ return yield* response.text();
+ },
+});
+```
+
+
diff --git a/smoke-test/AttachedServiceProvider.md b/smoke-test/AttachedServiceProvider.md
new file mode 100644
index 00000000..7d3d36e9
--- /dev/null
+++ b/smoke-test/AttachedServiceProvider.md
@@ -0,0 +1,19 @@
+---
+meta:
+ componentName: AttachedServiceProvider
+---
+
+```bash service=server exec
+node packages/cli/tests/fixtures/attached-service.mjs normal smoke
+```
+
+```js persist ephemeral eval
+const endpoint = server;
+yield* Sample.around({
+ *sample() {
+ return `attached:${endpoint.hostname}:${Object.isFrozen(endpoint)}`;
+ },
+});
+```
+
+
diff --git a/smoke-test/CooperativeProvider.md b/smoke-test/CooperativeProvider.md
deleted file mode 100644
index 83f7140c..00000000
--- a/smoke-test/CooperativeProvider.md
+++ /dev/null
@@ -1,19 +0,0 @@
----
-meta:
- componentName: CooperativeProvider
----
-
-```bash service=server exec
-node packages/cli/tests/fixtures/cooperative-service.mjs normal smoke
-```
-
-```js persist ephemeral eval
-const endpoint = server;
-yield* Sample.around({
- *sample() {
- return `cooperative:${endpoint.hostname}:${Object.isFrozen(endpoint)}`;
- },
-});
-```
-
-
diff --git a/smoke-test/Guide/Daemons.md b/smoke-test/Guide/Daemons.md
index 1bd61e70..5c56b389 100644
--- a/smoke-test/Guide/Daemons.md
+++ b/smoke-test/Guide/Daemons.md
@@ -3,8 +3,8 @@
The `daemon` modifier starts an arbitrary long-running process with configuration
the document or host already owns. The test below starts a process that remains
alive while the next block runs. When the test's scope closes, structured
-concurrency terminates the daemon with no manual cleanup. Cooperative dynamic
-services use `service=` instead.
+concurrency terminates the daemon with no manual cleanup. Attached services use
+`service=` instead.
@@ -20,14 +20,14 @@ echo daemon-ok
-
-
-
+
+
+
-
+
diff --git a/smoke-test/Guide/Summary.md b/smoke-test/Guide/Summary.md
index f823d142..14ce853c 100644
--- a/smoke-test/Guide/Summary.md
+++ b/smoke-test/Guide/Summary.md
@@ -35,8 +35,8 @@ cat <<'TABLE'
| eval binding interpolation| {label} in exec block from eval binding |
| daemon modifier | bash daemon exec starts background proc |
| daemon fixed configuration| Arbitrary process stays scope-owned |
-| service modifier | Cooperative process publishes live endpoint |
-| cooperative provider | persist ephemeral eval scopes middleware |
+| service modifier | Attached service publishes live endpoint |
+| attached-service provider | persist ephemeral eval scopes middleware |
| provider pattern | StubProvider installs Sample middleware |
| per-component eval scope | Each provider gets isolated middleware |
| props in env.values | model prop available in eval blocks |
diff --git a/smoke-test/attached-service-ping-pong.test.md b/smoke-test/attached-service-ping-pong.test.md
new file mode 100644
index 00000000..0de01bee
--- /dev/null
+++ b/smoke-test/attached-service-ping-pong.test.md
@@ -0,0 +1,10 @@
+# Attached service ping-pong
+
+
+
+
+
+
+
+
+
diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md
index c179c1fb..b1e2894a 100644
--- a/specs/executable-mdx-spec.md
+++ b/specs/executable-mdx-spec.md
@@ -548,17 +548,17 @@ only a nullish return value, and calling `output()` is an error. The modifier is
valid only directly around the terminal `eval`; `persist ephemeral eval` keeps
installed middleware in the invocation eval scope.
-**`service=`** is a terminal service-acquisition modifier. The code
+**`service=`** is a terminal service-attachment modifier. The code
block content is the shell command passed to `startService()`, and the required
parameter is the live binding that receives the frozen
`{ hostname: "127.0.0.1", port }` endpoint. Binding syntax and collisions are
-validated before process acquisition. The modifier produces no output or
-journal entry, and the acquired service remains supervised until the component
-invocation closes.
+validated before process attachment. The modifier produces no output or
+journal entry, and the service attachment remains supervised until the
+component invocation closes.
````markdown
```bash service=server exec
-./server --cooperative
+./handshake-compatible-server
```
```ts persist ephemeral eval
@@ -592,8 +592,8 @@ the info string is purely syntactic: it satisfies the detection rule
and signals to readers that this block runs a command.
`daemon` is for fixed-configuration background processes. It does not allocate
-or publish a dynamic endpoint and does not establish readiness; cooperative
-network services use `service=`.
+or publish a dynamic endpoint and does not perform the XMD service handshake;
+attached network services use `service=`.
| Property | `exec` | `daemon` |
|---|---|---|
@@ -1152,7 +1152,7 @@ of the same name cannot collide.
The exact list lives in the `STANDARD_IMPORTS` constant, which both
compilers share (`src/data-uri-compiler.ts`, `src/temp-file-compiler.ts`).
-#### Cooperative service API
+#### Attached service API
`@executablemd/runtime` exports provider-neutral `API.Service` under the stable
context-api name `runtime.service`, and its ordinary operation as
@@ -1170,12 +1170,12 @@ interface ServiceStartOptions {
readonly startupTimeout?: number;
}
-interface ServiceResource {
+interface ServiceAttachment {
readonly endpoint: Readonly;
}
interface ServiceHandler {
- start(options: ServiceStartOptions): Operation;
+ start(options: ServiceStartOptions): Operation;
}
```
@@ -1198,8 +1198,8 @@ yield* when(function* () {
`fetch().expect()` from `@effectionx/fetch` throws `HttpError` on non-2xx
responses. `when` catches it and retries until the assertion passes or the
-timeout expires. Cooperative-service startup readiness is established by the
-host protocol, not by polling from a document.
+timeout expires. Attached-service startup is established by the XMD service
+handshake protocol, not by polling from a document.
#### Compiling blocks
@@ -1270,7 +1270,7 @@ projected caller content.
| journal serialization and replay restore | yes | no |
The two namespaces may not overlap. `service=` validates the binding
-name and checks both environments before acquiring a process. Ordinary durable
+name and checks both environments before attaching a process. Ordinary durable
eval validates its declared exports against live names before execution or
replay restoration and before appending an eval event. `ephemeral eval`
validates its exports against durable names before execution; it may atomically
@@ -1502,16 +1502,16 @@ run but are absent from the diagnostic trace.
| `src/modifiers/timeout.ts` | `timeoutFactory`, `parseDuration()` |
| `src/modifiers/daemon.ts` | `daemonFactory` — long-running subprocess terminal modifier |
| `src/modifiers/ephemeral.ts` | `ephemeralFactory` — replay-safe live eval wrapper |
-| `src/modifiers/service.ts` | `serviceFactory` — scoped cooperative-service acquisition |
+| `src/modifiers/service.ts` | `serviceFactory` — scoped service attachment |
| `src/sample-api.ts` | `Sample` Api definition (§3.4) — LLM middleware surface |
-| `packages/runtime/service.ts` | provider-neutral `API.Service`, readiness protocol types and `startService()` resource |
+| `packages/runtime/service.ts` | provider-neutral `API.Service`, XMD service handshake types and `startService()` attachment |
| `src/api.ts` | Document Output Api definition, exports `output` (§9.2) |
| `src/collect.ts` | `collect()` — stream consumption helper, returns `Result` |
| `src/output/mod.ts` | Barrel export for output middleware |
| `src/output/normalize.ts` | `useNormalizedOutput()` — whitespace normalization middleware (§9.4) |
| `src/output/terminal.ts` | `useTerminalOutput()` — terminal ANSI formatting middleware (§9.5) |
| `packages/cli/src/cli.ts` | Runtime-neutral CLI (separate `cli` workspace package) with `--verbose`, `--journal`, and `--raw` flags; Output Api stream consumption (§9.6) |
-| `packages/cli/src/service-host.ts` | shared authenticated readiness observer and supervised host-process adapter |
+| `packages/cli/src/service-host.ts` | shared XMD service handshake observer and supervised host-process adapter |
| `packages/cli/src/{deno,node,bun,compiled}-service.ts` | runtime-named service adapters for token, environment and stdio behavior |
| `packages/cli/src/{deno,node,bun,compiled}.ts` | Entrypoints — each installs matching `API.Env` and `API.Service` adapters, then calls `runXmd` |
| `packages/workflow/src/service-denial.ts` | non-delegating workflow service denial middleware |
@@ -3781,15 +3781,16 @@ and strings.
### 6.7 Provider component pattern
-A **provider component** is a regular markdown component whose body acquires a
-cooperative service and installs middleware for its subtree. It composes
+A **provider component** is a regular markdown component whose body starts an
+attached service and installs middleware for its subtree. It composes
`service=` + `persist ephemeral eval` + ``; the host, not
-the document, owns endpoint allocation and authenticated readiness.
+the document, owns endpoint allocation and the authenticated XMD service
+handshake.
#### Structure
-1. A `service=` block starts the cooperative command and waits for an
- authenticated readiness record.
+1. A `service=` block starts the handshake-compatible command and waits
+ for an authenticated handshake record.
2. A `persist ephemeral eval` block reads the live endpoint and installs
provider middleware in the component eval scope.
3. `` expands the subtree while the supervised process and
@@ -3829,7 +3830,7 @@ yield* Sample.around({
The executable receives `XMD_SERVICE_HOST`, `XMD_SERVICE_PORT` and a
cryptographically random `XMD_SERVICE_TOKEN`. It binds exactly the supplied
-loopback host and port, then writes one newline-terminated readiness record to
+loopback host and port, then writes one newline-terminated handshake record to
stdout:
```text
@@ -3839,21 +3840,21 @@ XMD_SERVICE_READY:{"version":1,"token":"","hostname":"127.0.0.1","port":4
The host installs its byte-level stdout observer before spawn. At each line
start it retains only bytes that can still match `XMD_SERVICE_READY:`; on the
first mismatch it forwards those bytes and all subsequent ordinary bytes
-immediately, without waiting for a newline. Only an actual protocol candidate
+immediately, without waiting for a newline. Only an actual handshake candidate
is buffered, under a finite bound. The observer suppresses valid and invalid
-protocol records and accepts readiness only when the JSON object has exactly
-those fields and matches the expected version, token, host and port. Malformed,
-forged, duplicate or late records fail the service without exposing the token
-or raw protocol line.
-Startup races readiness against process exit, protocol failure and the
-contextual startup timeout. After readiness the host continues supervising
-process exit and duplicate records until acquisition ends. An observable host
+handshake records and accepts the handshake only when the JSON object has
+exactly those fields and matches the expected version, token, host and port.
+Malformed, forged, duplicate or late records fail the attached service without
+exposing the token or raw handshake line.
+Startup races the handshake against process exit, handshake failure and the
+contextual startup timeout. After the handshake the host continues supervising
+process exit and duplicate records until the attachment ends. An observable host
process teardown failure becomes `ServiceTeardownError`; when execution is
already failing, the invocation teardown aggregate preserves that execution
failure first and keeps the service failure reachable through its teardown
member.
-The service binding is live, so only the `ephemeral eval` block can read it.
+The attached-service binding is live, so only the `ephemeral eval` block can read it.
The block installs middleware in the invocation scope through `persist`; plain
eval, interpolation and the journal cannot observe the endpoint. Partial replay
runs both blocks again to reconstruct a current process and middleware chain.
@@ -3872,7 +3873,7 @@ scope boundary:
```
-Each acquisition receives a distinct host-selected endpoint and token. Both
+Each service attachment receives a distinct host-selected endpoint and token. Both
services remain live while the nested report expands; the inner service tears
down before the outer in standard structured-concurrency order.
@@ -3959,9 +3960,10 @@ All three props are optional with empty-string defaults:
#### Repeated-run behavior of the provider pattern
-Every run allocates a current free port, starts the daemon, performs readiness
-polling and child operations, then terminates the daemon when the component
-closes. A previous diagnostic trace does not suppress any of these actions.
+Every run allocates a current free port, creates the service attachment,
+performs the XMD service handshake and child operations, then terminates the
+attached service when the component closes. A previous diagnostic trace does
+not suppress any of these actions.
### 6.8.1 When a function component fails
@@ -5563,7 +5565,7 @@ yield* runXmd(args, useDenoService);
```
The installer is invoked only for `xmd run` and `xmd test`, immediately before
-`execute()`. Help, inspection and agent-worker paths never install or acquire a
+`execute()`. Help, inspection and agent-worker paths never install or attach a
service. Each adapter supplies host randomness, inherited environment and
stdout/stderr writers to the shared service host; production adapters reject a
non-loopback requested host before spawning.
@@ -5698,7 +5700,7 @@ Given a document:
```markdown
# Title
-
+
@@ -6641,7 +6643,7 @@ Defined in [Workflow runs](./workflow-spec.md) §9.5–§9.6.
| R1 | Live overlay hidden from plain eval | A service binding is absent from the ordinary eval preamble |
| R2 | Live overlay hidden from interpolation | `{server}` remains literal rather than becoming an endpoint string |
| R3 | `ephemeral eval` executes during partial replay | Live bindings and middleware are reconstructed without a journal entry |
-| R4 | Service publication collision | A durable or live binding with the requested name refuses acquisition before spawn |
+| R4 | Service publication collision | A durable or live binding with the requested name refuses attachment before spawn |
| R5 | Durable export collides with a service | Live execution and partial replay both reject before execution or restoration and append no eval event |
| R6 | Ephemeral export collides with durable state | The block is rejected before execution and publishes no partial export |
| R7 | Ephemeral update of a live binding | A later ephemeral block may atomically replace an existing live name |
@@ -6655,22 +6657,23 @@ Defined in [Workflow runs](./workflow-spec.md) §9.5–§9.6.
|---|------|--------|
| S1 | Full provider golden run | service → persistent ephemeral middleware → children → cleanup |
| S2 | Endpoint flows to ephemeral middleware | `server` is an exact frozen loopback endpoint available only to ephemeral eval |
-| S3 | Children can call sample after protocol readiness | `sample` calls in children reach the acquired endpoint |
-| S4 | Cancellation after readiness | The child exits and its listener can be rebound after its owning task is halted |
-| S5 | Startup failures | Exit, timeout and invalid readiness records produce dedicated errors without leaking protocol data |
+| S3 | Children can call sample after the handshake | `sample` calls in children reach the attached-service endpoint |
+| S4 | Cancellation after handshake | The child exits and its listener can be rebound after its owning task is halted |
+| S5 | Startup failures | Exit, timeout and invalid handshake records produce dedicated errors without leaking handshake data |
| S6 | Provider exits during projected content | The projected request reaches the ready process, its unexpected exit fails the document, and it is not restarted |
| S7 | Nested real providers | Outer + inner processes both start and inner teardown finishes before outer teardown |
| S8 | Nested providers, no model | Innermost provider handles sample call |
| S9 | Nested providers, explicit model matching outer | Inner passes through, outer handles |
| S10 | Nested providers, explicit model matching inner | Inner handles regardless of nesting depth |
| S11 | Unmatched model | Chain exhausted → descriptive error naming the model |
-| S12 | Partial replay | Service acquisition and ephemeral middleware execute again after the recorded prefix |
+| S12 | Partial replay | Service attachment and ephemeral middleware execute again after the recorded prefix |
| S13 | Completed replay | Completed document returns without process spawn or token allocation |
-| S14 | Concurrent service acquisitions | Two owners acquire at the same time and receive distinct live endpoints |
+| S14 | Concurrent service attachments | Two owners attach at the same time and receive distinct live endpoints |
| S15 | Incremental ordinary stdout | Unterminated and chunk-split ordinary bytes are forwarded before teardown, byte for byte |
-| S16 | Incremental protocol records | Split readiness is suppressed, duplicate supervision remains active, and invalid candidates are bounded and suppressed |
+| S16 | Incremental handshake records | A split handshake is suppressed, duplicate supervision remains active, and invalid candidates are bounded and suppressed |
| S17 | Service teardown failures | A lone observable process teardown failure becomes `ServiceTeardownError`; an active execution failure remains first in the invocation aggregate |
| S18 | Projected failure cleanup | A prompt projected-content failure tears down both retained real services and starts neither again |
+| S19 | Compiled-binary attached-service ping-pong | A smoke document attaches two real services, closes both endpoints into ephemeral middleware, journals only the ordinary filtered `Sample` result and completes `ping→pong→ping` |
### Tier EO — eval output() function
@@ -7058,7 +7061,7 @@ must preserve the trace for diagnosis or remove it before starting a new run.
| 42 | Service endpoints are live bindings | The endpoint identifies an execution-owned process and is reconstructed during partial replay, so it cannot enter durable eval, interpolation or the journal |
| 43 | `when` (from `@effectionx/converge`) is the polling VM global | `when` is the exported name from the package; the sandbox already contains it; no rename or addition needed |
| 44 | Provider lifecycle expressed as a component, not an `ExecuteOptions` field | Scope boundary is visible in the document tree; composable — multiple providers nest naturally via structured concurrency; no framework-level lifecycle hooks required |
-| 45 | Cooperative readiness is an authenticated stdout protocol | The host observes from before spawn, verifies version/token/host/port exactly and supervises the process continuously without a close-and-rebind race |
+| 45 | The XMD service handshake protocol is authenticated stdout | The host observes from before spawn, verifies version/token/host/port exactly and supervises the attached service continuously without a close-and-rebind race |
| 46 | Provider middleware reads an endpoint from the live overlay | The current endpoint is available to `ephemeral eval` while remaining invisible to durable effects and interpolation |
| 47 | Each component gets a fresh `EvalEnv` | The component's environment is installed as a scope-local `env` provider around body expansion, so eval blocks within a component share bindings but don't leak into parent or sibling components; critical for provider isolation |
| 48 | `output()` is a plain function, not `yield*` | Output is a synchronous side effect (mutating a ref), not an Effection operation; making it a function keeps the API simple and avoids requiring generator context just to set output text |
@@ -7075,7 +7078,7 @@ must preserve the trace for diagnosis or remove it before starting a new run.
| 59 | Provider components use ordinary generated-module imports | Provider-specific client functions may be imported explicitly; executable.md supplies `Sample`, `when`, `fetch` and the contextual document bindings |
| 60 | Props pre-populated into `env.values` at component invocation | Code block content uses bare `{name}` binding interpolation from `env.values`; props must enter `env.values` at invocation time to be accessible in code blocks; consistent with how eval bindings work |
| 61 | Provider HTTP calls use `@effectionx/fetch` | Calls remain Effection operations under structured cancellation and the provider's lexical middleware |
-| 62 | Protocol readiness and application health are separate | The readiness record proves the cooperative process owns the assigned endpoint; an application may still use `when` for a later domain-specific condition |
+| 62 | The XMD service handshake and application health are separate | The handshake record proves the attached service owns the assigned endpoint; an application may still use `when` for a later domain-specific condition |
| 63 | `stdio: "inherit"` is the default for `daemon()` | During development, seeing server logs in the terminal is valuable; production deployments can pass `stdio: "ignore"`; the executable.md `daemonFactory` passes no stdio option, defaulting to `"inherit"` |
| 64 | `DocumentOutput` Api with single `output` operation | Extensible to progress/printed errors; middleware-composable via `scope.around`; single Api surface for all output concerns |
| 65 | Whitespace normalization is middleware, not post-processing | Stateful across calls; composes with other middleware; can be disabled via `--raw`; mutable closure state scoped per `useNormalizedOutput()` call |