From 5837f200cb6ec0276c19e6c0eae62072ff3add8b Mon Sep 17 00:00:00 2001 From: udo Date: Wed, 14 Oct 2026 00:37:46 +0000 Subject: [PATCH] Convert array literals into as dval parameters An array type is constructible into dval like scalars and strings, so port_spawn(["cat"]) resolves and lowers through the dval constructor; a non-literal array still fails there with the existing message. The service fixtures and the port_spawn page use the literal form again, and the services guide names the oversized-message refusal beside the other two reasons a send returns false. Co-Authored-By: Claude Fable 5.1 --- scripts/capy_artifact_golden.sha256 | 4 ++-- scripts/test_capy_native_compiler.cpp | 5 ++++- site/doc/content/api/port-spawn.capy | 2 +- site/doc/content/guide/services.capy | 8 ++++---- site/doc/source/api/port_spawn.txt | 2 +- site/doc/source/guide/12-services.txt | 2 +- site/tests/capy-services-api.capy | 4 ++-- site/tests/capy-services-slice.capy | 4 ++-- src/capy/compiler.cpp | 3 ++- 9 files changed, 19 insertions(+), 15 deletions(-) diff --git a/scripts/capy_artifact_golden.sha256 b/scripts/capy_artifact_golden.sha256 index 16ca074c..c5447f6d 100644 --- a/scripts/capy_artifact_golden.sha256 +++ b/scripts/capy_artifact_golden.sha256 @@ -185,11 +185,11 @@ e7755838e50bc6c5694efbb9c50c7500c56e4a68d880c5a2117738a55501b20a site_tests_cap 8c7687f9f1f2fd8b214f54005c2f2138a0347e223d5db0eeb06bc8bd524626e5 site_tests_capy-serve-http.capy.wasm 07f986eb52a6ec08b6b2ab7c79315913f7013232a7c09873143712a4b859945a site_tests_capy-serve-http.capy.wasm.source-map 3789bade37c376d4b7ba3358ef7d4a73075ec08c436e40e46ebffc55b67d8663 site_tests_capy-services-api.capy.wasm -05fe02b659796a60e5ad805987c2a84f3b5dc4bafa1c6c11204127898adec6a4 site_tests_capy-services-api.capy.wasm.source-map +921f394702d8deb0471b75a506ade730876f961f1a34234c71427f1de5aa9cc2 site_tests_capy-services-api.capy.wasm.source-map 9458cf9aa5278841cee3646f11d1acc57baad307306b3061bcccb4c00bf3a946 site_tests_capy-services-api-dep.capy.wasm c3fd87156412bcca1629178b52d15a997f3108f479fb11c25ed96e215218b540 site_tests_capy-services-api-dep.capy.wasm.source-map 8b1fa9f2856e2d5ccca4702cb75dedbdf66f6f5c6908ea5aeae747dcc7015d1c site_tests_capy-services-slice.capy.wasm -5c393f02e13ee7255d0efbb87a54129b32888516513bd9b2058863a6629855ac site_tests_capy-services-slice.capy.wasm.source-map +8ca06db55cab8e106c829ee54756a4d8588698c40cb70954b38d583133e6591b site_tests_capy-services-slice.capy.wasm.source-map b38be0d419a49e12f46db3fb520e0f3689a46d069ecc0638f6bcd894c6c2898d site_tests_capy-session.capy.wasm db859884cce403a35a0c4e43088e5673713d494d738e0d8e6c877ea547863396 site_tests_capy-session.capy.wasm.source-map 4d2899e11780142c94f5a5e9f76d9d88a6051989bc8bb19ec2034f439f09a034 site_tests_capy-shell-exec-background-type-trap.capy.wasm diff --git a/scripts/test_capy_native_compiler.cpp b/scripts/test_capy_native_compiler.cpp index 52d63f3e..f03b1182 100644 --- a/scripts/test_capy_native_compiler.cpp +++ b/scripts/test_capy_native_compiler.cpp @@ -314,6 +314,10 @@ int main() assert(unit_validation.valid); assert(std::any_of(unit_validation.exports.begin(), unit_validation.exports.end(), [](const auto& exported) { return exported.name == "__wasm_call_ctors" && exported.kind == 0; })); + // An array literal converts into an `as dval` parameter through the dval constructor. + const auto array_as_dval = capy::compile_bearer_unit( + "function take(v : as dval) dval { -> v }\nfunction RENDER() { print(string(take([\"a\", \"b\"])), string(take(\"s\"))) }\n", options); + assert(capy::wasm::validate_bearer_unit(array_as_dval.wasm, {.bearer_abi_version = "11"}).valid); // Service handlers are named props handlers; every name is an export the runtime dispatches by event. const auto services = capy::compile_bearer_unit( "function SERVICE:START(props : dval) { print(string(props.count), string(request.unit)) }\n" @@ -537,7 +541,6 @@ int main() const std::string generic_before_dval_bytes(generic_before_dval.wasm.begin(), generic_before_dval.wasm.end()); assert(generic_before_dval_bytes.find("bearer_dv_s32") == std::string::npos); for (const char* source : { - "function accept(value : as dval) dval { -> value }\nfunction CLI(request : dval) { accept([1]) }\n", "struct Value { item : s32 }\nfunction accept(value : as dval) dval { -> value }\nfunction CLI(request : dval) { accept(Value(1)) }\n", }) { diff --git a/site/doc/content/api/port-spawn.capy b/site/doc/content/api/port-spawn.capy index fcf1e181..f9f9b773 100644 --- a/site/doc/content/api/port-spawn.capy +++ b/site/doc/content/api/port-spawn.capy @@ -1,5 +1,5 @@ function COMPONENT(props : dval) { print("

port_spawn

lib call

Capy

function port_spawn(argv : as dval, env : as dval = {}) u64

Description

port_spawn() starts a child process owned by the service, with its standard input, output, and error as pipes the service loop watches. Output arrives as SERVICE:DATA events with port, stream (stdout or stderr), and the bytes that were readable, at most one read per event. When the child exits, SERVICE:EXIT arrives with port and status after the last DATA of that child.

Write to the child's standard input with port_write(). port_close() closes the child's standard input, sends it SIGTERM, and SIGKILL one second later when it is still alive; its remaining output is still delivered, and SERVICE:EXIT arrives last. A service that is stopped or crashes takes its children with it.

The child inherits the worker's environment with the env entries added or replaced. A child that cannot be executed still yields a handle; its SERVICE:EXIT then carries status 127 and an error.

The program is executed directly, not through a shell. Build argv as a list; do not join it into one string.

Parameters

argv : the program and its arguments as a list of strings

env : environment variables for the chi") - print("ld as a map of strings, added to the worker's environment

Return Values

a port handle, or zero when the child could not be started

Errors

A failure to create the pipes or to start the helper yields zero and an operator log line. A program that cannot be executed is reported afterwards: the handle is returned and its SERVICE:EXIT carries status 127 and an error. Outside a service worker the call traps.

Example

Capy

var agent := port_spawn(dval(["pi", "--json"]), {"HOME": "/srv/agents"})\nagents[string(agent)] = {started: time_precise()}\n
") + print("ld as a map of strings, added to the worker's environment

Return Values

a port handle, or zero when the child could not be started

Errors

A failure to create the pipes or to start the helper yields zero and an operator log line. A program that cannot be executed is reported afterwards: the handle is returned and its SERVICE:EXIT carries status 127 and an error. Outside a service worker the call traps.

Example

Capy

var agent := port_spawn(["pi", "--json"], {"HOME": "/srv/agents"})\nagents[string(agent)] = {started: time_precise()}\n
") } diff --git a/site/doc/content/guide/services.capy b/site/doc/content/guide/services.capy index 3fd85b72..3fbfc826 100644 --- a/site/doc/content/guide/services.capy +++ b/site/doc/content/guide/services.capy @@ -2,8 +2,8 @@ function COMPONENT(props : dval) { print("

12. Services

A service is a unit that keeps running between requests. It owns I/O that a request handler cannot hold: child processes, pipes, sockets, timers. A request handler asks a service to do something by posting a message; the service answers through whatever channel fits, such as a WebSocket, the database, or the key-value cache. A pool of agents with their own processes, or a broker that keeps a connection open, is a service.

The shape of a service

Bearer owns the loop. Your code never blocks on a pipe or sleeps in a while. You write handlers, Bearer calls one for every event, each call is short and bounded, and the next event waits until the handler returns. This is the model of Erlang's gen_server and of Node's event loop, and it is why a service needs no locks.

Every service handler is a named SERVICE handler. The runtime's events are START, STOP, DATA, EXIT, and ACCEPT. Every other name is a signal your own code posts:

var ticks := 0\n\nfunction SERVICE:START(props : dval)\n{\n	ticks = 0\n}\n\nfunction SERVICE:TICK(props : dval)\n{\n	ticks = ticks + 1\n}\n\nfunction RENDER()\n{\n	print("service unit ready\\n")\n}\n

Output

service unit ready\n

This unit is also an ordinary page: the RENDER handler answers requests while the SERVICE handlers answer events. A service is identified by its unit path, and there is at most one running service per unit.<") print("/p>

State lives in unit variables

A service keeps one workspace for its whole life, so a top-level var is initialized once, when the service starts, and keeps its value across every handler call. That is where service state belongs:

var agents := {}\nvar seen := 0\n\nfunction SERVICE:START(props : dval)\n{\n	agents.capacity = s64(props.count)\n}\n\nfunction SERVICE:DATA(props : dval)\n{\n	seen = seen + 1\n	agents.last = string(props.data)\n}\n\nfunction RENDER()\n{\n	print("state in unit variables\\n")\n}\n

Every handler binds request implicitly, as handlers always do. For a service it is a synthetic request with no session, body, or form, built once for the life of the service. It carries the start arguments in request.props and the unit path in request.unit, and it lets request builtins such as ws_send() and unit_call() work inside a service. Do not use it as a place for state.

Start, stop, send

A request handler, a task, or another service talks to a service through four calls. service_start(unit, props) records that the service should run and starts it; calling it on a running service does nothing. service_stop(unit) posts a STOP and the worker exits once SERVICE:STOP returns. service_status(unit) reports state, pid, started_at, restarts, mailbox, and stale. service_send(unit, signal, props, delay_ms) posts a message and returns at once:

service_send("/agents/pool.capy", "assign", task: "review 42")

A send starts the service when ") - print("it is not running, so callers never check liveness first. service_stop() therefore means stop now, not keep down: the next send brings the service back. A send returns false only when the mailbox is full or the unit does not exist. The mailbox is the kernel's datagram queue, about 500 frames per service; messages the service holds for a delay are not counted against it. A send to a listening service is one datagram, and a request never waits for the service itself.

delay_ms is the one-shot timer. A service that posts to itself with a delay gets a retry or a backoff:

service_send(request.unit, "respawn", {}, 2000)

Intervals

Inside a service, service_every(signal, interval_ms, props) delivers SERVICE:<signal> repeatedly. The loop owns the timer and re-arms it only after each delivery, so a slow handler stretches the interval instead of stacking ticks in the mailbox. One timer exists per signal name; calling it again replaces the interval, and an interval of zero cancels it.

function SERVICE:START(props : dval)\n{\n service_every("sweep", 30000)\n}

Ports

A port is a file descriptor the service owns and the loop watches. port_spawn(argv, env) starts a child with its standard streams as pipes, port_connect(host, port) opens a TCP connection, and port_listen(path) listens on a unix socket. All three return a handle. port_write(handle, data) queues bytes without blocking, and port_close(handle) closes the port and sends a spawned child SIGTERM.

Readiness arrives as handlers. SERVICE:DATA brings port, stream, and whatever bytes were r") - print("eadable, at most one read per event, so line framing is the service's job. SERVICE:EXIT brings port and status after a child's last DATA. SERVICE:ACCEPT brings port and the accepted connection:

var buffers := {}\n\nfunction SERVICE:DATA(props : dval)\n{\n	var key := string(props.port)\n	buffers[key] = string(buffers[key], fallback: "") + string(props.data)\n}\n\nfunction SERVICE:EXIT(props : dval)\n{\n	delete(buffers, string(props.port))\n}\n\nfunction RENDER()\n{\n	print("port handlers ready\\n")\n}\n

Port calls exist only inside a service worker, because a descriptor needs an owner that outlives one invocation and a loop that watches it. Anywhere else they trap.

Failure

Each handler call runs under the invocation deadline. A handler that hangs is interrupted, that one event is dropped with an operator log line, and the service continues. What a service prints goes to the operator log, since there is no response to write to.

A service that exits without a stop is crashed. The supervisor restarts it with increasing delays, SERVICE:START runs again, and restarts in the status counts it. This is Erlang's advice: let it crash, and make START able to rebuild what the service needs, including its ports and intervals. Messages queued before the crash are gone, as they are in Erlang.

A service does not restart when its source changes. stale in the status says that the running workspace predates the source, so an admin page can offer a restart. Stop and start it, which recompiles the unit.

What a service is not

A service has no HTTP r") - print("equest, no session, and no response. Do not pass a request to a service; pass identifiers and small options, as you would to a task, and let the service read what it needs. Do not wait for a service in a page request. Post the message, and let the service report through a channel the page already watches.

") + print("it is not running, so callers never check liveness first. service_stop() therefore means stop now, not keep down: the next send brings the service back. A send returns false only when the mailbox is full, the message is larger than one datagram, or the unit does not exist. The mailbox is the kernel's datagram queue, about 500 frames per service; messages the service holds for a delay are not counted against it. A send to a listening service is one datagram, and a request never waits for the service itself.

delay_ms is the one-shot timer. A service that posts to itself with a delay gets a retry or a backoff:

service_send(request.unit, "respawn", {}, 2000)

Intervals

Inside a service, service_every(signal, interval_ms, props) delivers SERVICE:<signal> repeatedly. The loop owns the timer and re-arms it only after each delivery, so a slow handler stretches the interval instead of stacking ticks in the mailbox. One timer exists per signal name; calling it again replaces the interval, and an interval of zero cancels it.

function SERVICE:START(props : dval)\n{\n service_every("sweep", 30000)\n}

Ports

A port is a file descriptor the service owns and the loop watches. port_spawn(argv, env) starts a child with its standard streams as pipes, port_connect(host, port) opens a TCP connection, and port_listen(path) listens on a unix socket. All three return a handle. port_write(handle, data) queues bytes without blocking, and port_close(handle) closes the port and sends a spawned child SIGTERM.

Readiness arrives as handlers. SERVICE:DATA brings port, stream, and whatever bytes were readable, at most one read per event, so line framing is the service's job. SERVICE:EXIT brings port and status after a child's last DATA. SERVICE:ACCEPT brings port and the accepted connection:

var buffers := {}\n\nfunction SERVICE:DATA(props : dval)\n{\n	var key := string(props.port)\n	buffers[key] = string(buffers[key], fallback: "") + string(props.data)\n}\n\nfunction SERVICE:EXIT(props : dval)\n{\n	delete(buffers, string(props.port))\n}\n\nfunction RENDER()\n{\n	print("port handlers ready\\n")\n}\n

Port calls exist only inside a service worker, because a descriptor needs an owner that outlives one invocation and a loop that watches it. Anywhere else they trap.

Failure

Each handler call runs under the invocation deadline. A handler that hangs is interrupted, that one event is dropped with an operator log line, and the service continues. What a service prints goes to the operator log, since there is no response to write to.

A service that exits without a stop is crashed. The supervisor restarts it with increasing delays, SERVICE:START runs again, and restarts in the status counts it. This is Erlang's advice: let it crash, and make START able to rebuild what the service needs, including its ports and intervals. Messages queued before the crash are gone, as they are in Erlang.

A service does not restart when its source changes. stale in the status says that the running workspace predates the source, so an admin page can offer a restart. Stop and start it, which recompiles the unit.

What a ser") + print("vice is not

A service has no HTTP request, no session, and no response. Do not pass a request to a service; pass identifiers and small options, as you would to a task, and let the service read what it needs. Do not wait for a service in a page request. Post the message, and let the service report through a channel the page already watches.

") } diff --git a/site/doc/source/api/port_spawn.txt b/site/doc/source/api/port_spawn.txt index 4d5b1713..0b61f73d 100644 --- a/site/doc/source/api/port_spawn.txt +++ b/site/doc/source/api/port_spawn.txt @@ -21,7 +21,7 @@ The program is executed directly, not through a shell. Build `argv` as a list; d A failure to create the pipes or to start the helper yields zero and an operator log line. A program that cannot be executed is reported afterwards: the handle is returned and its `SERVICE:EXIT` carries status 127 and an `error`. Outside a service worker the call traps. :example capy service -var agent := port_spawn(dval(["pi", "--json"]), {"HOME": "/srv/agents"}) +var agent := port_spawn(["pi", "--json"], {"HOME": "/srv/agents"}) agents[string(agent)] = {started: time_precise()} :see-group diff --git a/site/doc/source/guide/12-services.txt b/site/doc/source/guide/12-services.txt index 700ffe0d..e76cc7e6 100644 --- a/site/doc/source/guide/12-services.txt +++ b/site/doc/source/guide/12-services.txt @@ -69,7 +69,7 @@ A request handler, a task, or another service talks to a service through four ca service_send("/agents/pool.capy", "assign", task: "review 42") -A send starts the service when it is not running, so callers never check liveness first. `service_stop()` therefore means stop now, not keep down: the next send brings the service back. A send returns false only when the mailbox is full or the unit does not exist. The mailbox is the kernel's datagram queue, about 500 frames per service; messages the service holds for a delay are not counted against it. A send to a listening service is one datagram, and a request never waits for the service itself. +A send starts the service when it is not running, so callers never check liveness first. `service_stop()` therefore means stop now, not keep down: the next send brings the service back. A send returns false only when the mailbox is full, the message is larger than one datagram, or the unit does not exist. The mailbox is the kernel's datagram queue, about 500 frames per service; messages the service holds for a delay are not counted against it. A send to a listening service is one datagram, and a request never waits for the service itself. `delay_ms` is the one-shot timer. A service that posts to itself with a delay gets a retry or a backoff: diff --git a/site/tests/capy-services-api.capy b/site/tests/capy-services-api.capy index 2585057d..943dc776 100644 --- a/site/tests/capy-services-api.capy +++ b/site/tests/capy-services-api.capy @@ -20,7 +20,7 @@ function SERVICE: START(props : dval) { setup() record("START " + string(length(props))) - child = port_spawn(dval(["/bin/cat"])) + child = port_spawn(["/bin/cat"]) record("WRITE " + string(port_write(child, "hello\n"))) record("UNKNOWN " + string(port_write(999999, "x"))) record("BAD " + string(port_spawn(dval([])))) @@ -124,7 +124,7 @@ function CLI(request : dval) } if mode == "spawn" { - port_spawn(dval(["/bin/cat"])) + port_spawn(["/bin/cat"]) } if mode == "connect" { diff --git a/site/tests/capy-services-slice.capy b/site/tests/capy-services-slice.capy index 31e827d0..e19dd7c0 100644 --- a/site/tests/capy-services-slice.capy +++ b/site/tests/capy-services-slice.capy @@ -18,7 +18,7 @@ function SERVICE: START(props : dval) { logfile = dirname(target()) + "/events.log" record("START " + string(service_status(target()).state)) - child = port_spawn(dval(["/bin/cat"])) + child = port_spawn(["/bin/cat"]) record("WRITE " + string(port_write(child, "slice"))) service_every("BAD", 10) service_every("BAD", 0) @@ -154,7 +154,7 @@ function CLI: TRAP(request : dval) } if mode == "spawn" { - port_spawn(dval(["/bin/cat"])) + port_spawn(["/bin/cat"]) } if mode == "connect" { diff --git a/src/capy/compiler.cpp b/src/capy/compiler.cpp index 97296f5f..b85d9612 100644 --- a/src/capy/compiler.cpp +++ b/src/capy/compiler.cpp @@ -1129,7 +1129,8 @@ struct Module if (can_convert(source, target)) return true; const std::string name = target.rfind("struct:", 0) == 0 ? target.substr(7) : target; - if (target == "dval" && (source == "string" || is_scalar(source) || source == "dval")) + // Arrays convert through the dval constructor like scalars and strings. + if (target == "dval" && (source == "string" || is_scalar(source) || source == "dval" || source.rfind("array<", 0) == 0)) return true; if (target.rfind("struct:", 0) == 0) {