commit f4e7405e309a640148eef4c8a266ce94c4a23b6d from: Isaac Meerleo date: Sat Jul 18 16:47:04 2026 UTC Merge commit 'bc50b13' into codex/implement-all-three # Conflicts: # CONTEXT.md # src/fugu-tool/Makefile # src/fugu/Makefile commit - 32a9a63b07ec43fcd0d3100fadfcea52fab6ed0c commit + f4e7405e309a640148eef4c8a266ce94c4a23b6d blob - 49ea1c5d7760eab9c7240161e93c3ba7afa37c7f blob + 4a2800dd74c9fbf66a7cd527cbb7d4dc8e462aa5 --- CONTEXT.md +++ CONTEXT.md @@ -145,7 +145,9 @@ reaches its steady pledge/unveil and replies `READY`. `libexec/fugu/`: **fugu-tty** (front end, terminal), **fugu-api** (model-credential custodian), **fugu-web** (search custodian and hostile-content blast room), **fugu-tool** (tool executor; read-only -role instance serves subagents), **fugu-editor** (compose spawn room). +role instance serves subagents), **fugu-artifact** (Owner-private retained +Tool output, quota, and bounded retrieval), **fugu-editor** (compose spawn +room). **Protect list**: Owner-configured paths unveiled away from every tool-executing @@ -155,8 +157,8 @@ role at startup; contents kernel-unreachable, ENOENT-s **Tool**: A capability offered to the model (read, write, edit, multi_edit, -shell, shell_bg/output/kill, grep, find, ls, web_search, web_fetch, -http_request, skill, agent). +shell, shell_bg/output/kill, output_read, grep, find, ls, web_search, +web_fetch, http_request, skill, agent). **Subagent**: An ephemeral, kernel-enforced read-only conversation spawned by the @@ -214,8 +216,14 @@ unknown. ## Relationships -- The **Coordinator** alone talks to every worker; **Workers** never - talk to each other (authority map, I10). +- The **Coordinator** alone exchanges control messages with every worker. + **Workers** have no direct imsg channel. The sole direct data plane is one + correlated, bounded pipe protocol from the persistent Lead **fugu-tool** to + **fugu-artifact**, with its producer endpoint checked and relayed by the + Coordinator; a Subagent Role never receives one (authority map, I10). + OpenBSD's bidirectional `pipe(2)` extension reports both endpoints as + `O_RDWR`, so the one-way flow is enforced by Role and protocol ownership, + not by claiming a kernel write-only access mode. - The **Owner** is fugu's only human user. The **Invoking user** is that person's OS identity for filesystem ownership and legacy match policy, not a second domain participant or configurable subscription identity. @@ -232,6 +240,10 @@ unknown. - **Effort** applies unchanged to every Lead Generation and survives Provider and model selection. A Subagent receives its inherited or explicit value at launch and retains it for every child Generation. +- A committed **Artifact** belongs to its **Session** and survives resume, + **Compaction**, and clear boundaries until quota eviction. The Journal owns + the structured id/size record; **fugu-artifact** alone owns raw storage, and + `output_read` returns only a bounded id-addressed page. ## Example dialogue blob - c43906d6ddbf0e54a107b625cd3db44f786c9a02 blob + cf3089f13ac129c627887206cc546b0d709bc8da --- docs/adr/0001-process-topology.md +++ docs/adr/0001-process-topology.md @@ -1,4 +1,4 @@ -# Coordinator plus five single-purpose workers +# Coordinator plus six single-purpose workers The security invariants (handoff/invariants.md I1–I15) force several separations but leave process count and topology free. We chose a @@ -13,8 +13,13 @@ secrets), **fugu-api** exec after the CA bundle loads), **fugu-web** (search-credential custodian; fetch, HTML-to-text, SSRF guard), **fugu-tool** (tool executor; project tree unveiled; the same binary runs in a read-only -role for subagents, I15), and **fugu-editor** (minimal spawn room for -the external-editor compose key). +role for subagents, I15), **fugu-artifact** (Owner-private raw Tool-output +storage, quota accounting, and bounded page retrieval), and **fugu-editor** +(minimal spawn room for the external-editor compose key). The +Artifact Role alone has `rwc` access to the managed Artifact root. It has no +project, configuration, Journal, network, tty, credential, or exec authority. +The Coordinator remains the only Journal writer and retains no Artifact path +promise. ## Considered options @@ -24,6 +29,15 @@ the external-editor compose key). - *Maximal separation (dedicated journal writer, one executor per subagent):* rejected as decorative; no invariant names those walls. "Fewer, stronger walls beat many decorative ones" (invariants.md §5). +- *Coordinator-owned Artifact files:* rejected because the Coordinator's + steady pledge intentionally has no path-open promise. Restoring one would + combine the authority hub and private raw-output storage. +- *Project-local Artifact files:* rejected because model-selected Tools and + subprocesses can read the project tree, while retained output can contain + secrets and must survive outside the Projection. +- *A general storage service:* rejected as a shallow ambient capability. The + Artifact Role exposes only fixed Session-bound begin/settle/read/quota + exchanges and cannot address arbitrary paths. ## Consequences @@ -36,3 +50,12 @@ raw untrusted input (vmd-parent precedent). Sharing the canonical Turn Implementation with the read-only Subagent Role does not move either runtime or add a channel; ADR-0005 records that refinement. +Artifact control and terminal messages remain Coordinator-brokered. The sole +Worker-to-Worker data-plane exception is the bounded one-way protocol over a +pipe whose producer endpoint the Coordinator relays from `fugu-artifact` to +the Lead `fugu-tool` for one correlated call. OpenBSD's bidirectional pipe +extension reports both endpoints as `O_RDWR`; exact validation therefore +requires a blocking FIFO with that access mode and `CLOEXEC`, while Role +ownership supplies the one-way flow. Neither Role uses the endpoint in the +reverse direction, no endpoint can become a path or general protocol, and the +read-only Subagent Role never receives one. blob - 5e7b0ba64fd36ebb0ad2668c447cb5c0028c7267 blob + 755d1afe65074b216dab7ac90eb8fc0a3243c868 --- docs/adr/0003-bespoke-privsep-module.md +++ docs/adr/0003-bespoke-privsep-module.md @@ -5,8 +5,14 @@ daemon framework: a full N×N worker mesh, a control s `getpeereid` ACLs, `chroot`, `daemon(3)`, config reload, and per-process privilege drop to an unprivileged uid. fugu is a foreground, single-session, per-invocation program with a -hub-and-spoke authority map (ADR-0001) — the coordinator talks to -each worker; workers never talk to each other. +hub-and-spoke control authority map (ADR-0001) — the coordinator talks to +each worker; workers never exchange control messages directly. One later +load-bearing exception carries no control protocol: for an in-scope Lead Tool +call, the Coordinator relays a checked pipe producer endpoint from +`fugu-artifact` to `fugu-tool`, forming a bounded one-way raw-byte protocol. +The Artifact Role keeps the consumer endpoint, the descriptor is correlated +to that call, and Subagent Roles never accept it. OpenBSD exposes both pipe +endpoints as `O_RDWR`; the Roles never exercise the reverse direction. We therefore write a small bespoke `priv` module that copies vmd's *discipline* — `socketpair`+`fork`+`execve` with the child's channel @@ -14,8 +20,9 @@ We therefore write a small bespoke `priv` module that terminal business, `closefrom`, `setproctitle`, and deliberate reaping — but none of its framework: no control socket, no chroot (unveil(2) is fugu's filesystem confinement), no daemonization, no -mesh. Workers connect to the coordinator alone, which keeps the -authority map auditable in one header (`proto.h`, I10). +mesh. Workers' imsg channels connect to the coordinator alone, which keeps the +authority map auditable in one header (`proto.h`, I10). The typed Artifact +pipe relay is also enumerated there and is not a general Worker channel. ## Considered options blob - da7a636f413daa37ad766b186a121ad2c9e29a87 blob + 1efd39fabc68a246908c0437b89e898c27d81a67 --- docs/adr/0006-typed-imsg-exchanges.md +++ docs/adr/0006-typed-imsg-exchanges.md @@ -41,5 +41,17 @@ and stabilizes the events that the canonical Generatio The protocol test surface injects failure at every chunk and terminal position and traces wrong sizes, out-of-order data, duplicate or missing terminals, wrong correlation, overruns, and data after termination. This decision does -not change ADR-0001's process topology, ADR-0002's libevent dispatch, or -ADR-0003's bespoke hub-and-spoke privsep Module. +not otherwise change ADR-0001's process topology, ADR-0002's libevent +dispatch, or ADR-0003's bespoke hub-and-spoke privsep Module. + +The Artifact stream is the one narrow descriptor-bearing exception to normal +`imsgev` traffic. `imsgev` owns an exact typed FD send/receive operation that +requires the expected message type, correlation, payload size, descriptor +presence, and close-on-exec state. `fugu-artifact` creates a pipe and sends +only its producer endpoint to the Coordinator; the Coordinator validates and +relays it only to the persistent Lead `fugu-tool`. OpenBSD's bidirectional +pipe extension makes the exact endpoint access mode `O_RDWR`, not +`O_WRONLY`; one-way use is a Role/protocol rule, while the descriptor check +requires FIFO, blocking, and `CLOEXEC` state. No storage descriptor or path +is transferred, ordinary messages still reject descriptors, and every +unexpected, missing, reordered, or injected descriptor fails closed. blob - bc52461e2f27a308cb13af3f73f3e345053213db blob + d8f057b44600da6ccbae0c312dca346b6b931a17 --- docs/design/m10-release.md +++ docs/design/m10-release.md @@ -123,7 +123,8 @@ enter either destination. The source build installs: - `${PREFIX}/bin/fugu`, mode 2555 and group `_fugu`; -- `fugu-tty`, `fugu-api`, `fugu-web`, `fugu-tool`, and `fugu-editor` below +- `fugu-tty`, `fugu-api`, `fugu-web`, `fugu-tool`, `fugu-artifact`, and + `fugu-editor` below `${PREFIX}/libexec/fugu/`; - `fugu.1` and `fugu.conf.5` in the normal manual hierarchy. blob - e8324d9fadbfe9d0d87233da03609c5638756493 blob + 0e0c253057e1abd97e8b6ddbebf68b4e014e668a --- docs/design/m3-privsep.md +++ docs/design/m3-privsep.md @@ -6,6 +6,15 @@ steady states, the imsg authority map, and the first k proofs. Real functionality (turns, tools, streaming) arrives in M4+; M3 proves the *walls*. +> **Post-M3 topology amendment:** ADR-0001 now adds the persistent +> `fugu-artifact` Role for Owner-private retained Tool output. The original +> milestone counts below are historical M3 scope, not the complete current +> topology. Artifact control stays hub-and-spoke; one Coordinator-brokered, +> one-way `fugu-tool` to `fugu-artifact` pipe protocol is the sole direct +> Worker data plane. OpenBSD reports both pipe endpoints as `O_RDWR`; Role +> ownership fixes the direction. ADR-0001, ADR-0003, and ADR-0006 define its +> current authority and typed-descriptor rules. + ## 1. Framework choice: a lean bespoke privsep module vmd's `proc.c`/`proc.h` is a 730-line daemon framework: a full @@ -13,8 +22,8 @@ PROC_MAX mesh (every worker connected to every other), socket, chroot, `daemon(3)`, config reload, `getpeereid` ACLs. fugu is none of those things — it is a foreground, single-session, per-invocation program with a **hub-and-spoke** authority map -(ADR-0001): the coordinator talks to each worker; workers never talk -to each other. +(ADR-0001): the coordinator exchanges control with each worker; workers have +no direct imsg channel. **Decision:** write a small bespoke `priv` module that copies vmd's *discipline* — the exact spawn ordering, fd pinning, `closefrom`, @@ -34,6 +43,7 @@ bin/fugu (coordinator, setgid _fugu at exec, sheds gr ├─ fork+exec libexec/fugu/fugu-api persistent, model-key custodian ├─ fork+exec libexec/fugu/fugu-web persistent, search custodian + blast room ├─ fork+exec libexec/fugu/fugu-tool persistent, tool executor + ├─ fork+exec libexec/fugu/fugu-artifact persistent, private output store ├─ fork+exec libexec/fugu/fugu-tool -r on demand, read-only, per subagent (M9) └─ fork+exec libexec/fugu/fugu-editor on demand, compose room (M4/M7) ``` @@ -112,8 +122,9 @@ coordinator → worker: FUGU_IMSG_SHUTDOWN Every receiver validates type and length before use (I10); a malformed message is a clean shutdown, never memory corruption. The `imsgbuf_*` post-rewrite API from M-refs §4. Workers accept messages -**only** from the coordinator fd; there is no worker-to-worker path -to authorize. +**only** from the coordinator fd. The later Artifact amendment adds no direct +imsg path: it adds only the typed, correlated, one-way raw-byte pipe described +above. ## 5. Teardown (I12) blob - d246d795a3bbf6ae26e363738c23e91767f82a41 blob + ca9e3a2eff0c37a2e68c89d29cceba02314cb2a0 --- docs/design/m5-tools.md +++ docs/design/m5-tools.md @@ -90,14 +90,17 @@ clean per-tool error. rename, preserving the existing mode; offered only under `allow_write`. `multi_edit` applies 1–64 pairs to an in-memory copy, all-or-nothing, naming the failing 1-based index. -- **shell** — `/bin/ksh -c` in the project dir, combined output - capped at 256 KiB, 30 s wall clock then process-group kill. +- **shell** — `/bin/ksh -c` in the project dir, combined output captured by + the private Artifact role under the 16 MiB stream and bounded generated- + result policy in behavior.md §5; 30 s wall clock then process-group kill. - **grep** / **find** / **ls** — `grep -RnI`, `find`, `ls -la` - semantics, 256 KiB caps. + semantics under the same Artifact capture policy. - **shell_bg** / **shell_output** / **shell_kill** — a job table; - each job in its own process group, exempt from the 30 s clock, a - 256 KiB ring and bounded drain work per pump; `max_bg_jobs` cap - returns a clean error rather than queueing. A direct-child + each job in its own process group, exempt from the 30 s clock. Each + `shell_output` freezes one immutable Artifact segment and gives a running job + a fresh segment; large results expose only a Session-local opaque id that + `output_read` can page. The `max_bg_jobs` cap returns a clean error rather + than queueing. A direct-child supervisor anchors each command group until the worker reaps it, preserves the command's exact wait status, and watches a worker- liveness pipe, so ordinary group descendants die even if @@ -105,6 +108,12 @@ clean per-tool error. Turn BEGIN/COMMIT/ABORT controls and are killed before an abandoned Turn is reported (I12/I14, best-effort — `setsid(2)` escapes). +The initial M5 baseline used direct 256 KiB result caps and a 256 KiB +background ring. The current Artifact broker supersedes those storage details; +the confinement, process-group, and Turn-transaction mechanics above remain +the M5 foundation. The pinned current interface and limits live in +behavior.md §5. + ### File freshness A per-process table records dev/ino/size/mtime as `read` returns each blob - 77a25f0e2f957ef4bd6cbd5c4abc8c83b170b3e5 blob + 0bd4f50cce36898fe62ae23a36fb4db7975a95f6 --- docs/design/m9-subagents.md +++ docs/design/m9-subagents.md @@ -4,7 +4,7 @@ Status: implementation and verification complete, 2026 The OpenBSD `regress/agent` suite passes 155 checks. This slice adds the model-facing `agent` tool, its Owner-bounded route palette, fresh read-only workers, bounded execution, and the lead-only journal boundary required by -behavior.md §5.12 and invariant I15. The combined normal and hardened release +behavior.md §5.13 and invariant I15. The combined normal and hardened release gates also pass as recorded by `docs/design/m10-release.md`. ## Surface and routing blob - e8af57475d109b5c013829db53b40a12098fc695 blob + 95e57a866d9ee863fd031631e45cc6b7811a5124 --- docs/release-live-checks.md +++ docs/release-live-checks.md @@ -139,7 +139,7 @@ This target intentionally mutates the clean host. It leaves it installed. It verifies: 1. package creation of `_fugu` with GID 992; -2. the root/group/mode contract for the set-group-ID front binary, all five +2. the root/group/mode contract for the set-group-ID front binary, all six workers, both manuals, and `/etc/fugu.conf.sample`; 3. installation of every key and the supplied configuration as `root:_fugu` mode 0640, while the ordinary user cannot read them directly; blob - d1b228663ae914c56bda016364fe0548959da2a0 blob + 3499d090bca621c22c42b1227432d2af1e749ce4 --- handoff/README.md +++ handoff/README.md @@ -63,7 +63,7 @@ state (HEAD of 2026-06-26, 24 regress suites passing), decisions by the author: strict behavior parity, known-flaw fixes promoted to requirements (invariants.md I13/I14), and a set of deliberate additions beyond parity — the kernel-confined subagent -tool with per-call model routing (behavior.md §5.12, invariants.md +tool with per-call model routing (behavior.md §5.13, invariants.md I15), and a harness feature set chosen after a gap analysis against contemporary agent harnesses: project context files with `#` capture, bracketed paste, external-editor compose, a message queue with blob - f78265824442903f56ca11c1e2cbcc7e11974c1a blob + 156955f32d3f03a8c55b72479053527f54b833fa --- handoff/behavior.md +++ handoff/behavior.md @@ -407,6 +407,14 @@ engineering). All file paths are interpreted relative directory; absolute paths within it are fine. Every tool result is bounded; every boundary value obeys invariants.md I13. +The raw stdout/stderr of `shell`, `grep`, `find`, `ls`, `shell_bg`, and +`shell_output` is brokered through the private Artifact role, not a Tool-owned +ring buffer. Each captured stream retains at most 16 MiB. A complete generated +result is inline only when its serialized document fits 256 KiB; otherwise it +contains bounded head/tail previews and an opaque, Session-local output id. +Private paths never cross the role boundary. `output_read` pages that id by raw +byte offset (64 KiB default, 128 KiB maximum), as UTF-8 text or exact base64. + Always offered: 1. **read** `{path, offset?, limit?}` (required: path) — return file @@ -423,37 +431,47 @@ Always offered: request. Line scanning is length-aware end to end (I13). Description steers: read before editing, use `ls` for directories, read only the window needed. -2. **write** `{path, content}` (required: both) — create or fully +2. **output_read** `{id, offset?, limit?, encoding?}` (required: id) — + Lead-only; read one bounded raw-byte page from a Session-local opaque output + id. + `offset` defaults to 0, `limit` defaults to 64 KiB and is capped at + 128 KiB, and `encoding` is `text` (default) or `base64`. Text returns only + a valid UTF-8 prefix and directs the model to retry the same cursor as + base64 when necessary. Results report returned/total bytes, next offset, + EOF, and whether the retained object is pending in the current Turn or + committed and available. Unknown, expired, malformed, and out-of-range ids + are clean generated errors; ids are never paths. +3. **write** `{path, content}` (required: both) — create or fully overwrite; atomic (temp + fsync + rename), preserving an existing file's mode; description steers: prefer `edit` for changes, don't create unasked documentation. Offered only when `allow_write` is on. -3. **edit** `{path, old_string, new_string}` (required: all) — replace +4. **edit** `{path, old_string, new_string}` (required: all) — replace exactly one occurrence; fail if `old_string` is absent or ambiguous; description steers: byte-exact match including indentation, keep the match minimal but unique. Offered only when `allow_write` is on. -4. **shell** `{command}` (required: command) — run via `/bin/ksh -c` - in the project directory; combined stdout+stderr, capped at - 256 KiB with a truncation marker; 30-second wall clock, then kill; +5. **shell** `{command}` (required: command) — run via `/bin/ksh -c` + in the project directory; combined stdout+stderr uses the 16 MiB Artifact + capture and generated-result policy above; 30-second wall clock, then kill; description must state plainly that there is no network access and name the web tools as the alternative, and warn about destructive commands. -5. **grep** `{pattern, path?}` — regex search over file contents +6. **grep** `{pattern, path?}` — regex search over file contents (recursive, line-numbered, binary-skipping, `grep -RnI` - semantics), output capped at 256 KiB. -6. **find** `{path?, name?}` — list files, optional glob filter, - capped at 256 KiB. -7. **ls** `{path?}` — long listing (`ls -la` semantics), capped at - 256 KiB. + semantics), under the same Artifact capture policy. +7. **find** `{path?, name?}` — list files, optional glob filter, under the + same Artifact capture policy. +8. **ls** `{path?}` — long listing (`ls -la` semantics), under the same + Artifact capture policy. Offered while `web_search` is on (default): -8. **web_search** `{query}` (required) — brokered web search returning +9. **web_search** `{query}` (required) — brokered web search returning titles, URLs, snippets (reference: Kagi, 10 results, per-field caps). The search provider sits behind a seam: `search_provider` names the backend, "kagi" is the one that exists. -9. **web_fetch** `{url}` (required) — HTTPS-only fetch returning +10. **web_fetch** `{url}` (required) — HTTPS-only fetch returning readable text (HTML reduced to text); raw body cap 2 MiB, returned text cap 256 KiB with truncation marker; 30-second deadline; does not follow redirects — a redirect status is reported to the model @@ -462,7 +480,7 @@ Offered while `web_search` is on (default): Offered only when `http_allow` is configured: -10. **http_request** `{url, method?, headers?, body?}` (required: +11. **http_request** `{url, method?, headers?, body?}` (required: url) — arbitrary-method HTTPS request with model-supplied headers and body to hosts passing the allow/block patterns (§6); response is the status plus body, non-2xx is a result rather than an @@ -470,11 +488,11 @@ Offered only when `http_allow` is configured: Offered when any model-invocable skill exists: -11. **skill** `{name}` (required) — §4. +12. **skill** `{name}` (required) — §4. New in this revision, offered while `max_subagents` > 0 (default): -12. **agent** `{prompt, label?, model?}` (required: prompt) — spawn a +13. **agent** `{prompt, label?, model?}` (required: prompt) — spawn a subagent: a fresh conversation with the same system conventions as the lead, whose task is the prompt and whose final assistant text is returned as the tool result. Constraints (also invariants.md @@ -523,7 +541,7 @@ New in this revision, offered while `max_subagents` > Offered when `allow_write` is on (with write and edit): -13. **multi_edit** `{path, edits}` (required: both) — `edits` is an +14. **multi_edit** `{path, edits}` (required: both) — `edits` is an array of 1–64 `{old_string, new_string}` objects applied in order to an in-memory copy of the file, each `old_string` required to match exactly once in the intermediate content produced by the @@ -538,7 +556,7 @@ Offered when `allow_write` is on (with write and edit) Offered while `max_bg_jobs` > 0 (default), never to subagents: -14. **shell_bg** `{command}` (required) — start a command under +15. **shell_bg** `{command}` (required) — start a command under exactly `shell`'s confinement and working directory, in its own process group, exempt from the 30-second clock; returns a job id. At the `max_bg_jobs` cap a further call returns a clean error @@ -547,11 +565,12 @@ Offered while `max_bg_jobs` > 0 (default), never to su (builds, test suites); poll with `shell_output` instead of re-running; kill jobs no longer needed; the same no-network warning as `shell` applies. -15. **shell_output** `{id}` (required) — return and consume the job's - accumulated stdout+stderr (bounded ring buffer, 256 KiB per poll, - overflow marked) plus running/exited status and the exit code - once exited. -16. **shell_kill** `{id}` (required) — terminate the job's process +16. **shell_output** `{id}` (required) — atomically freeze and return the + job's accumulated Artifact segment plus running/exited status and the exit + code once exited. A still-running job adopts a fresh 16 MiB segment; the + frozen segment follows the same inline/opaque-id policy above. Repeated + polls therefore advance immutable segments rather than overwriting a ring. +17. **shell_kill** `{id}` (required) — terminate the job's process group. Background jobs are session-scoped: a job spawned by a turn that @@ -638,7 +657,7 @@ Keywords (global or in `match` unless noted). Defaults | `web_search` yes\|no | offer web_search + web_fetch [yes] | | `search_provider` *string* | search backend name [kagi] | | `max_subagents` *number* | concurrent subagent cap; 0 withholds the agent tool [4] | -| `subagent_model` *string* | model id subagents use by default [unset: the lead's active model]; joins the agent tool's per-call palette (§5.12) | +| `subagent_model` *string* | model id subagents use by default [unset: the lead's active model]; joins the agent tool's per-call palette (§5.13) | | `subagent_provider` *string* | name of a configured provider block (or the implicit default) whose route and credential `subagent_model` is resolved against [the lead's active provider] | | `subagent_effort` *string* | exact child Generation policy [unset: inherit `effort`; both unset: Provider default] | | `max_bg_jobs` *number* | concurrent background shell jobs; 0 withholds shell_bg/shell_output/shell_kill [4] | blob - 282a7a9294c4609250a0ac8875ecd2a563b5c6e2 blob + 2d2bf3039539bec1b6b06011388c3eae84bc39f2 --- handoff/invariants.md +++ handoff/invariants.md @@ -144,7 +144,14 @@ them. MUST throughout. peer must produce a clean error or shutdown, not memory corruption. IPC uses `imsg(3)`. Whatever the topology, the design must make each role's *authority* — who can ask whom for what — explicit and - auditable in one place. + auditable in one place. Fugu's control topology is hub-and-spoke. Its sole + direct Worker data plane is a bounded one-way protocol over a pipe from the + persistent Lead Tool Role to the Artifact Role for one correlated output + stream; the Coordinator validates and relays the typed producer endpoint, + ordinary imsg traffic rejects descriptors, and no storage descriptor or + path crosses the boundary. OpenBSD exposes both pipe endpoints as `O_RDWR`, + so Role ownership — not a false write-only claim — fixes the direction. The + Subagent Tool Role never accepts this stream. - **I11 — least linkage.** Each executable links only the libraries its role needs: libtls only where TLS is spoken and curses only where the terminal is drawn. No ports library or language-runtime dependency may appear anywhere @@ -156,7 +163,9 @@ them. MUST throughout. escape the group — §4 records this non-guarantee). Session journals are the Owner's own files, mode 0600, under `~/.fugu/sessions/`; the role that writes them is confined to that - directory at the time it writes. + directory at the time it writes. Retained raw Tool output is stored under + the separate Owner-only managed root `~/.fugu/artifacts/`; only the Artifact + Role can open it, with mode-0700 Session directories and mode-0600 files. ### Construction requirements blob - a02345bf80cf8a1ddf2f55a57abc50758ab09fc8 blob + f61986f71509bbd3138c9bcdb0c69c885e25cd03 --- handoff/verification.md +++ handoff/verification.md @@ -175,7 +175,10 @@ states the property, not just examples: file byte-identical and names the index. - **Background jobs:** `max_bg_jobs` honored; jobs die on turn failure, cancellation, session end, and drawer switch; unknown and - prior-session ids error cleanly; ring-buffer overflow marked. + prior-session ids error cleanly. Repeated `shell_output` polls freeze exact + immutable Artifact segments, preserve unavailable reservations, and retire + available/unavailable spools through exact DROP/ACK, RELEASE, and job-wide + CANCEL/ACK races without reusing historical ownership. - **Retry policy:** transport retries never occur after the first content delta; `Retry-After` clamped to [1, 60] and parsed as untrusted (I13); cancellation during backoff leaves the journal clean. Separately, exact @@ -193,6 +196,16 @@ states the property, not just examples: - **Read paging:** offset/limit windows respect the byte cap against a newline-free 1 MiB line; totals and next-offset reported; zero and negative values rejected. +- **Artifact output paging and lifetime:** `artifact_result` computes the + complete serialized 256 KiB boundary and its +1 transition for printable, + escape-heavy, NUL, and invalid-UTF-8/base64 shapes. `artifact_page` pins raw + byte cursors, UTF-8 prefix behavior, base64 fidelity, and the 128 KiB page + cap. `artifact_store` covers pending/available/expired reads, 16 MiB stream, + 128 MiB Session and 1 GiB global quotas, reconciliation, eviction, and + transaction rollback. `turn` drives a real opaque id through Provider + `output_read`, Coordinator parsing/defaults, the Artifact role, an exact + NUL/invalid-byte base64 page, and the next Generation; a delayed response is + cancelled to prove its exact DATA/READ_RESULT drain precedes rollback. - **Edit-diff display:** the display cap and elision marker exercised; binary and over-cap content degrades to a summary; hostile diff bytes are length-checked at the role boundary (I13); blob - ea73a765a80c0fbb62e6ba01f1aa49dce9d7442a blob + 02d3a12f946b134c4795cbd4a232f25c2eca37f0 --- port/productivity/fugu/pkg/PLIST +++ port/productivity/fugu/pkg/PLIST @@ -6,6 +6,7 @@ @group libexec/fugu/ @bin libexec/fugu/fugu-api +@bin libexec/fugu/fugu-artifact @bin libexec/fugu/fugu-editor @bin libexec/fugu/fugu-tool @bin libexec/fugu/fugu-tty blob - bb8099191815ac9e733c651c79e7f16af2d0c280 blob + 377dee31422ad5ea47f3498150895217538f7689 --- regress/README +++ regress/README @@ -56,7 +56,12 @@ I10 mutual distrust across roles imsgev (partially con (a hostile second terminal cannot flip a committed turn; a tool subprocess cannot reach the - coordinator control fd); sandbox + coordinator control fd; normal exact + Artifact rotation/retirement and delayed + read frames cross the live role mesh); + tools (hostile Artifact rotation/drop/cancel + mechanics); artifact_store, artifact_page, + artifact_lock; sandbox (fdguard: closefrom closes the inherited control fd) I11 least linkage deploy (installed ELF NEEDED sets and @@ -74,7 +79,11 @@ I13 length-aware strings buf, json (NUL round-trip), sse (NUL transparency), anthropic, openai, http, web, output (NUL-bearing framing, headers, bodies, - and JSON emission); boundary-wide + and JSON emission); artifact_result + (whole-document printable/escape/NUL/base64 + boundaries), artifact_page (raw-byte text and + base64 cursors), turn (binary output_read); + boundary-wide property grows with each subsystem I14 resume fidelity journal (transactional replay: @@ -140,6 +149,10 @@ tools (behavior.md 5) agentcfg (agent schema and rou tools (executors, caps, freshness, NUL rejection, bounded background draining, supervisor/liveness cleanup); + artifact_result, artifact_page, artifact_store, + artifact_lock (brokered output retention and + paging); turn (opaque-id output_read loop and + cancelled exact-response drain); web (search/fetch/request policy, caps, credential scoping); turn (full file loop); agent (ephemeral- @@ -219,7 +232,15 @@ src/common/openai_req.c, src/fugu/parse.y, conf.c conf src/fugu/agentcfg.c agentcfg; agent (live schema/route) src/fugu/priv.c, coord.c sandbox (spawn/handshake/teardown), - turn, agent (lead and subagent turn loops) + turn (Lead loop and Coordinator Artifact + paging seam), agent (subagent turn loop) +src/fugu/artifact_result.c, + artifact_capture.c artifact_result; tools; turn +src/fugu/artifact_page.c artifact_page; turn (real output_read) +src/fugu/artifact_lock.c, + artifact_scope.c, + artifact_pipe.c artifact_lock; sandbox; turn +src/fugu-artifact (main.c, store.c) artifact_store; sandbox; turn src/fugu/journal.c journal, turn src/fugu/turn_txn.c turn_txn (pure ordering plus real-Journal integration); turn (cache/SIGINT path) blob - 48f9dca49e20ad3dff78f57dbfce466d63901fea blob + 610e69cacd582d67d25bb12662536703ab9cfc8d --- regress/agent/run.sh +++ regress/agent/run.sh @@ -59,7 +59,7 @@ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curv lx=$dir/libexec/fugu mkdir -p "$lx" -for w in fugu-tty fugu-api fugu-web fugu-tool fugu-editor; do +for w in fugu-tty fugu-api fugu-web fugu-tool fugu-artifact fugu-editor; do b=$root/src/$w/obj/$w [ -x "$b" ] || b=$root/src/$w/$w [ -x "$b" ] || { echo "FAIL - $w not built"; exit 1; } @@ -208,7 +208,7 @@ done ok "agent loop: child receives the complete read-only tool surface" \ "$readonly_ok" forbidden=0 -for name in edit write shell_bg shell_output agent skill http_request; do +for name in edit write shell_bg shell_output output_read agent skill http_request; do if grep -q "\"name\":\"$name\"" "$dir/child-initial"; then forbidden=1 fi blob - cd2e39e3a1622d80751e8bcebdf12cdda13a70ef blob + d2c0f0ef3da80f624fbc914e2aec87cec80886c5 --- regress/artifact_lock/artifact_lock_test.c +++ regress/artifact_lock/artifact_lock_test.c @@ -7,9 +7,11 @@ */ #include +#include #include #include +#include #include #include #include @@ -24,7 +26,7 @@ main(void) { char base[] = "/tmp/fugu-artifact-lock.XXXXXXXXXX"; char session[PATH_MAX], lock[PATH_MAX], wrong[PATH_MAX]; - int fd, ro, other; + int contender, duplicate, fd, ro, third, wrongfd; if (mkdtemp(base) == NULL || (size_t)snprintf(session, sizeof(session), "%s/session", base) >= @@ -34,6 +36,12 @@ main(void) (fd = open(lock, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600)) == -1) err(1, "create lock fixture"); CHECK(artifact_lifetime_lock_valid(fd, base, "session")); + contender = open(lock, O_RDWR | O_CLOEXEC); + CHECK(contender != -1 && + !artifact_lifetime_lock_valid(contender, base, "session")); + duplicate = fcntl(fd, F_DUPFD_CLOEXEC, 0); + CHECK(duplicate != -1 && + artifact_lifetime_lock_valid(duplicate, base, "session")); ro = open(lock, O_RDONLY | O_CLOEXEC); CHECK(ro != -1 && !artifact_lifetime_lock_valid(ro, base, "session")); @@ -42,17 +50,29 @@ main(void) if ((size_t)snprintf(wrong, sizeof(wrong), "%s/wrong", session) >= sizeof(wrong)) errx(1, "wrong-file fixture path is too long"); - other = open(wrong, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600); - CHECK(other != -1 && - !artifact_lifetime_lock_valid(other, base, "session")); - if (other != -1) - close(other); + wrongfd = open(wrong, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600); + CHECK(wrongfd != -1 && + !artifact_lifetime_lock_valid(wrongfd, base, "session")); + if (wrongfd != -1) + close(wrongfd); CHECK(fchmod(fd, 0644) == 0 && !artifact_lifetime_lock_valid(fd, base, "session")); CHECK(fchmod(fd, 0600) == 0 && artifact_lifetime_lock_valid(fd, base, "session")); + if (duplicate != -1) + close(duplicate); close(fd); + CHECK(contender != -1 && + artifact_lifetime_lock_valid(contender, base, "session")); + third = open(lock, O_RDWR | O_CLOEXEC); + errno = 0; + CHECK(third != -1 && flock(third, LOCK_EX | LOCK_NB) == -1 && + (errno == EWOULDBLOCK || errno == EAGAIN)); + if (third != -1) + close(third); + if (contender != -1) + close(contender); (void)unlink(wrong); (void)unlink(lock); (void)rmdir(session); blob - 65b8df500be7aa4ca4d5e29cc597f05f5c0d63b8 blob + ae44b759a6d7e7e67be0de4889def77679866826 --- regress/artifact_page/artifact_page_test.c +++ regress/artifact_page/artifact_page_test.c @@ -14,18 +14,61 @@ #include "artifact_page.h" #include "artifact_result.h" #include "buf.h" +#include "json.h" #include "proto.h" #include "regress.h" static const char id[] = "0123456789abcdef0123456789abcdef"; +static int +keys_exact(const struct buf *out, const char *const *want, size_t nwant) +{ + unsigned int seen = 0; + struct json j; + char *key; + size_t keylen, i; + int at, root, value, n, ok = 0; + + if (nwant > sizeof(seen) * 8 || + json_parse(&j, out->data, out->len, 0) == -1) + return (0); + root = json_root(&j); + n = json_obj_len(&j, root); + if (n < 0 || (size_t)n != nwant) + goto done; + for (at = 0; at < n; at++) { + if (json_obj_item(&j, root, at, &key, &keylen, &value) == -1) + goto done; + for (i = 0; i < nwant; i++) + if (strlen(want[i]) == keylen && + memcmp(key, want[i], keylen) == 0) + break; + free(key); + if (i == nwant || (seen & (1U << i)) != 0) + goto done; + seen |= 1U << i; + } + ok = 1; +done: + json_done(&j); + return (ok); +} + int main(void) { + static const char *const page_keys[] = { + "id", "offset", "returned_bytes", "total_bytes", "next_offset", + "eof", "encoding", "state", "data", "is_error" + }; + static const char *const retry_keys[] = { + "id", "offset", "encoding", "is_error", "error" + }; struct buf out; struct artifact_read_req req; struct artifact_read_result result; u_char invalid[] = { 0x80, 'x' }; + u_char invalid_after[] = { 'a', 0x80 }; u_char split[] = { 'a', 0xe2, 0x82 }; u_char *large; int retry; @@ -57,21 +100,38 @@ main(void) out.len <= ARTIFACT_RESULT_MAX && strstr(buf_cstr(&out), "\"data\":\"abc\"") != NULL && strstr(buf_cstr(&out), "\"eof\":true") != NULL); + CHECK(keys_exact(&out, page_keys, + sizeof(page_keys) / sizeof(page_keys[0]))); CHECK(artifact_page_build(&out, id, 7, 9, 0, invalid, sizeof(invalid), ARTIFACT_PAGE_TEXT, &retry) == 0 && retry && out.len <= ARTIFACT_RESULT_MAX && strstr(buf_cstr(&out), "retry") != NULL && strstr(buf_cstr(&out), "base64") != NULL); + CHECK(keys_exact(&out, retry_keys, + sizeof(retry_keys) / sizeof(retry_keys[0]))); + CHECK(artifact_page_build(&out, id, 0, sizeof(invalid_after), 0, + invalid_after, sizeof(invalid_after), ARTIFACT_PAGE_TEXT, + &retry) == 0 && !retry && + strstr(buf_cstr(&out), "\"data\":\"a\"") != NULL && + strstr(buf_cstr(&out), "\"returned_bytes\":1") != NULL && + strstr(buf_cstr(&out), "\"next_offset\":1") != NULL); + CHECK(artifact_page_build(&out, id, 1, sizeof(invalid_after), 0, + invalid_after + 1, 1, ARTIFACT_PAGE_TEXT, &retry) == 0 && retry); /* A valid lead byte split by the requested ceiling advances only through - * the complete prefix; an incomplete sequence at EOF is invalid. */ + * the complete prefix. At EOF that prefix is still returned; retrying at + * the incomplete sequence itself produces the base64 instruction. */ CHECK(artifact_page_build(&out, id, 0, 4, 1, split, sizeof(split), ARTIFACT_PAGE_TEXT, &retry) == 0 && !retry && strstr(buf_cstr(&out), "\"returned_bytes\":1") != NULL && strstr(buf_cstr(&out), "\"next_offset\":1") != NULL); CHECK(artifact_page_build(&out, id, 0, sizeof(split), 0, split, - sizeof(split), ARTIFACT_PAGE_TEXT, &retry) == 0 && retry); + sizeof(split), ARTIFACT_PAGE_TEXT, &retry) == 0 && !retry && + strstr(buf_cstr(&out), "\"returned_bytes\":1") != NULL && + strstr(buf_cstr(&out), "\"next_offset\":1") != NULL); + CHECK(artifact_page_build(&out, id, 1, sizeof(split), 0, split + 1, 2, + ARTIFACT_PAGE_TEXT, &retry) == 0 && retry); large = malloc(FUGU_ARTIFACT_PAGE_MAX); CHECK(large != NULL); blob - 006eb8ff4b201d90e5b8013013e710f4d499ff8e blob + e9e8de2e750b837e6cfee7a4a5018a4684f8cc2b --- regress/artifact_result/Makefile +++ regress/artifact_result/Makefile @@ -1,5 +1,6 @@ PROG= artifact_result_test -SRCS= artifact_result_test.c artifact_result.c artifact_capture.c json.c \ +SRCS= artifact_result_test.c artifact_result.c artifact_capture.c \ + artifact_scope.c json.c \ utf8.c buf.c log.c xmalloc.c CFLAGS+= -I${.CURDIR}/../../src/fugu blob - d407d2e47701c030541091407f2567fb2a235b32 blob + 1090a5bf0d5dc1f8c9e76e79baba43a936f5db9a --- regress/artifact_result/artifact_result_test.c +++ regress/artifact_result/artifact_result_test.c @@ -16,6 +16,7 @@ #include "artifact_result.h" #include "artifact_capture.h" +#include "artifact_scope.h" #include "buf.h" #include "json.h" #include "log.h" @@ -41,6 +42,32 @@ field_count(const struct json *j, int obj, const char return (count); } +static int +keys_exact(const struct json *j, int obj, const char *const *want, + size_t nwant) +{ + unsigned int seen = 0; + char *key; + size_t keylen, i; + int at, value, n = json_obj_len(j, obj); + + if (n < 0 || (size_t)n != nwant || nwant > sizeof(seen) * 8) + return (0); + for (at = 0; at < n; at++) { + if (json_obj_item(j, obj, at, &key, &keylen, &value) == -1) + return (0); + for (i = 0; i < nwant; i++) + if (strlen(want[i]) == keylen && + memcmp(key, want[i], keylen) == 0) + break; + free(key); + if (i == nwant || (seen & (1U << i)) != 0) + return (0); + seen |= 1U << i; + } + return (1); +} + static char * string_field(const struct json *j, int root, const char *name, size_t *len) { @@ -50,6 +77,12 @@ string_field(const struct json *j, int root, const cha static void test_inline_and_forgery(void) { + static const char *const keys[] = { + "preview_head", "preview_tail", "observed_bytes", + "retained_bytes", "preview_abbreviated", + "artifact_cap_reached", "termination", "preview_encoding", + "is_error", "exit_status" + }; static const char raw[] = "ok\n\",\"id\":\"forged\",\"is_error\":true"; struct artifact_result in; struct json j; @@ -70,6 +103,7 @@ test_inline_and_forgery(void) CHECK(out.len <= ARTIFACT_RESULT_MAX); CHECK(json_parse(&j, out.data, out.len, 0) == 0); root = json_root(&j); + CHECK(keys_exact(&j, root, keys, sizeof(keys) / sizeof(keys[0]))); CHECK(json_obj_get(&j, root, "id") == -1); CHECK(field_count(&j, root, "is_error") == 1); CHECK(field_count(&j, root, "observed_bytes") == 1); @@ -85,6 +119,12 @@ static void test_encoded_budgets_and_id(void) { static const char id[] = "0123456789abcdef0123456789abcdef"; + static const char *const keys[] = { + "id", "preview_head", "preview_tail", "observed_bytes", + "retained_bytes", "preview_abbreviated", + "artifact_cap_reached", "termination", "preview_encoding", + "is_error", "exit_status" + }; struct artifact_result in; struct json j; struct buf raw, out; @@ -107,6 +147,7 @@ test_encoded_budgets_and_id(void) CHECK(out.len <= ARTIFACT_RESULT_MAX); CHECK(json_parse(&j, out.data, out.len, 0) == 0); root = json_root(&j); + CHECK(keys_exact(&j, root, keys, sizeof(keys) / sizeof(keys[0]))); head = string_field(&j, root, "preview_head", &hlen); tail = string_field(&j, root, "preview_tail", &tlen); encoding = string_field(&j, root, "preview_encoding", &elen); @@ -183,6 +224,83 @@ test_boundary_selection(void) } static void +test_exact_inline_boundaries(void) +{ + static const struct { + const char *name; + u_char byte; + const char *encoding; + size_t encoded_step; + } shapes[] = { + { "printable", 'x', "utf-8", 1 }, + { "escape-heavy", '\1', "utf-8", 6 }, + { "NUL", '\0', "utf-8", 6 }, + { "invalid UTF-8/base64", 0xff, "base64", 4 } + }; + struct artifact_result in; + struct json j; + struct buf out; + char *encoding; + u_char *raw; + size_t elen, hi, i, lo, mid; + int root; + + raw = malloc(ARTIFACT_RESULT_MAX + 1); + CHECK(raw != NULL); + if (raw == NULL) + return; + buf_init(&out); + for (i = 0; i < sizeof(shapes) / sizeof(shapes[0]); i++) { + memset(raw, shapes[i].byte, ARTIFACT_RESULT_MAX + 1); + lo = 0; + hi = ARTIFACT_RESULT_MAX + 1; + while (lo + 1 < hi) { + mid = lo + (hi - lo) / 2; + if (artifact_result_inline_fits(raw, mid)) + lo = mid; + else + hi = mid; + } + CHECK(lo != 0 && artifact_result_inline_fits(raw, lo)); + CHECK(!artifact_result_inline_fits(raw, lo + 1)); + + memset(&in, 0, sizeof(in)); + in.raw = raw; + in.raw_len = lo; + in.observed_bytes = lo; + in.retained_bytes = lo; + in.termination = ARTIFACT_TERM_EXIT; + CHECK(artifact_result_build(&out, &in) == 0 && + out.len <= ARTIFACT_RESULT_MAX && + ARTIFACT_RESULT_MAX - out.len < shapes[i].encoded_step); + CHECK(json_parse(&j, out.data, out.len, 0) == 0); + root = json_root(&j); + CHECK(json_get_bool(&j, json_obj_get(&j, root, + "preview_abbreviated")) == 0); + encoding = string_field(&j, root, "preview_encoding", &elen); + CHECK(encoding != NULL && elen == strlen(shapes[i].encoding) && + memcmp(encoding, shapes[i].encoding, elen) == 0); + free(encoding); + json_done(&j); + + in.raw_len = lo + 1; + in.observed_bytes = lo + 1; + in.retained_bytes = lo + 1; + in.id = good_id; + CHECK(artifact_result_build(&out, &in) == 0 && + out.len <= ARTIFACT_RESULT_MAX); + CHECK(json_parse(&j, out.data, out.len, 0) == 0); + root = json_root(&j); + CHECK(json_get_bool(&j, json_obj_get(&j, root, + "preview_abbreviated")) == 1); + json_done(&j); + (void)shapes[i].name; + } + buf_free(&out); + free(raw); +} + +static void test_error_metadata_is_not_spoofable(void) { static const char detail[] = "spawn failed before capture"; @@ -394,6 +512,25 @@ test_streamed_equivalence(void) free(raw); } +static void +test_capture_scope_is_exact(void) +{ + static const char *included[] = { + "shell", "grep", "find", "ls", "shell_bg", "shell_output" + }; + static const char *excluded[] = { + "read", "write", "edit", "multi_edit", "shell_kill", + "output_read", "web_search", "web_fetch", "http_request", + "skill", "agent" + }; + size_t i; + + for (i = 0; i < sizeof(included) / sizeof(included[0]); i++) + CHECK(artifact_capture_tool_name(included[i])); + for (i = 0; i < sizeof(excluded) / sizeof(excluded[0]); i++) + CHECK(!artifact_capture_tool_name(excluded[i])); +} + int main(void) { @@ -402,11 +539,13 @@ main(void) test_encoded_budgets_and_id(); test_binary_base64(); test_boundary_selection(); + test_exact_inline_boundaries(); test_error_metadata_is_not_spoofable(); test_json_integer_range(); test_empty_streaming_capture_is_inline(); test_fixed_wire_id_validation(); test_escape_heavy_error_budget(); test_streamed_equivalence(); + test_capture_scope_is_exact(); REGRESS_END(); } blob - fb4bcb5e128ddfb756d0b0a464368112e5d40e92 blob + ecc4e21e840b8a0c66405e3b405f5bb7d4cd9d6c --- regress/artifact_store/Makefile +++ regress/artifact_store/Makefile @@ -1,6 +1,6 @@ PROG= artifact_store_test -SRCS= artifact_store_test.c store.c artifact_result.c json.c buf.c log.c \ - xmalloc.c +SRCS= artifact_store_test.c store.c artifact_lock.c artifact_result.c json.c \ + buf.c log.c xmalloc.c CFLAGS+= -I${.CURDIR}/../../src/fugu \ -I${.CURDIR}/../../src/fugu-artifact blob - ab2a9add66a46151f9a1347bf300b2b084f6ad34 blob + 00ff8f0b07ef8f1fe47232c0131c252992729b38 --- regress/artifact_store/artifact_store_test.c +++ regress/artifact_store/artifact_store_test.c @@ -23,6 +23,7 @@ #include #include "artifact_result.h" +#include "artifact_lock.h" #include "buf.h" #include "proto.h" #include "store.h" @@ -155,6 +156,19 @@ begin_stream_kind(struct artifact_store *store, uint32 write_fd, stream)); } +static struct artifact_store * +open_ready(const char *root, const char *session, int fresh) +{ + struct artifact_store *store; + + store = artifact_store_open(root, session, fresh); + if (store != NULL && artifact_store_reconcile_finish(store) == -1) { + artifact_store_close(store); + store = NULL; + } + return (store); +} + static int begin_stream(struct artifact_store *store, uint32_t correlation, struct artifact_pending **pending, int *write_fd, @@ -196,24 +210,80 @@ write_and_drain(struct artifact_pending *pending, int return (rc); } +#ifdef ARTIFACT_STORE_TESTING static void +test_reconciliation_state(const char *root) +{ + struct artifact_store *store; + struct artifact_pending *pending; + struct artifact_stream stream; + struct artifact_record record; + struct artifact_read_req req; + struct artifact_read_result result; + struct buf out; + char id[FUGU_ARTIFACT_ID_BUFSZ]; + int write_fd = -1; + + store = artifact_store_open(root, "reconcile-state", 1); + CHECK(store != NULL); + memset(&record, 0, sizeof(record)); + id_for(42, id); + strlcpy(record.id, id, sizeof(record.id)); + CHECK(artifact_store_turn_begin(store) == -1); + buf_init(&out); + memset(&req, 0, sizeof(req)); + strlcpy(req.id, id, sizeof(req.id)); + CHECK(artifact_store_read(store, &req, &out, &result) == -1); + CHECK(artifact_store_reconcile_add(store, &record) == 0); + artifact_store_test_fail(ARTIFACT_STORE_FAULT_RECONCILE_FSYNC); + CHECK(artifact_store_reconcile_finish(store) == -1); + /* Failed END leaves the initial phase retryable. */ + CHECK(artifact_store_reconcile_add(store, &record) == 0); + CHECK(artifact_store_reconcile_finish(store) == 0); + CHECK(artifact_store_reconcile_finish(store) == -1); + CHECK(artifact_store_reconcile_add(store, &record) == -1); + CHECK(artifact_store_read(store, &req, &out, &result) == 0 && + result.status == ARTIFACT_READ_EXPIRED); + + CHECK(artifact_store_turn_begin(store) == 0); + CHECK(begin_stream(store, 1, &pending, &write_fd, &stream) == 0); + /* Reconciliation can never be restarted around live staging. */ + CHECK(artifact_store_reconcile_add(store, &record) == -1); + CHECK(artifact_store_reconcile_finish(store) == -1); + close(write_fd); + CHECK(artifact_store_turn_abort(store) == 0); + buf_free(&out); + artifact_store_close(store); +} +#endif + +static void test_lock_and_permissions(const char *root) { struct artifact_store *store, *again; struct stat st; - char path[PATH_MAX]; + char path[PATH_MAX], alias[PATH_MAX], target[PATH_MAX], outside[PATH_MAX]; pid_t pid; int p[2], status; char result; store = artifact_store_open(root, "lock-session", 1); CHECK(store != NULL); + CHECK(stat(root, &st) == 0 && st.st_uid == geteuid() && + (st.st_mode & 0777) == 0700); path_for(path, sizeof(path), root, "lock-session", ".session.lock"); - CHECK(stat(path, &st) == 0 && (st.st_mode & 0777) == 0600); + CHECK(stat(path, &st) == 0 && st.st_uid == geteuid() && + (st.st_mode & 0777) == 0600 && st.st_nlink == 1); if ((size_t)snprintf(path, sizeof(path), "%s/%s", root, "lock-session") >= sizeof(path)) errx(1, "lock path"); - CHECK(stat(path, &st) == 0 && (st.st_mode & 0777) == 0700); + CHECK(stat(path, &st) == 0 && st.st_uid == geteuid() && + (st.st_mode & 0777) == 0700); + CHECK(artifact_store_open(root, "", 0) == NULL && errno == EINVAL); + CHECK(artifact_store_open(root, "../foreign", 0) == NULL && + errno == EINVAL); + CHECK(artifact_store_open(root, "bad/session", 0) == NULL && + errno == EINVAL); CHECK(artifact_store_open(root, "lock-session", 1) == NULL && errno == EEXIST); if (pipe(p) == -1) @@ -241,7 +311,8 @@ test_lock_and_permissions(const char *root) { int lock_copy = artifact_store_lock_dup(store); - CHECK(lock_copy != -1); + CHECK(lock_copy != -1 && artifact_lifetime_lock_valid(lock_copy, + root, "lock-session")); artifact_store_close(store); CHECK(artifact_store_open(root, "lock-session", 0) == NULL && errno == EWOULDBLOCK); @@ -250,6 +321,41 @@ test_lock_and_permissions(const char *root) again = artifact_store_open(root, "lock-session", 0); CHECK(again != NULL); artifact_store_close(again); + + /* Managed roots and Session names are exact directories, never aliases. */ + CHECK((size_t)snprintf(alias, sizeof(alias), "%s-root-alias", root) < + sizeof(alias)); + CHECK((size_t)snprintf(target, sizeof(target), "%s-root-target", root) < + sizeof(target)); + CHECK(mkdir(target, 0700) == 0 && symlink(target, alias) == 0); + CHECK(artifact_store_open(alias, "session", 1) == NULL); + + CHECK((size_t)snprintf(alias, sizeof(alias), "%s-session-alias", root) < + sizeof(alias)); + CHECK((size_t)snprintf(target, sizeof(target), "%s-session-target", root) < + sizeof(target)); + CHECK(mkdir(alias, 0700) == 0 && mkdir(target, 0700) == 0); + CHECK((size_t)snprintf(path, sizeof(path), "%s/session", alias) < + sizeof(path) && symlink(target, path) == 0); + CHECK(artifact_store_open(alias, "session", 0) == NULL); + + /* A hard-linked lock could alias another Owner file; exact nlink rejects it. */ + CHECK((size_t)snprintf(alias, sizeof(alias), "%s-lock-alias", root) < + sizeof(alias)); + mkdir_session(alias, "session"); + path_for(path, sizeof(path), alias, "session", ".session.lock"); + CHECK((size_t)snprintf(outside, sizeof(outside), "%s/outside", alias) < + sizeof(outside)); + { + int outside_fd = open(outside, + O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600); + + CHECK(outside_fd != -1); + if (outside_fd != -1) + close(outside_fd); + } + CHECK(unlink(path) == 0 && link(outside, path) == 0); + CHECK(artifact_store_open(alias, "session", 0) == NULL); } static void @@ -265,7 +371,7 @@ test_cap_and_transaction(const char *root) char committed[FUGU_ARTIFACT_ID_BUFSZ], aborted[FUGU_ARTIFACT_ID_BUFSZ]; int write_fd, rc; - store = artifact_store_open(root, "states", 1); + store = open_ready(root, "states", 1); CHECK(store != NULL && artifact_store_turn_begin(store) == 0); CHECK(begin_stream(store, 1, &pending, &write_fd, &stream) == 0 && stream.status == ARTIFACT_STREAM_AVAILABLE); @@ -330,6 +436,58 @@ test_cap_and_transaction(const char *root) } static void +test_session_read_isolation(const char *root) +{ + struct artifact_store *one, *two; + struct artifact_pending *pending; + struct artifact_stream stream; + struct artifact_terminal terminal; + struct artifact_record record; + struct artifact_read_req req; + struct artifact_read_result result; + struct buf out; + struct stat st; + char id[FUGU_ARTIFACT_ID_BUFSZ], source[PATH_MAX], alias[PATH_MAX]; + int write_fd; + + one = open_ready(root, "isolation-one", 1); + CHECK(one != NULL && artifact_store_turn_begin(one) == 0); + CHECK(begin_stream(one, 1, &pending, &write_fd, &stream) == 0 && + write_and_drain(pending, write_fd, 3, &terminal) == 1 && + artifact_store_turn_prepare(one) == 0 && + artifact_store_turn_commit(one) == 0); + strlcpy(id, stream.id, sizeof(id)); + path_for(source, sizeof(source), root, "isolation-one", id); + CHECK(stat(source, &st) == 0 && st.st_uid == geteuid() && + (st.st_mode & 0777) == 0600 && st.st_nlink == 1); + + two = artifact_store_open(root, "isolation-two", 1); + buf_init(&out); + memset(&req, 0, sizeof(req)); + strlcpy(req.id, id, sizeof(req.id)); + req.limit = 3; + /* Even corrupted replay knowledge cannot turn a same-Owner file alias + * into a cross-Session read: final symlinks and hard links are rejected. */ + memset(&record, 0, sizeof(record)); + strlcpy(record.id, id, sizeof(record.id)); + record.retained_bytes = 3; + CHECK(artifact_store_reconcile_add(two, &record) == 0 && + artifact_store_reconcile_finish(two) == 0); + path_for(alias, sizeof(alias), root, "isolation-two", id); + CHECK(symlink(source, alias) == 0 && + artifact_store_read(two, &req, &out, &result) == 0 && + result.status == ARTIFACT_READ_ERROR && out.len == 0 && + unlink(alias) == 0); + CHECK(link(source, alias) == 0 && + artifact_store_read(two, &req, &out, &result) == 0 && + result.status == ARTIFACT_READ_ERROR && out.len == 0 && + unlink(alias) == 0); + buf_free(&out); + artifact_store_close(two); + artifact_store_close(one); +} + +static void test_spool_ownership(const char *root) { struct artifact_store *store; @@ -342,7 +500,7 @@ test_spool_ownership(const char *root) char detached[FUGU_ARTIFACT_ID_BUFSZ]; int write_fd; - store = artifact_store_open(root, "spools", 1); + store = open_ready(root, "spools", 1); CHECK(store != NULL && artifact_store_turn_begin(store) == 0); CHECK(begin_stream_kind(store, 1, ARTIFACT_BEGIN_SPOOL, &pending, &write_fd, &stream) == 0); @@ -386,7 +544,7 @@ test_correlation_keys(const char *root) struct artifact_terminal terminal; int fdone, fdtwo, ignored_fd = -1; - store = artifact_store_open(root, "correlations", 1); + store = open_ready(root, "correlations", 1); CHECK(store != NULL && artifact_store_turn_begin(store) == 0); CHECK(begin_stream_kind(store, 10, ARTIFACT_BEGIN_SPOOL, &one, &fdone, &sone) == 0); @@ -425,7 +583,7 @@ test_publication_collision_and_prepared_close(const ch char id[FUGU_ARTIFACT_ID_BUFSZ], path[PATH_MAX], byte = 0; int fd, write_fd; - store = artifact_store_open(root, "publication", 1); + store = open_ready(root, "publication", 1); CHECK(store != NULL && artifact_store_turn_begin(store) == 0); CHECK(begin_stream(store, 1, &pending, &write_fd, &stream) == 0 && write_and_drain(pending, write_fd, 1, &terminal) == 1); @@ -483,7 +641,7 @@ test_terminal_storage_errors(const char *root) for (i = 0; i < sizeof(faults) / sizeof(faults[0]); i++) { (void)snprintf(session, sizeof(session), "terminal-%zu", i); - store = artifact_store_open(root, session, 1); + store = open_ready(root, session, 1); CHECK(store != NULL && artifact_store_turn_begin(store) == 0); CHECK(begin_stream_kind(store, 1, ARTIFACT_BEGIN_SPOOL, &pending, &write_fd, &stream) == 0); @@ -520,7 +678,7 @@ test_abort_retry(const char *root) for (i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { (void)snprintf(session, sizeof(session), "abort-%zu", i); - store = artifact_store_open(root, session, 1); + store = open_ready(root, session, 1); CHECK(store != NULL && artifact_store_turn_begin(store) == 0); CHECK(begin_stream(store, 1, &pending, &write_fd, &stream) == 0 && write_and_drain(pending, write_fd, 1, &terminal) == 1); @@ -541,6 +699,399 @@ test_abort_retry(const char *root) } static void +test_partial_publication_cleanup(const char *root) +{ + struct artifact_store *store; + struct artifact_pending *one, *two; + struct artifact_stream sone, stwo; + struct artifact_terminal terminal; + char pending[64], order[64], path[PATH_MAX], byte = 0; + int fd, fdone, fdtwo; + + store = open_ready(root, "partial-publish-abort", 1); + CHECK(store != NULL && artifact_store_turn_begin(store) == 0); + CHECK(begin_stream(store, 1, &one, &fdone, &sone) == 0 && + write_and_drain(one, fdone, 3, &terminal) == 1); + artifact_store_test_fail( + ARTIFACT_STORE_FAULT_PREPARE_STAGING_UNLINK); + CHECK(artifact_store_turn_prepare(store) == -1); + (void)snprintf(pending, sizeof(pending), ".pending-%s", sone.id); + (void)snprintf(order, sizeof(order), ".order-%s", sone.id); + CHECK(exists(root, "partial-publish-abort", sone.id) && + exists(root, "partial-publish-abort", pending)); + CHECK(artifact_store_turn_abort(store) == 0); + CHECK(!exists(root, "partial-publish-abort", sone.id) && + !exists(root, "partial-publish-abort", pending) && + !exists(root, "partial-publish-abort", order)); + artifact_store_close(store); + + /* A later collision can follow an earlier successful publication in the + * same prepare. Shutdown rolls the whole partial prepare back without + * deleting the colliding pre-existing final. */ + store = open_ready(root, "partial-publish-close", 1); + CHECK(store != NULL && artifact_store_turn_begin(store) == 0); + CHECK(begin_stream(store, 1, &one, &fdone, &sone) == 0 && + begin_stream(store, 2, &two, &fdtwo, &stwo) == 0); + CHECK(write_and_drain(one, fdone, 1, &terminal) == 1 && + write_and_drain(two, fdtwo, 2, &terminal) == 1); + path_for(path, sizeof(path), root, "partial-publish-close", sone.id); + fd = open(path, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600); + CHECK(fd != -1 && write(fd, "Z", 1) == 1); + if (fd != -1) + close(fd); + CHECK(artifact_store_turn_prepare(store) == -1 && + exists(root, "partial-publish-close", stwo.id)); + artifact_store_close(store); + (void)snprintf(pending, sizeof(pending), ".pending-%s", sone.id); + CHECK(!exists(root, "partial-publish-close", pending) && + !exists(root, "partial-publish-close", stwo.id)); + fd = open(path, O_RDONLY | O_CLOEXEC); + CHECK(fd != -1 && read(fd, &byte, 1) == 1 && byte == 'Z'); + if (fd != -1) + close(fd); +} + +static void +test_metadata_validation_and_nonblocking_opens(const char *root) +{ + struct artifact_store *store; + struct artifact_pending *pending = NULL; + struct artifact_stream stream; + struct artifact_terminal terminal; + struct artifact_record record; + struct artifact_read_req req; + struct stat st; + char subroot[PATH_MAX], path[PATH_MAX], outside[PATH_MAX]; + char id[FUGU_ARTIFACT_ID_BUFSZ], order[64]; + uint64_t marker = UINT64_C(0x1122334455667788), observed = 0; + pid_t pid; + mode_t oldmask; + int fd, status, write_fd = -1, rc; + + CHECK(mkdir(root, 0700) == 0); + + /* Creation reaches exact private modes even when the inherited Owner + * umask masks every permission bit. */ + (void)snprintf(subroot, sizeof(subroot), "%s/creation-umask", root); + store = open_ready(subroot, "session", 1); + CHECK(store != NULL && artifact_store_turn_begin(store) == 0 && + begin_stream(store, 1, &pending, &write_fd, &stream) == 0); + oldmask = umask(0777); + rc = write_and_drain(pending, write_fd, 1, &terminal); + (void)umask(oldmask); + CHECK(rc == 1 && terminal.status == ARTIFACT_STORE_OK); + (void)snprintf(path, sizeof(path), "%s/.sequence", subroot); + CHECK(stat(path, &st) == 0 && (st.st_mode & 0777) == 0600); + (void)snprintf(path, sizeof(path), "%s/session/.order-%s", subroot, + stream.id); + CHECK(stat(path, &st) == 0 && (st.st_mode & 0777) == 0600); + CHECK(artifact_store_turn_abort(store) == 0); + artifact_store_close(store); + + /* A preplanted hard link must not let sequence allocation chmod or write + * an arbitrary Owner file. */ + (void)snprintf(subroot, sizeof(subroot), "%s/sequence", root); + store = open_ready(subroot, "session", 1); + CHECK(store != NULL && artifact_store_turn_begin(store) == 0 && + begin_stream(store, 1, &pending, &write_fd, &stream) == 0); + (void)snprintf(outside, sizeof(outside), "%s/sequence-outside", root); + fd = open(outside, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0644); + CHECK(fd != -1 && fchmod(fd, 0644) == 0 && + write(fd, &marker, sizeof(marker)) == (ssize_t)sizeof(marker)); + if (fd != -1) + close(fd); + (void)snprintf(path, sizeof(path), "%s/.sequence", subroot); + CHECK(link(outside, path) == 0); + rc = write_and_drain(pending, write_fd, 1, &terminal); + CHECK(rc == -1 && terminal.status == ARTIFACT_STORE_ERROR); + fd = open(outside, O_RDONLY | O_CLOEXEC); + CHECK(fd != -1 && fstat(fd, &st) == 0 && + (st.st_mode & 0777) == 0644 && + pread(fd, &observed, sizeof(observed), 0) == + (ssize_t)sizeof(observed) && observed == marker); + if (fd != -1) + close(fd); + CHECK(artifact_store_turn_abort(store) == 0); + artifact_store_close(store); + + /* Existing order metadata is exact, including mode and link count. */ + (void)snprintf(subroot, sizeof(subroot), "%s/order-mode", root); + store = artifact_store_open(subroot, "session", 1); + id_for(100, id); + make_sparse(subroot, "session", id, 1, 1); + make_order(subroot, "session", id, 1); + (void)snprintf(order, sizeof(order), ".order-%s", id); + path_for(path, sizeof(path), subroot, "session", order); + CHECK(chmod(path, 0644) == 0); + memset(&record, 0, sizeof(record)); + strlcpy(record.id, id, sizeof(record.id)); + record.retained_bytes = 1; + CHECK(store != NULL && artifact_store_reconcile_add(store, &record) == 0 && + artifact_store_reconcile_finish(store) == 0 && + artifact_store_turn_begin(store) == 0); + rc = begin_stream(store, 1, &pending, &write_fd, &stream); + CHECK(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE); + if (rc == 0) + close(write_fd); + CHECK(artifact_store_turn_abort(store) == 0); + artifact_store_close(store); + + (void)snprintf(subroot, sizeof(subroot), "%s/order-link", root); + store = artifact_store_open(subroot, "session", 1); + id_for(101, id); + make_sparse(subroot, "session", id, 1, 1); + (void)snprintf(outside, sizeof(outside), "%s/order-outside", root); + fd = open(outside, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600); + CHECK(fd != -1 && write(fd, &marker, sizeof(marker)) == + (ssize_t)sizeof(marker)); + if (fd != -1) + close(fd); + (void)snprintf(order, sizeof(order), ".order-%s", id); + path_for(path, sizeof(path), subroot, "session", order); + CHECK(link(outside, path) == 0); + memset(&record, 0, sizeof(record)); + strlcpy(record.id, id, sizeof(record.id)); + record.retained_bytes = 1; + CHECK(store != NULL && artifact_store_reconcile_add(store, &record) == 0 && + artifact_store_reconcile_finish(store) == 0 && + artifact_store_turn_begin(store) == 0); + rc = begin_stream(store, 1, &pending, &write_fd, &stream); + CHECK(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE); + if (rc == 0) + close(write_fd); + CHECK(artifact_store_turn_abort(store) == 0); + artifact_store_close(store); + + /* FIFOs at read-only path boundaries return an error promptly rather than + * waiting for an attacker-controlled peer. */ + (void)snprintf(subroot, sizeof(subroot), "%s/order-fifo", root); + store = artifact_store_open(subroot, "session", 1); + id_for(102, id); + make_sparse(subroot, "session", id, 1, 1); + (void)snprintf(order, sizeof(order), ".order-%s", id); + path_for(path, sizeof(path), subroot, "session", order); + CHECK(mkfifo(path, 0600) == 0); + memset(&record, 0, sizeof(record)); + strlcpy(record.id, id, sizeof(record.id)); + record.retained_bytes = 1; + CHECK(store != NULL && artifact_store_reconcile_add(store, &record) == 0 && + artifact_store_reconcile_finish(store) == 0 && + artifact_store_turn_begin(store) == 0); + pid = fork(); + if (pid == 0) { + alarm(2); + rc = begin_stream(store, 1, &pending, &write_fd, &stream); + _exit(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE ? + 0 : 1); + } + CHECK(pid != -1 && waitpid(pid, &status, 0) == pid && + WIFEXITED(status) && WEXITSTATUS(status) == 0); + CHECK(artifact_store_turn_abort(store) == 0); + artifact_store_close(store); + + (void)snprintf(subroot, sizeof(subroot), "%s/final-fifo", root); + store = artifact_store_open(subroot, "session", 1); + id_for(103, id); + path_for(path, sizeof(path), subroot, "session", id); + CHECK(mkfifo(path, 0600) == 0); + memset(&record, 0, sizeof(record)); + strlcpy(record.id, id, sizeof(record.id)); + CHECK(store != NULL && artifact_store_reconcile_add(store, &record) == 0 && + artifact_store_reconcile_finish(store) == 0); + pid = fork(); + if (pid == 0) { + struct artifact_read_result result; + struct buf out; + + alarm(2); + buf_init(&out); + memset(&req, 0, sizeof(req)); + strlcpy(req.id, id, sizeof(req.id)); + _exit(artifact_store_read(store, &req, &out, &result) == 0 && + result.status == ARTIFACT_READ_ERROR ? 0 : 1); + } + CHECK(pid != -1 && waitpid(pid, &status, 0) == pid && + WIFEXITED(status) && WEXITSTATUS(status) == 0); + artifact_store_close(store); +} + +static void +fill_sparse_session(const char *root, const char *session, unsigned int base) +{ + char id[FUGU_ARTIFACT_ID_BUFSZ]; + int i; + + for (i = 0; i < 64; i++) { + id_for(base + (unsigned int)i, id); + make_sparse(root, session, id, ARTIFACT_RAW_MAX, 1 + i); + } +} + +static void +test_quota_metadata_and_inactive_cleanup(const char *root) +{ + struct artifact_store *store; + struct artifact_pending *pending = NULL; + struct artifact_stream stream; + struct artifact_record record; + struct stat st; + char subroot[PATH_MAX], path[PATH_MAX], outside[PATH_MAX]; + char id[FUGU_ARTIFACT_ID_BUFSZ], first[FUGU_ARTIFACT_ID_BUFSZ]; + char pending_name[64], order_name[64]; + int fd, write_fd = -1, rc; + + CHECK(mkdir(root, 0700) == 0); + + /* Every final counted by quota has the same exact private-file shape as + * a final admitted by output_read. */ + (void)snprintf(subroot, sizeof(subroot), "%s/final-mode", root); + store = artifact_store_open(subroot, "current", 1); + id_for(200, id); + make_sparse(subroot, "current", id, 1, 1); + path_for(path, sizeof(path), subroot, "current", id); + CHECK(chmod(path, 0644) == 0); + memset(&record, 0, sizeof(record)); + strlcpy(record.id, id, sizeof(record.id)); + record.retained_bytes = 1; + CHECK(store != NULL && artifact_store_reconcile_add(store, &record) == 0 && + artifact_store_reconcile_finish(store) == 0 && + artifact_store_turn_begin(store) == 0); + rc = begin_stream(store, 1, &pending, &write_fd, &stream); + CHECK(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE && + exists(subroot, "current", id)); + if (rc == 0) + close(write_fd); + CHECK(artifact_store_turn_abort(store) == 0); + artifact_store_close(store); + + (void)snprintf(subroot, sizeof(subroot), "%s/final-link", root); + store = artifact_store_open(subroot, "current", 1); + id_for(201, id); + (void)snprintf(outside, sizeof(outside), "%s/final-outside", root); + fd = open(outside, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600); + CHECK(fd != -1 && write(fd, "X", 1) == 1); + if (fd != -1) + close(fd); + path_for(path, sizeof(path), subroot, "current", id); + CHECK(link(outside, path) == 0); + memset(&record, 0, sizeof(record)); + strlcpy(record.id, id, sizeof(record.id)); + record.retained_bytes = 1; + CHECK(store != NULL && artifact_store_reconcile_add(store, &record) == 0 && + artifact_store_reconcile_finish(store) == 0 && + artifact_store_turn_begin(store) == 0); + rc = begin_stream(store, 1, &pending, &write_fd, &stream); + CHECK(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE && + exists(subroot, "current", id)); + if (rc == 0) + close(write_fd); + CHECK(artifact_store_turn_abort(store) == 0); + artifact_store_close(store); + + (void)snprintf(subroot, sizeof(subroot), "%s/final-oversize", root); + store = artifact_store_open(subroot, "current", 1); + id_for(202, id); + make_sparse(subroot, "current", id, ARTIFACT_RAW_MAX + 1, 1); + memset(&record, 0, sizeof(record)); + strlcpy(record.id, id, sizeof(record.id)); + record.retained_bytes = ARTIFACT_RAW_MAX; + CHECK(store != NULL && artifact_store_reconcile_add(store, &record) == 0 && + artifact_store_reconcile_finish(store) == 0 && + artifact_store_turn_begin(store) == 0); + rc = begin_stream(store, 1, &pending, &write_fd, &stream); + CHECK(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE && + exists(subroot, "current", id)); + if (rc == 0) + close(write_fd); + CHECK(artifact_store_turn_abort(store) == 0); + artifact_store_close(store); + + /* An inactive crash between final link and staging unlink is repaired as + * a distinct synchronized pass; its valid final and FIFO order survive. */ + (void)snprintf(subroot, sizeof(subroot), "%s/crash-link", root); + store = open_ready(subroot, "current", 1); + mkdir_session(subroot, "inactive"); + id_for(300, id); + make_sparse(subroot, "inactive", id, 1, 1); + path_for(path, sizeof(path), subroot, "inactive", id); + (void)snprintf(pending_name, sizeof(pending_name), ".pending-%s", id); + path_for(outside, sizeof(outside), subroot, "inactive", pending_name); + CHECK(link(path, outside) == 0); + make_order(subroot, "inactive", id, 1); + (void)snprintf(order_name, sizeof(order_name), ".order-%s", id); + CHECK(store != NULL && artifact_store_turn_begin(store) == 0); + artifact_store_test_fail(ARTIFACT_STORE_FAULT_INACTIVE_CLEANUP_FSYNC); + rc = begin_stream(store, 1, &pending, &write_fd, &stream); + CHECK(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE); + if (rc == 0) + close(write_fd); + rc = begin_stream(store, 2, &pending, &write_fd, &stream); + CHECK(rc == 0 && !exists(subroot, "inactive", pending_name) && + exists(subroot, "inactive", id) && + exists(subroot, "inactive", order_name)); + path_for(path, sizeof(path), subroot, "inactive", id); + CHECK(stat(path, &st) == 0 && st.st_nlink == 1); + if (rc == 0) + close(write_fd); + CHECK(artifact_store_turn_abort(store) == 0); + artifact_store_close(store); + + /* Invalid Session directories, names, and lifetime locks are never made + * eviction candidates even under global pressure. */ + (void)snprintf(subroot, sizeof(subroot), "%s/session-mode", root); + store = open_ready(subroot, "current", 1); + mkdir_session(subroot, "inactive"); + fill_sparse_session(subroot, "inactive", 1000); + (void)snprintf(path, sizeof(path), "%s/inactive", subroot); + CHECK(chmod(path, 0755) == 0); + id_for(1000, first); + CHECK(artifact_store_turn_begin(store) == 0); + rc = begin_stream(store, 1, &pending, &write_fd, &stream); + CHECK(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE && + exists(subroot, "inactive", first)); + if (rc == 0) + close(write_fd); + CHECK(artifact_store_turn_abort(store) == 0); + artifact_store_close(store); + + (void)snprintf(subroot, sizeof(subroot), "%s/session-name", root); + store = open_ready(subroot, "current", 1); + mkdir_session(subroot, "invalid_name"); + fill_sparse_session(subroot, "invalid_name", 2000); + id_for(2000, first); + CHECK(artifact_store_turn_begin(store) == 0); + rc = begin_stream(store, 1, &pending, &write_fd, &stream); + CHECK(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE && + exists(subroot, "invalid_name", first)); + if (rc == 0) + close(write_fd); + CHECK(artifact_store_turn_abort(store) == 0); + artifact_store_close(store); + + (void)snprintf(subroot, sizeof(subroot), "%s/session-lock", root); + store = open_ready(subroot, "current", 1); + mkdir_session(subroot, "inactive"); + fill_sparse_session(subroot, "inactive", 3000); + path_for(path, sizeof(path), subroot, "inactive", ".session.lock"); + CHECK(unlink(path) == 0); + (void)snprintf(outside, sizeof(outside), "%s/lock-outside", root); + fd = open(outside, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600); + CHECK(fd != -1); + if (fd != -1) + close(fd); + CHECK(link(outside, path) == 0); + id_for(3000, first); + CHECK(artifact_store_turn_begin(store) == 0); + rc = begin_stream(store, 1, &pending, &write_fd, &stream); + CHECK(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE && + exists(subroot, "inactive", first)); + if (rc == 0) + close(write_fd); + CHECK(artifact_store_turn_abort(store) == 0); + artifact_store_close(store); +} + +static void test_discard_retry_does_not_close_reused_fd(const char *root) { struct artifact_store *store; @@ -550,7 +1101,7 @@ test_discard_retry_does_not_close_reused_fd(const char char path[PATH_MAX]; int sentinel, write_fd; - store = artifact_store_open(root, "discard-retry", 1); + store = open_ready(root, "discard-retry", 1); CHECK(store != NULL && artifact_store_turn_begin(store) == 0); CHECK(begin_stream(store, 1, &pending, &write_fd, &stream) == 0 && write_and_drain(pending, write_fd, 1, &terminal) == 1); @@ -642,7 +1193,7 @@ spawn_quota_child(const char *root, unsigned int sessi close(result[0]); close(stop[1]); (void)snprintf(session, sizeof(session), "quota-%u", session_no); - store = artifact_store_open(root, session, 1); + store = open_ready(root, session, 1); if (store == NULL || artifact_store_turn_begin(store) == -1) _exit(2); (void)write(result[1], &ch, 1); @@ -737,9 +1288,9 @@ test_active_session_protection(const char *root) struct artifact_pending *pending; struct artifact_stream stream; char id[FUGU_ARTIFACT_ID_BUFSZ], first[FUGU_ARTIFACT_ID_BUFSZ]; - int write_fd, i; + int write_fd = -1, i; - current = artifact_store_open(root, "current", 1); + current = open_ready(root, "current", 1); CHECK(current != NULL); mkdir_session(root, "active"); id_for(900, id); @@ -799,7 +1350,7 @@ test_global_quota_current_first_and_sync(const char *r char inactive_id[FUGU_ARTIFACT_ID_BUFSZ]; int i, write_fd = -1; - current = artifact_store_open(root, "current", 1); + current = open_ready(root, "current", 1); CHECK(current != NULL); mkdir_session(root, "inactive"); id_for(2000, current_id); @@ -841,6 +1392,8 @@ main(void) { char base[] = "/tmp/fugu-artifact-store.XXXXXXXXXX"; char root[PATH_MAX], quota[PATH_MAX], active[PATH_MAX], priority[PATH_MAX]; + char metadata[PATH_MAX]; + char quota_metadata[PATH_MAX]; if (mkdtemp(base) == NULL) err(1, "mkdtemp"); @@ -848,15 +1401,25 @@ main(void) (void)snprintf(quota, sizeof(quota), "%s/quota", base); (void)snprintf(active, sizeof(active), "%s/active-root", base); (void)snprintf(priority, sizeof(priority), "%s/priority", base); + (void)snprintf(metadata, sizeof(metadata), "%s/metadata", base); + (void)snprintf(quota_metadata, sizeof(quota_metadata), + "%s/quota-metadata", base); test_lock_and_permissions(root); +#ifdef ARTIFACT_STORE_TESTING + test_reconciliation_state(root); +#endif test_cap_and_transaction(root); + test_session_read_isolation(root); test_spool_ownership(root); test_correlation_keys(root); test_publication_collision_and_prepared_close(root); #ifdef ARTIFACT_STORE_TESTING test_terminal_storage_errors(root); test_abort_retry(root); + test_partial_publication_cleanup(root); test_discard_retry_does_not_close_reused_fd(root); + test_metadata_validation_and_nonblocking_opens(metadata); + test_quota_metadata_and_inactive_cleanup(quota_metadata); #endif test_reconcile_and_fifo(root); test_concurrent_global_quota(quota); blob - a7bebf14d9e15b9ab01591791f276c797059063e blob + 056675df21b268029c06cafe23d7c4eef2f1562f --- regress/deploy/deploy_test.sh +++ regress/deploy/deploy_test.sh @@ -52,7 +52,7 @@ ok "layout: fugu is under PREFIX/bin" $? test "$(stat -f %Mp%Lp "$prefix/bin/fugu" 2>/dev/null)" = 2555 ok "layout: fugu is installed mode 2555" $? -workers='fugu-api fugu-editor fugu-tool fugu-tty fugu-web' +workers='fugu-api fugu-artifact fugu-editor fugu-tool fugu-tty fugu-web' missing=0 badmode=0 for worker in $workers; do @@ -60,7 +60,7 @@ for worker in $workers; do test "$(stat -f %Lp "$prefix/libexec/fugu/$worker" 2>/dev/null)" = \ 555 || badmode=1 done -ok "layout: all five workers are under PREFIX/libexec/fugu" "$missing" +ok "layout: all six workers are under PREFIX/libexec/fugu" "$missing" ok "layout: workers are ordinary mode-0555 executables" "$badmode" test -f "$prefix/man/man1/fugu.1" && \ @@ -120,8 +120,9 @@ test "$(needed "$prefix/libexec/fugu/fugu-api")" = "c test "$(needed "$prefix/libexec/fugu/fugu-web")" = "c event tls util" ok "linkage: only network custodians add tls" $? test "$(needed "$prefix/libexec/fugu/fugu-tool")" = "c event util" && \ + test "$(needed "$prefix/libexec/fugu/fugu-artifact")" = "c event util" && \ test "$(needed "$prefix/libexec/fugu/fugu-editor")" = "c event util" -ok "linkage: tool and editor map no tls or curses" $? +ok "linkage: tool, artifact, and editor map no tls or curses" $? for binary in "$prefix/bin/fugu" "$prefix/libexec/fugu"/*; do needed "$binary" blob - b6c459d77ce6bd555f8e8e3dca2f41fdab2e4bfd blob + ec864a3123dbff7336905ecbdae77c205e6a3c04 --- regress/journal/journal_test.c +++ regress/journal/journal_test.c @@ -457,6 +457,60 @@ test_epoch_retained_transactions(void) } static void +test_artifact_survives_epoch_resets(void) +{ + static const char artifact_id[] = + "0123456789abcdef0123456789abcdef"; + struct system_epoch start, compact, clear; + struct artifact_record record; + struct journal_state state; + struct journal *j; + char id[SESSION_ID_MAX]; + + make_epoch(&start, SYSTEM_EPOCH_START, NULL, 0); + make_epoch(&compact, SYSTEM_EPOCH_COMPACT, "retained", 8); + make_epoch(&clear, SYSTEM_EPOCH_CLEAR, NULL, 0); + memset(&record, 0, sizeof(record)); + strlcpy(record.id, artifact_id, sizeof(record.id)); + record.retained_bytes = 12345; + + j = journal_create(dir, id, sizeof(id)); + CHECK(j != NULL && + journal_system_epoch(j, &start, NULL, NULL, 0) == 0); + journal_turn_begin(j, 1); + journal_artifact(j, &record); + append_text_message(j, ROLE_USER, "artifact owner", 14); + append_text_message(j, ROLE_ASSISTANT, "artifact result", 15); + journal_turn_end(j, 1, TURN_OK); + CHECK(journal_system_epoch(j, &compact, NULL, NULL, 0) == 0); + journal_close(j); + + journal_state_init(&state); + CHECK(journal_restore(dir, id, &state) == 0 && + state.have_epoch && state.epoch.reason == SYSTEM_EPOCH_COMPACT && + state.nartifacts == 1 && + strcmp(state.artifacts[0].id, artifact_id) == 0 && + state.artifacts[0].retained_bytes == 12345); + journal_state_free(&state); + + j = journal_open(dir, id); + CHECK(j != NULL && + journal_system_epoch(j, &clear, NULL, NULL, 0) == 0); + journal_close(j); + journal_state_init(&state); + CHECK(journal_restore(dir, id, &state) == 0 && + state.have_epoch && state.epoch.reason == SYSTEM_EPOCH_CLEAR && + count(&state.messages) == 0 && state.nartifacts == 1 && + strcmp(state.artifacts[0].id, artifact_id) == 0 && + state.artifacts[0].retained_bytes == 12345); + journal_state_free(&state); + + system_epoch_free(&clear); + system_epoch_free(&compact); + system_epoch_free(&start); +} + +static void test_roundtrip(void) { struct journal *j; @@ -2040,6 +2094,7 @@ main(void) test_roundtrip(); test_system_epoch_roundtrip(); test_epoch_retained_transactions(); + test_artifact_survives_epoch_resets(); test_epoch_reasons_and_legacy(); test_epoch_malformed_and_bounds(); test_resume_fidelity(); blob - 1096a8e195db44bbff50609519907374c52cbb0d blob + 4a6a0b39bb69dddf944dcf9d6b66b9cfdd438de2 --- regress/openai/run.sh +++ regress/openai/run.sh @@ -28,7 +28,7 @@ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curv lx=$dir/libexec/fugu mkdir -p "$lx" "$dir/home" -for w in fugu-tty fugu-api fugu-web fugu-tool fugu-editor; do +for w in fugu-tty fugu-api fugu-web fugu-tool fugu-artifact fugu-editor; do b=$root/src/$w/obj/$w [ -x "$b" ] || b=$root/src/$w/$w [ -x "$b" ] || { echo "FAIL - $w not built"; exit 1; } blob - 75a7e52480e04e44c8e98f6876b9d9a109855c56 blob + f7eba65eae05bc013ec6f6c18228eef209360fa7 --- regress/print/run.sh +++ regress/print/run.sh @@ -48,7 +48,7 @@ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curv lx=$dir/libexec/fugu mkdir -p "$lx" -for worker in fugu-tty fugu-api fugu-web fugu-tool fugu-editor; do +for worker in fugu-tty fugu-api fugu-web fugu-tool fugu-artifact fugu-editor; do bin=$root/src/$worker/obj/$worker [ -x "$bin" ] || bin=$root/src/$worker/$worker [ -x "$bin" ] || { echo "FAIL - $worker not built"; exit 1; } blob - 4b9b9728466d28a8ebfb55104ce6689584699477 blob + 0c97435af5abb2ff9df5621fc131d3b2ae65447a --- regress/sandbox/Makefile +++ regress/sandbox/Makefile @@ -1,5 +1,5 @@ PROG= harness -SRCS= harness.c priv.c log.c xmalloc.c +SRCS= harness.c priv.c artifact_lock.c artifact_pipe.c log.c xmalloc.c NOMAN= yes .PATH: ${.CURDIR}/../../src/fugu ${.CURDIR}/../../src/common blob - fbde8b9464fceb0681f58f70fde07b85dd63e73e blob + 2b83b3a84f03afd1881af57da7905d73d2712fa4 --- regress/sandbox/harness.c +++ regress/sandbox/harness.c @@ -37,10 +37,12 @@ */ #include +#include #include #include #include +#include #include #include #include @@ -52,13 +54,28 @@ #include "log.h" #include "priv.h" #include "proto.h" +#include "artifact_lock.h" +#include "artifact_pipe.h" #define TEST_BAD_HELLO (-1) #define TEST_BAD_HELLO_ID (-2) #define TEST_BAD_PING (-3) #define TEST_BAD_PING_ID (-4) #define TEST_CLAUDE_MODELS (-5) +#define TEST_TOOL_DESCRIPTOR_READ (-6) +#define TEST_TOOL_SHELL_NO_STREAM (-7) +#define TEST_TOOL_REORDERED_STREAM (-8) +#define TEST_TOOL_SHELL (-9) +#define TEST_TOOL_FREEZE_SWAP (-10) +#define TEST_TOOL_FREEZE_TERMINAL (-11) +#define TEST_TOOL_FREEZE_UNAVAILABLE (-12) +#define TEST_TOOL_FREEZE_CANCEL (-13) +static const char artifact_id_one[] = + "0123456789abcdef0123456789abcdef"; +static const char artifact_id_two[] = + "fedcba9876543210fedcba9876543210"; + /* Blocking imsg read of one message of the expected type. */ static int expect_msg(struct imsgbuf *ibuf, uint32_t want, struct imsg *out) @@ -71,6 +88,8 @@ expect_msg(struct imsgbuf *ibuf, uint32_t want, struct return (-1); if (n != 0) { if (imsg_get_type(out) != want) { + warnx("expected imsg %u, got %u", want, + imsg_get_type(out)); imsg_free(out); return (-1); } @@ -78,10 +97,14 @@ expect_msg(struct imsgbuf *ibuf, uint32_t want, struct } pfd.fd = ibuf->fd; pfd.events = POLLIN; - if (poll(&pfd, 1, 5000) <= 0) + if (poll(&pfd, 1, 5000) <= 0) { + warnx("timed out waiting for imsg %u", want); return (-1); /* worker died or timed out */ - if ((n = imsgbuf_read(ibuf)) == -1 || n == 0) + } + if ((n = imsgbuf_read(ibuf)) == -1 || n == 0) { + warnx("worker closed while waiting for imsg %u", want); return (-1); + } } } @@ -102,6 +125,409 @@ send_msg(struct imsgbuf *ibuf, uint32_t type, const vo send_msg_id(ibuf, type, 0, data, len); } +static int +artifact_id_valid(const char id[FUGU_ARTIFACT_ID_BUFSZ]) +{ + size_t i; + + for (i = 0; i < FUGU_ARTIFACT_ID_LEN; i++) + if (!((id[i] >= '0' && id[i] <= '9') || + (id[i] >= 'a' && id[i] <= 'f'))) + return (0); + return (id[FUGU_ARTIFACT_ID_LEN] == '\0'); +} + +/* Exercise the real post-lockdown SCM_RIGHTS stream exchange. */ +static void +exercise_artifact_stream(struct imsgbuf *ibuf) +{ + static const char probe[] = "artifact-pipe-probe"; + struct artifact_turn_ack ack; + struct artifact_begin begin; + struct artifact_stream stream; + struct artifact_terminal terminal; + struct imsg imsg; + int fd, fdflags; + + send_msg(ibuf, FUGU_IMSG_ARTIFACT_TURN_BEGIN, NULL, 0); + if (expect_msg(ibuf, FUGU_IMSG_ARTIFACT_TURN_ACK, &imsg) == -1 || + imsg_get_len(&imsg) != sizeof(ack) || + imsg_get_id(&imsg) != 0 || + imsg_get_data(&imsg, &ack, sizeof(ack)) == -1 || + ack.control != FUGU_IMSG_ARTIFACT_TURN_BEGIN) + errx(2, "fugu-artifact sent malformed Turn begin ACK"); + imsg_free(&imsg); + + memset(&begin, 0, sizeof(begin)); + begin.kind = ARTIFACT_BEGIN_FOREGROUND; + send_msg_id(ibuf, FUGU_IMSG_ARTIFACT_BEGIN, 1, + &begin, sizeof(begin)); + if (expect_msg(ibuf, FUGU_IMSG_ARTIFACT_STREAM, &imsg) == -1 || + imsg_get_len(&imsg) != sizeof(stream) || + imsg_get_id(&imsg) != 1 || + imsg_get_data(&imsg, &stream, sizeof(stream)) == -1 || + stream.status != ARTIFACT_STREAM_AVAILABLE || + !artifact_id_valid(stream.id) || + (fd = imsg_get_fd(&imsg)) == -1 || + (fdflags = fcntl(fd, F_GETFD)) == -1 || + fcntl(fd, F_SETFD, fdflags | FD_CLOEXEC) == -1 || + !artifact_producer_endpoint_valid(fd)) + errx(2, "fugu-artifact sent malformed producer endpoint"); + imsg_free(&imsg); + if (write(fd, probe, sizeof(probe) - 1) != + (ssize_t)(sizeof(probe) - 1)) + errx(2, "write Artifact producer endpoint"); + close(fd); + + if (expect_msg(ibuf, FUGU_IMSG_ARTIFACT_TERMINAL, &imsg) == -1 || + imsg_get_len(&imsg) != sizeof(terminal) || + imsg_get_id(&imsg) != 1 || + imsg_get_data(&imsg, &terminal, sizeof(terminal)) == -1 || + terminal.status != ARTIFACT_STORE_OK || + terminal.retained_bytes != sizeof(probe) - 1 || + memcmp(terminal.id, stream.id, sizeof(stream.id)) != 0) + errx(2, "fugu-artifact sent malformed stream terminal"); + imsg_free(&imsg); + + send_msg(ibuf, FUGU_IMSG_ARTIFACT_TURN_ABORT, NULL, 0); + if (expect_msg(ibuf, FUGU_IMSG_ARTIFACT_TURN_ACK, &imsg) == -1 || + imsg_get_len(&imsg) != sizeof(ack) || + imsg_get_id(&imsg) != 0 || + imsg_get_data(&imsg, &ack, sizeof(ack)) == -1 || + ack.control != FUGU_IMSG_ARTIFACT_TURN_ABORT) + errx(2, "fugu-artifact sent malformed Turn abort ACK"); + imsg_free(&imsg); +} + +static void +send_msg_fd_id(struct imsgbuf *ibuf, uint32_t type, uint32_t id, int fd, + const void *data, size_t len) +{ + if (imsg_compose(ibuf, type, id, -1, fd, data, len) == -1) + errx(2, "descriptor imsg_compose"); + if (imsgbuf_flush(ibuf) == -1) + errx(2, "descriptor imsgbuf_flush"); +} + +static int +send_artifact_stream_status(struct imsgbuf *ibuf, uint32_t id, + const char *artifact_id, int available) +{ + struct artifact_stream stream; + int p[2]; + + memset(&stream, 0, sizeof(stream)); + stream.status = available ? ARTIFACT_STREAM_AVAILABLE : + ARTIFACT_STREAM_UNAVAILABLE; + if (!available) { + send_msg_id(ibuf, FUGU_IMSG_TOOL_ARTIFACT_STREAM, id, + &stream, sizeof(stream)); + return (-1); + } + if (pipe2(p, O_CLOEXEC) == -1) + errx(2, "Artifact fixture pipe"); + strlcpy(stream.id, artifact_id, sizeof(stream.id)); + send_msg_fd_id(ibuf, FUGU_IMSG_TOOL_ARTIFACT_STREAM, id, p[1], + &stream, sizeof(stream)); + return (p[0]); +} + +static int +send_artifact_stream(struct imsgbuf *ibuf, uint32_t id) +{ + return (send_artifact_stream_status(ibuf, id, artifact_id_one, 1)); +} + +static void +send_tool_req(struct imsgbuf *ibuf, uint32_t id, const char *name) +{ + struct tool_req req; + + memset(&req, 0, sizeof(req)); + strlcpy(req.name, name, sizeof(req.name)); + send_msg_id(ibuf, FUGU_IMSG_TOOL_REQ, id, &req, sizeof(req)); +} + +static void +send_tool_call(struct imsgbuf *ibuf, uint32_t id, const char *name, + const char *arg) +{ + send_tool_req(ibuf, id, name); + if (arg != NULL) + send_msg_id(ibuf, FUGU_IMSG_TOOL_ARG, id, arg, strlen(arg)); + send_msg_id(ibuf, FUGU_IMSG_TOOL_RUN, id, NULL, 0); +} + +static int +tool_turn_control(struct imsgbuf *ibuf, uint32_t control) +{ + struct artifact_turn_ack ack; + struct imsg imsg; + int ok; + + send_msg(ibuf, control, NULL, 0); + if (expect_msg(ibuf, FUGU_IMSG_TOOL_TURN_ACK, &imsg) == -1) + return (-1); + ok = imsg_get_id(&imsg) == 0 && + imsg_get_len(&imsg) == sizeof(ack) && + imsg_get_data(&imsg, &ack, sizeof(ack)) == 0 && + ack.control == control; + imsg_free(&imsg); + return (ok ? 0 : -1); +} + +static int +expect_tool_success(struct imsgbuf *ibuf, uint32_t id, int artifact) +{ + struct tool_result result; + struct imsg imsg; + u_char *p; + size_t len; + int ok; + + if (expect_msg(ibuf, FUGU_IMSG_TOOL_OUT, &imsg) == -1 || + imsg_get_id(&imsg) != id) + return (-1); + len = imsg_get_len(&imsg); + p = len == 0 ? NULL : malloc(len); + if (len != 0 && (p == NULL || imsg_get_data(&imsg, p, len) == -1)) { + free(p); + imsg_free(&imsg); + return (-1); + } + ok = p != NULL && memmem(p, len, "scope-ok", 8) != NULL; + free(p); + imsg_free(&imsg); + if (!ok || expect_msg(ibuf, FUGU_IMSG_TOOL_RESULT, &imsg) == -1) + return (-1); + if (imsg_get_id(&imsg) != id || imsg_get_len(&imsg) != sizeof(result) || + imsg_get_data(&imsg, &result, sizeof(result)) == -1) { + imsg_free(&imsg); + return (-1); + } + imsg_free(&imsg); + return (!result.is_error && !!result.artifact == !!artifact && + !result.spool_frozen ? 0 : -1); +} + +/* A managed shell_output publishes its ownership frame before ordinary + * output. want_adopted == -1 proves that no frame preceded the result. */ +static int +expect_tool_artifact_result(struct imsgbuf *ibuf, uint32_t id, + int want_adopted, int want_detach, uint32_t job_id, const char *frozen_id, + const char *replacement_id, struct tool_result *result) +{ + static const char zero[FUGU_ARTIFACT_ID_BUFSZ]; + struct artifact_spool_rotate rotate; + struct imsg imsg; + const char *want_frozen = frozen_id == NULL ? zero : frozen_id; + const char *want_replacement = replacement_id == NULL ? zero : + replacement_id; + int ok; + + if (want_adopted != -1) { + if (expect_msg(ibuf, FUGU_IMSG_TOOL_ARTIFACT_ROTATE, &imsg) == -1) + return (-1); + ok = imsg_get_id(&imsg) == id && + imsg_get_len(&imsg) == sizeof(rotate) && + imsg_get_data(&imsg, &rotate, sizeof(rotate)) == 0 && + rotate.job_id == job_id && + rotate.replacement_adopted == (uint32_t)want_adopted && + rotate.detach == (uint32_t)want_detach && + memcmp(rotate.frozen_id, want_frozen, + sizeof(rotate.frozen_id)) == 0 && + memcmp(rotate.replacement_id, want_replacement, + sizeof(rotate.replacement_id)) == 0; + imsg_free(&imsg); + if (!ok) { + warnx("wrong freeze frame: job=%u adopted=%u detach=%u " + "frozen=%s replacement=%s", rotate.job_id, + rotate.replacement_adopted, rotate.detach, + rotate.frozen_id, rotate.replacement_id); + return (-1); + } + } + if (expect_msg(ibuf, FUGU_IMSG_TOOL_OUT, &imsg) == -1) + return (-1); + ok = imsg_get_id(&imsg) == id; + imsg_free(&imsg); + if (!ok || expect_msg(ibuf, FUGU_IMSG_TOOL_RESULT, &imsg) == -1) + return (-1); + memset(result, 0, sizeof(*result)); + ok = imsg_get_id(&imsg) == id && + imsg_get_len(&imsg) == sizeof(*result) && + imsg_get_data(&imsg, result, sizeof(*result)) == 0; + imsg_free(&imsg); + return (ok ? 0 : -1); +} + +static int +send_artifact_cancel(struct imsgbuf *ibuf, uint32_t job_id, const char *id) +{ + struct artifact_job_cancel_ack ack; + struct artifact_record record; + struct imsg imsg; + int ok; + + memset(&record, 0, sizeof(record)); + strlcpy(record.id, id, sizeof(record.id)); + send_msg_id(ibuf, FUGU_IMSG_TOOL_ARTIFACT_CANCEL, job_id, &record, + sizeof(record)); + if (expect_msg(ibuf, FUGU_IMSG_TOOL_ARTIFACT_CANCEL_ACK, &imsg) == -1) + return (-1); + ok = imsg_get_id(&imsg) == job_id && + imsg_get_len(&imsg) == sizeof(ack) && + imsg_get_data(&imsg, &ack, sizeof(ack)) == 0 && + ack.job_id == job_id && strcmp(ack.artifact_id, id) == 0; + imsg_free(&imsg); + return (ok ? 0 : -1); +} + +static int +test_tool_freeze_frames(struct imsgbuf *ibuf, pid_t worker_pid, int action) +{ + static const char zero[FUGU_ARTIFACT_ID_BUFSZ]; + struct artifact_record record; + struct tool_result result; + char arg[64]; + const char *command; + pid_t notifier = -1; + uint32_t bg_id = 101, poll_id = 102, job_id; + int old_fd = -1, replacement_fd = -1, status; + int old_available = action != TEST_TOOL_FREEZE_UNAVAILABLE; + int rc = -1; + + command = action == TEST_TOOL_FREEZE_CANCEL ? + "{\"command\":\"yes x\"}" : + action == TEST_TOOL_FREEZE_SWAP ? + "{\"command\":\"printf swap; sleep 30\"}" : + "{\"command\":\"/usr/bin/true\"}"; + if (tool_turn_control(ibuf, FUGU_IMSG_TOOL_TURN_BEGIN) == -1) + goto done; + old_fd = send_artifact_stream_status(ibuf, bg_id, artifact_id_one, + old_available); + send_tool_call(ibuf, bg_id, "shell_bg", command); + if (expect_tool_artifact_result(ibuf, bg_id, -1, 0, 0, NULL, NULL, + &result) == -1 || result.is_error || result.spool_frozen || + !result.spool_adopted || + result.background_job_id <= 0) { + warnx("wrong shell_bg result: error=%d frozen=%d adopted=%d job=%d", + result.is_error, result.spool_frozen, result.spool_adopted, + result.background_job_id); + goto done; + } + job_id = (uint32_t)result.background_job_id; + if (tool_turn_control(ibuf, FUGU_IMSG_TOOL_TURN_COMMIT) == -1) + goto done; + usleep(action == TEST_TOOL_FREEZE_CANCEL ? 700000 : 350000); + if (tool_turn_control(ibuf, FUGU_IMSG_TOOL_TURN_BEGIN) == -1) + goto done; + replacement_fd = send_artifact_stream_status(ibuf, poll_id, + artifact_id_two, 1); + (void)snprintf(arg, sizeof(arg), "{\"id\":%u}", job_id); + if (action == TEST_TOOL_FREEZE_CANCEL) { + notifier = fork(); + if (notifier == -1) + goto done; + if (notifier == 0) { + usleep(200000); + (void)kill(worker_pid, SIGUSR1); + _exit(0); + } + } + send_tool_call(ibuf, poll_id, "shell_output", arg); + if (action == TEST_TOOL_FREEZE_SWAP) { + if (expect_tool_artifact_result(ibuf, poll_id, 1, 1, job_id, + artifact_id_one, artifact_id_two, &result) == -1 || + result.is_error || !result.spool_frozen || + !result.spool_adopted || + result.background_job_id != (int)job_id) { + warnx("wrong swap result: error=%d frozen=%d adopted=%d job=%d", + result.is_error, result.spool_frozen, + result.spool_adopted, result.background_job_id); + goto done; + } + } else if (action == TEST_TOOL_FREEZE_TERMINAL) { + if (expect_tool_artifact_result(ibuf, poll_id, 0, 0, job_id, + artifact_id_one, NULL, &result) == -1 || result.is_error || + !result.spool_frozen || result.spool_adopted || + result.background_job_id != (int)job_id || + strcmp(result.artifact_id, artifact_id_one) != 0) { + warnx("wrong available terminal result"); + goto done; + } + } else if (action == TEST_TOOL_FREEZE_UNAVAILABLE) { + if (expect_tool_artifact_result(ibuf, poll_id, 0, 0, job_id, + NULL, NULL, &result) == -1 || result.is_error || + !result.spool_frozen || result.spool_adopted || + result.background_job_id != (int)job_id || + memcmp(result.artifact_id, zero, + sizeof(result.artifact_id)) != 0) { + warnx("wrong unavailable terminal result"); + goto done; + } + } else { + if (expect_tool_artifact_result(ibuf, poll_id, -1, 0, job_id, + NULL, NULL, &result) == -1 || result.is_error || + result.spool_frozen || result.spool_adopted || + result.background_job_id != (int)job_id) { + warnx("wrong cancelled pre-freeze result"); + goto done; + } + } + if (notifier != -1) { + if (waitpid(notifier, &status, 0) == -1 || !WIFEXITED(status) || + WEXITSTATUS(status) != 0) + goto done; + notifier = -1; + } + if (action == TEST_TOOL_FREEZE_TERMINAL) { + memset(&record, 0, sizeof(record)); + strlcpy(record.id, artifact_id_one, sizeof(record.id)); + send_msg_id(ibuf, FUGU_IMSG_TOOL_ARTIFACT_RELEASE, job_id, + &record, sizeof(record)); + } + if (tool_turn_control(ibuf, FUGU_IMSG_TOOL_TURN_ABORT) == -1) + goto done; + if (action == TEST_TOOL_FREEZE_SWAP) { + if (send_artifact_cancel(ibuf, job_id, artifact_id_two) == -1) + goto done; + } else if (action == TEST_TOOL_FREEZE_CANCEL) { + if (send_artifact_cancel(ibuf, job_id, artifact_id_one) == -1) + goto done; + } + rc = 0; +done: + if (notifier > 0) { + (void)kill(notifier, SIGTERM); + (void)waitpid(notifier, NULL, 0); + } + if (old_fd != -1) + close(old_fd); + if (replacement_fd != -1) + close(replacement_fd); + return (rc); +} + +static int +worker_rejected(pid_t pid) +{ + int i, status; + pid_t rc; + + for (i = 0; i < 200; i++) { + rc = waitpid(pid, &status, WNOHANG); + if (rc == pid) + return (WIFEXITED(status) && WEXITSTATUS(status) == 1); + if (rc == -1) + return (0); + usleep(10000); + } + (void)kill(pid, SIGKILL); + (void)waitpid(pid, &status, 0); + return (0); +} + static enum fugu_role role_of(const char *s) { @@ -113,6 +539,8 @@ role_of(const char *s) return (ROLE_WEB); if (strcmp(s, "fugu-tool") == 0) return (ROLE_TOOL); + if (strcmp(s, "fugu-artifact") == 0) + return (ROLE_ARTIFACT); if (strcmp(s, "fugu-editor") == 0) return (ROLE_EDIT); errx(1, "unknown role %s", s); @@ -143,6 +571,22 @@ action_of(const char *s) return (TEST_BAD_PING_ID); if (strcmp(s, "claude_models") == 0) return (TEST_CLAUDE_MODELS); + if (strcmp(s, "tool_descriptor_read") == 0) + return (TEST_TOOL_DESCRIPTOR_READ); + if (strcmp(s, "tool_shell_no_stream") == 0) + return (TEST_TOOL_SHELL_NO_STREAM); + if (strcmp(s, "tool_reordered_stream") == 0) + return (TEST_TOOL_REORDERED_STREAM); + if (strcmp(s, "tool_shell") == 0) + return (TEST_TOOL_SHELL); + if (strcmp(s, "tool_freeze_swap") == 0) + return (TEST_TOOL_FREEZE_SWAP); + if (strcmp(s, "tool_freeze_terminal") == 0) + return (TEST_TOOL_FREEZE_TERMINAL); + if (strcmp(s, "tool_freeze_unavailable") == 0) + return (TEST_TOOL_FREEZE_UNAVAILABLE); + if (strcmp(s, "tool_freeze_cancel") == 0) + return (TEST_TOOL_FREEZE_CANCEL); if (strcmp(s, "none") == 0) return (0); errx(1, "unknown action %s", s); @@ -191,6 +635,7 @@ main(int argc, char *argv[]) errx(2, "spawn %s", rolename); if (imsgbuf_init(&ibuf, kid.fd) == -1) errx(2, "imsgbuf_init"); + imsgbuf_allow_fdpass(&ibuf); if (action == TEST_BAD_HELLO || action == TEST_BAD_HELLO_ID) { if (action == TEST_BAD_HELLO) send_msg(&ibuf, FUGU_IMSG_HELLO, &byte, sizeof(byte)); @@ -246,6 +691,7 @@ main(int argc, char *argv[]) memset(&h, 0, sizeof(h)); h.allow_write = !nowrite; h.allow_subprocess_net = 0; + h.max_bg_jobs = 4; send_msg(&ibuf, FUGU_IMSG_HELLO, &h, sizeof(h)); } else if (role == ROLE_WEB) { struct hello_web h; @@ -253,6 +699,35 @@ main(int argc, char *argv[]) memset(&h, 0, sizeof(h)); strlcpy(h.search_provider, "kagi", sizeof(h.search_provider)); send_msg(&ibuf, FUGU_IMSG_HELLO, &h, sizeof(h)); + } else if (role == ROLE_ARTIFACT) { + struct hello_artifact h; + int fdflags, lock_fd; + + memset(&h, 0, sizeof(h)); + h.fresh = 1; + if (snprintf(h.session, sizeof(h.session), "sandbox-%ld", + (long)getpid()) < 0 || + getcwd(h.root, sizeof(h.root)) == NULL || + strlcat(h.root, "/artifacts", sizeof(h.root)) >= + sizeof(h.root)) + errx(2, "Artifact sandbox path"); + send_msg(&ibuf, FUGU_IMSG_HELLO, &h, sizeof(h)); + + /* The lifetime lock is the Artifact Role's exact pre-READY + * descriptor exchange. Consume and validate it before the common + * worker READY message, just as the Coordinator does. */ + if (expect_msg(&ibuf, FUGU_IMSG_ARTIFACT_LIFETIME_LOCK, + &imsg) == -1) + errx(2, "fugu-artifact omitted lifetime lock"); + lock_fd = imsg_get_fd(&imsg); + if (imsg_get_len(&imsg) != 0 || imsg_get_id(&imsg) != 0 || + lock_fd == -1 || + (fdflags = fcntl(lock_fd, F_GETFD)) == -1 || + fcntl(lock_fd, F_SETFD, fdflags | FD_CLOEXEC) == -1 || + !artifact_lifetime_lock_valid(lock_fd, h.root, h.session)) + errx(2, "fugu-artifact sent malformed lifetime lock"); + close(lock_fd); + imsg_free(&imsg); } else { send_msg(&ibuf, FUGU_IMSG_HELLO, NULL, 0); } @@ -261,7 +736,80 @@ main(int argc, char *argv[]) if (imsg_get_len(&imsg) != 0 || imsg_get_id(&imsg) != 0) errx(2, "%s sent malformed READY", rolename); imsg_free(&imsg); + if (role == ROLE_ARTIFACT) { + /* Mirror the Coordinator's explicit startup phase boundary before + * exercising any steady-state Turn or selftest request. */ + send_msg(&ibuf, FUGU_IMSG_ARTIFACT_RECONCILE_END, NULL, 0); + if (expect_msg(&ibuf, FUGU_IMSG_ARTIFACT_RECONCILE_ACK, + &imsg) == -1 || imsg_get_len(&imsg) != 0 || + imsg_get_id(&imsg) != 0 || imsg_get_fd(&imsg) != -1) + errx(2, "fugu-artifact did not finish reconciliation"); + imsg_free(&imsg); + } + if (role == ROLE_TOOL && (action == TEST_TOOL_FREEZE_SWAP || + action == TEST_TOOL_FREEZE_TERMINAL || + action == TEST_TOOL_FREEZE_UNAVAILABLE || + action == TEST_TOOL_FREEZE_CANCEL)) { + int ok = test_tool_freeze_frames(&ibuf, kid.pid, action) == 0; + + if (!ok) { + (void)kill(kid.pid, SIGKILL); + (void)waitpid(kid.pid, &status, 0); + return (1); + } + send_msg(&ibuf, FUGU_IMSG_SHUTDOWN, NULL, 0); + if (waitpid(kid.pid, &status, 0) == -1) + errx(2, "waitpid Tool freeze frame"); + return (WIFEXITED(status) && WEXITSTATUS(status) == 0 ? 0 : 1); + } + + if (role == ROLE_TOOL && (action == TEST_TOOL_DESCRIPTOR_READ || + action == TEST_TOOL_SHELL_NO_STREAM || + action == TEST_TOOL_REORDERED_STREAM || + action == TEST_TOOL_SHELL)) { + static const char shell_arg[] = + "{\"command\":\"printf scope-ok\"}"; + uint32_t id = 77; + int artifact_fd = -1, ok; + + if (action == TEST_TOOL_DESCRIPTOR_READ) { + artifact_fd = send_artifact_stream(&ibuf, id); + send_tool_req(&ibuf, id, "read"); + } else if (action == TEST_TOOL_SHELL_NO_STREAM) { + send_tool_req(&ibuf, 0, "shell"); + } else if (action == TEST_TOOL_REORDERED_STREAM) { + send_tool_req(&ibuf, 0, "read"); + artifact_fd = send_artifact_stream(&ibuf, id); + } else { + if (readonly) + id = 0; + else + artifact_fd = send_artifact_stream(&ibuf, id); + send_tool_req(&ibuf, id, "shell"); + send_msg_id(&ibuf, FUGU_IMSG_TOOL_ARG, id, shell_arg, + sizeof(shell_arg) - 1); + send_msg_id(&ibuf, FUGU_IMSG_TOOL_RUN, id, NULL, 0); + ok = expect_tool_success(&ibuf, id, !readonly) == 0; + if (artifact_fd != -1) + close(artifact_fd); + if (!ok) { + (void)kill(kid.pid, SIGKILL); + (void)waitpid(kid.pid, &status, 0); + return (1); + } + send_msg(&ibuf, FUGU_IMSG_SHUTDOWN, NULL, 0); + if (waitpid(kid.pid, &status, 0) == -1) + errx(2, "waitpid Tool scope success"); + return (WIFEXITED(status) && WEXITSTATUS(status) == 0 ? + 0 : 1); + } + ok = worker_rejected(kid.pid); + if (artifact_fd != -1) + close(artifact_fd); + return (ok ? 0 : 1); + } + if (action == TEST_CLAUDE_MODELS) { struct model_ent me; u_char p[sizeof(me) + 1 + FUGU_MODEL_ID_MAX]; @@ -306,7 +854,9 @@ main(int argc, char *argv[]) } if (action == 0) { - /* handshake-only: verify liveness then shut down cleanly */ + /* Verify liveness and Artifact's typed descriptor exchange. */ + if (role == ROLE_ARTIFACT) + exercise_artifact_stream(&ibuf); send_msg(&ibuf, FUGU_IMSG_PING, NULL, 0); if (expect_msg(&ibuf, FUGU_IMSG_PONG, &imsg) == -1) errx(2, "%s did not pong", rolename); blob - dfeb858e0b2c2973fb5f97752d25b6efcb7d9212 blob + 1df4955f3793f707c20254cf3f3a0fa5f047343a --- regress/sandbox/run.sh +++ regress/sandbox/run.sh @@ -53,7 +53,7 @@ mkdir -p "$lx" "$tree" trap 'rm -rf "$dir"' EXIT INT TERM # Locate and stage the real worker binaries. -for w in fugu-tty fugu-api fugu-web fugu-tool fugu-editor; do +for w in fugu-tty fugu-api fugu-web fugu-tool fugu-artifact fugu-editor; do bin=$root/src/$w/obj/$w [ -x "$bin" ] || bin=$root/src/$w/$w if [ ! -x "$bin" ]; then @@ -89,8 +89,27 @@ run_case fugu-api none ok - run_case fugu-api claude_models ok - run_case fugu-web none ok - run_case fugu-tool none ok - +run_case fugu-artifact none ok - run_case fugu-editor none ok - +# Persistent Lead Artifact scope is an exact protocol property: scoped calls +# require one descriptor, unscoped calls forbid it, and a descriptor cannot be +# injected after a descriptor-free request has begun. The -r executor remains +# descriptor-free and can run its ordinary inspection shell unchanged. +run_case fugu-tool tool_descriptor_read reject - +run_case fugu-tool tool_shell_no_stream reject - +run_case fugu-tool tool_reordered_stream reject - +run_case fugu-tool tool_shell ok - +run_case fugu-tool-r tool_shell ok - + +# shell_output's out-of-band frame is the ownership proof: a running swap +# names both ids, terminal freezes name no replacement (including a zero-id +# unavailable old spool), and an interrupted pre-freeze call emits no frame. +run_case fugu-tool tool_freeze_swap ok - +run_case fugu-tool tool_freeze_terminal ok - +run_case fugu-tool tool_freeze_unavailable ok - +run_case fugu-tool tool_freeze_cancel ok - + # I10/I13: startup and liveness frames are exact typed exchanges. The TTY # and editor historically accepted malformed HELLO payloads; the common # worker runtime must also reject nonzero control correlation IDs and malformed @@ -109,6 +128,15 @@ run_case fugu-api-claude open_etc dead open run_case fugu-web exec dead execve run_case fugu-web open_etc dead open +# The Artifact Role can use only its private managed root: it cannot execute, +# reach the network, read configuration, access the tty, or write the project. +# The first two are pledge kills; the path probes are unveil-shaped ENOENTs. +run_case fugu-artifact exec dead execve +run_case fugu-artifact connect dead socket +run_case fugu-artifact open_etc errno - +run_case fugu-artifact open_tty errno - +run_case fugu-artifact write_intree errno - + # I7: the blast room cannot reach the tty. run_case fugu-web open_tty dead open blob - 8419f1e7f89a5ed2419fc81ac4e8e053a50195c3 blob + c9cb30793f43409f0341a3a161f935ea4d158cda --- regress/tools/Makefile +++ regress/tools/Makefile @@ -4,6 +4,7 @@ SRCS= tools_test.c tools.c job.c supervisor.c artifact NOMAN= yes CFLAGS+= -I${.CURDIR}/../../src/fugu -I${.CURDIR}/../../src/fugu-tool +CFLAGS+= -DJOB_TESTING .PATH: ${.CURDIR}/../../src/fugu-tool ${.CURDIR}/../../src/fugu REGRESS_TARGETS= run-tools blob - e3cb883d0b5c9c2820c1174582e015b39cb195ee blob + bf5cedd91dd6f600a031196206f2f8eb9f4312eb --- regress/tools/tools_test.c +++ regress/tools/tools_test.c @@ -36,6 +36,7 @@ #include #include #include +#include #include #include "log.h" @@ -48,7 +49,19 @@ static struct tool_ctx *ctx; static struct buf out; static int err_flag; +static uint32_t managed_correlation = 100; +static const char artifact_ids[][ARTIFACT_ID_HEX_LEN + 1] = { + "10000000000000000000000000000001", + "20000000000000000000000000000002", + "30000000000000000000000000000003", + "40000000000000000000000000000004", + "50000000000000000000000000000005", + "60000000000000000000000000000006", + "70000000000000000000000000000007", + "80000000000000000000000000000008" +}; + static void run(const char *name, const char *input) { @@ -58,6 +71,93 @@ run(const char *name, const char *input) tool_exec(ctx, name, input, strlen(input), &out, &err_flag); } +static void +managed_run(const char *name, const char *input, const char *id, int fd, + int available) +{ + ctx->artifact_mode = 1; + ctx->artifact_available = available; + ctx->artifact_fd = fd; + ctx->artifact_id = available ? id : NULL; + ctx->artifact_correlation = managed_correlation++; + run(name, input); +} + +static void +cancel_active_signal(int sig) +{ + (void)sig; + tool_cancel_active(); +} + +static void +nonblock_fd(int fd) +{ + int flags = fcntl(fd, F_GETFL); + + if (flags == -1 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) + err(1, "nonblocking fixture fd"); +} + +static uint64_t +drain_bytes(int fd) +{ + u_char bytes[64 * 1024]; + uint64_t total = 0; + ssize_t n; + + for (;;) { + n = read(fd, bytes, sizeof(bytes)); + if (n > 0) { + total += (uint64_t)n; + continue; + } + if (n == -1 && errno == EINTR) + continue; + break; + } + return (total); +} + +static void +fill_pipe(int fd) +{ + u_char bytes[4096]; + ssize_t n; + + memset(bytes, 0xa5, sizeof(bytes)); + nonblock_fd(fd); + for (;;) { + n = write(fd, bytes, sizeof(bytes)); + if (n > 0) + continue; + if (n == -1 && errno == EINTR) + continue; + if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) + return; + err(1, "fill fixture pipe"); + } +} + +static long +elapsed_ms(const struct timespec *start, const struct timespec *end) +{ + return ((long)(end->tv_sec - start->tv_sec) * 1000L + + (end->tv_nsec - start->tv_nsec) / 1000000L); +} + +static void +managed_clear(void) +{ + if (ctx->artifact_fd != -1) + close(ctx->artifact_fd); + ctx->artifact_fd = -1; + ctx->artifact_mode = 0; + ctx->artifact_available = 0; + ctx->artifact_id = NULL; + ctx->artifact_correlation = 0; +} + /* memmem so NUL-bearing results are searched length-aware */ static int outhas(const char *s) @@ -170,14 +270,824 @@ make_long_line(const char *path, size_t target) close(fd); } +static void +test_managed_artifacts(void) +{ + struct job_test_state cap_state; + struct timespec start, end; + u_char sink[16 * 1024]; + char arg[64]; + int p1[2], p2[2], job_id, i, duplicate, nullfd, status; + pid_t notifier; + uint64_t frozen_expected, snapshot, spooled; + + job_set_cap(ctx->jobs, 4); + + /* Foreground capture observes no more than the one byte needed to prove + * a cap crossing. Exact-cap EOF is success; crossing is exactly +1. */ + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell", "{\"command\":\"/usr/bin/perl -e " + "'print q(x) x 16777216'\"}", artifact_ids[0], nullfd, 1); + CHECK(!err_flag && !ctx->artifact_cap_reached && + ctx->artifact_termination == ARTIFACT_TERM_EXIT && + ctx->artifact_observed == ARTIFACT_RAW_MAX && + ctx->artifact_raw_len == ARTIFACT_RAW_MAX); + managed_clear(); + + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell", "{\"command\":\"/usr/bin/perl -e " + "'print q(x) x 17825792'\"}", artifact_ids[0], nullfd, 1); + CHECK(err_flag && ctx->artifact_cap_reached && + ctx->artifact_termination == ARTIFACT_TERM_CAP && + ctx->artifact_observed == ARTIFACT_RAW_MAX + 1ULL && + ctx->artifact_raw_len == ARTIFACT_RAW_MAX); + managed_clear(); + + /* A replacement created in the spawning Turn remains Turn-owned. */ + CHECK(job_turn_begin(ctx->jobs) == 0 && pipe(p1) == 0); + nonblock_fd(p1[0]); + managed_run("shell_bg", + "{\"command\":\"printf owned-marker; sleep 30\"}", + artifact_ids[0], p1[1], 1); + job_id = ctx->artifact_job_id; + CHECK(!err_flag && ctx->artifact_spool_adopted && job_id > 0); + for (i = 0; i < 20; i++) { + job_pump(ctx->jobs); + (void)drain_bytes(p1[0]); + usleep(10000); + } + CHECK(pipe(p2) == 0); + nonblock_fd(p2[0]); + (void)snprintf(arg, sizeof(arg), "{\"id\":%d}", job_id); + managed_run("shell_output", arg, artifact_ids[1], p2[1], 1); + CHECK(ctx->artifact_job_id == job_id && ctx->artifact_spool_adopted && + !ctx->artifact_spool_detach && ctx->artifact_result_available && + strcmp(ctx->artifact_result_id, artifact_ids[0]) == 0); + CHECK(memmem(ctx->artifact_capture.full.data, + ctx->artifact_capture.full.len, "owned-marker", 12) != NULL); + CHECK(job_artifact_release(ctx->jobs, job_id, artifact_ids[0]) == 0); + CHECK(job_turn_abort(ctx->jobs) == 0); + managed_clear(); + close(p1[0]); + close(p2[0]); + run("shell_output", arg); + CHECK(err_flag && outhas("no such job")); + + /* A committed job's later poll replacement detaches before abort. */ + CHECK(job_turn_begin(ctx->jobs) == 0 && pipe(p1) == 0); + nonblock_fd(p1[0]); + managed_run("shell_bg", "{\"command\":\"printf durable; sleep 30\"}", + artifact_ids[2], p1[1], 1); + job_id = ctx->artifact_job_id; + CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0); + CHECK(job_turn_begin(ctx->jobs) == 0 && pipe(p2) == 0); + nonblock_fd(p2[0]); + (void)snprintf(arg, sizeof(arg), "{\"id\":%d}", job_id); + managed_run("shell_output", arg, artifact_ids[3], p2[1], 1); + CHECK(ctx->artifact_spool_adopted && ctx->artifact_spool_detach && + ctx->artifact_job_id == job_id); + CHECK(job_artifact_release(ctx->jobs, job_id, artifact_ids[2]) == 0); + CHECK(job_turn_abort(ctx->jobs) == 0); + managed_clear(); + close(p1[0]); + close(p2[0]); + run("shell_output", arg); + CHECK(!err_flag && !outhas("no such job")); + run("shell_kill", arg); + CHECK(!err_flag); + + /* A producer that completed with no bytes is reaped and EOF-closed in the + * same poll. It must not report running or adopt an endless replacement. */ + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell_bg", "{\"command\":\"/usr/bin/true\"}", + artifact_ids[6], nullfd, 1); + job_id = ctx->artifact_job_id; + CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0); + managed_clear(); + usleep(300000); + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + (void)snprintf(arg, sizeof(arg), "{\"id\":%d}", job_id); + managed_run("shell_output", arg, artifact_ids[7], nullfd, 1); + CHECK(!err_flag && ctx->artifact_captured && + ctx->artifact_termination == ARTIFACT_TERM_EXIT && + ctx->artifact_observed == 0 && ctx->artifact_raw_len == 0 && + ctx->artifact_result_available && !ctx->artifact_spool_adopted); + managed_clear(); + run("shell_output", arg); + CHECK(err_flag && outhas("no such job")); + CHECK(job_artifact_release(ctx->jobs, job_id, artifact_ids[6]) == 0); + CHECK(job_artifact_release(ctx->jobs, job_id, artifact_ids[6]) == -1); + CHECK(job_artifact_cancel(ctx->jobs, job_id, artifact_ids[6]) == -1); + CHECK(job_artifact_release(ctx->jobs, job_id, artifact_ids[7]) == -1); + CHECK(job_turn_abort(ctx->jobs) == 0); + + /* shell_output freezes one FIONREAD snapshot. A blocked fast producer + * cannot refill the pipe into the segment while that snapshot is drained. */ + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell_bg", "{\"command\":\"yes snapshot\"}", + artifact_ids[4], nullfd, 1); + job_id = ctx->artifact_job_id; + CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0); + managed_clear(); + for (i = 0; i < 200 && job_test_output_bytes(ctx->jobs, job_id) == 0; + i++) + usleep(5000); + usleep(50000); + snapshot = job_test_output_bytes(ctx->jobs, job_id); + CHECK(snapshot > 0 && snapshot <= job_test_pump_per_job_limit()); + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + (void)snprintf(arg, sizeof(arg), "{\"id\":%d}", job_id); + managed_run("shell_output", arg, artifact_ids[5], nullfd, 1); + CHECK(!err_flag && ctx->artifact_observed == snapshot && + ctx->artifact_raw_len == snapshot && ctx->artifact_spool_adopted && + ctx->artifact_spool_detach); + CHECK(job_artifact_release(ctx->jobs, job_id, artifact_ids[4]) == 0); + CHECK(job_turn_abort(ctx->jobs) == 0); + managed_clear(); + run("shell_kill", arg); + CHECK(!err_flag); + + /* Reservation-unavailable capture stays inline while safe and kills the + * producer conspicuously exactly when no terminal form can fit. */ + CHECK(job_turn_begin(ctx->jobs) == 0); + managed_run("shell_bg", "{\"command\":\"printf inline; sleep 30\"}", + artifact_ids[4], -1, 0); + job_id = ctx->artifact_job_id; + for (i = 0; i < 10; i++) + job_pump(ctx->jobs); + (void)snprintf(arg, sizeof(arg), "{\"id\":%d}", job_id); + managed_run("shell_output", arg, artifact_ids[5], -1, 0); + CHECK(!err_flag && !ctx->artifact_result_available && + ctx->artifact_termination == ARTIFACT_TERM_RUNNING); + CHECK(job_turn_abort(ctx->jobs) == 0); + managed_clear(); + + CHECK(job_turn_begin(ctx->jobs) == 0); + managed_run("shell_bg", "{\"command\":\"yes unavailable\"}", + artifact_ids[4], -1, 0); + job_id = ctx->artifact_job_id; + for (i = 0; i < 200 && + !job_test_storage_unavailable(ctx->jobs, job_id); i++) { + job_pump(ctx->jobs); + usleep(5000); + } + CHECK(job_test_storage_unavailable(ctx->jobs, job_id)); + (void)snprintf(arg, sizeof(arg), "{\"id\":%d}", job_id); + managed_run("shell_output", arg, artifact_ids[5], -1, 0); + CHECK(err_flag && + ctx->artifact_termination == ARTIFACT_TERM_STORAGE_UNAVAILABLE && + ctx->artifact_raw_len <= ARTIFACT_RESULT_MAX); + CHECK(job_turn_abort(ctx->jobs) == 0); + managed_clear(); + + /* Exact-cap bytes survive an injected EAGAIN. Once they flush, the next + * producer byte records the crossing and the retained 16 MiB stays exact. */ + CHECK(job_turn_begin(ctx->jobs) == 0 && pipe(p1) == 0); + nonblock_fd(p1[0]); + managed_run("shell_bg", "{\"command\":\"perl -e " + "'print q(x) x 17825792'\"}", + artifact_ids[6], p1[1], 1); + job_id = ctx->artifact_job_id; + job_test_backpressure_at_boundary(); + spooled = 0; + clock_gettime(CLOCK_MONOTONIC, &start); + for (;;) { + job_pump(ctx->jobs); + spooled += drain_bytes(p1[0]); + if (job_test_boundary_pending(ctx->jobs, job_id)) + break; + clock_gettime(CLOCK_MONOTONIC, &end); + if (elapsed_ms(&start, &end) >= 10000) + break; + if (job_test_last_pump_bytes(ctx->jobs, job_id) == 0) + usleep(1000); + } + if (!job_test_boundary_pending(ctx->jobs, job_id) && + job_test_state(ctx->jobs, job_id, &cap_state) == 0) + fprintf(stderr, "cap witness: observed=%llu retained=%llu " + "pending=%zu cap=%d exited=%d producer=%d artifact=%d " + "storage_error=%d unavailable=%d\n", + (unsigned long long)cap_state.observed, + (unsigned long long)cap_state.retained, + cap_state.pending_bytes, cap_state.cap_reached, + cap_state.exited, cap_state.producer_open, + cap_state.artifact_open, cap_state.storage_error, + cap_state.storage_unavailable); + CHECK(job_test_boundary_pending(ctx->jobs, job_id)); + for (i = 0; i < 10; i++) { + spooled += drain_bytes(p1[0]); + job_pump(ctx->jobs); + } + (void)snprintf(arg, sizeof(arg), "{\"id\":%d}", job_id); + managed_run("shell_output", arg, artifact_ids[7], -1, 0); + spooled += drain_bytes(p1[0]); + CHECK(err_flag && ctx->artifact_cap_reached && + ctx->artifact_termination == ARTIFACT_TERM_CAP && + ctx->artifact_observed == ARTIFACT_RAW_MAX + 1ULL && + ctx->artifact_raw_len == ARTIFACT_RAW_MAX && + spooled == ARTIFACT_RAW_MAX); + CHECK(job_artifact_release(ctx->jobs, job_id, artifact_ids[6]) == 0); + CHECK(job_turn_abort(ctx->jobs) == 0); + managed_clear(); + close(p1[0]); + + /* Backpressure has no arbitrary settlement deadline. Cancelling a poll + * leaves a committed job and its old spool intact, so a later Turn can + * drain and freeze the same bytes without loss. */ + CHECK(job_turn_begin(ctx->jobs) == 0 && pipe(p1) == 0); + nonblock_fd(p1[0]); + duplicate = dup(p1[1]); + CHECK(duplicate != -1); + managed_run("shell_bg", "{\"command\":\"yes blocked\"}", + artifact_ids[0], p1[1], 1); + job_id = ctx->artifact_job_id; + CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0); + managed_clear(); + fill_pipe(duplicate); + close(duplicate); + for (i = 0; i < 200 && + job_test_pending_bytes(ctx->jobs, job_id) == 0; i++) { + job_pump(ctx->jobs); + usleep(5000); + } + CHECK(job_test_pending_bytes(ctx->jobs, job_id) > 0); + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + (void)snprintf(arg, sizeof(arg), "{\"id\":%d}", job_id); + notifier = fork(); + if (notifier == -1) + err(1, "fork background cancel notifier"); + if (notifier == 0) { + usleep(150000); + (void)kill(getppid(), SIGUSR1); + _exit(0); + } + clock_gettime(CLOCK_MONOTONIC, &start); + managed_run("shell_output", arg, artifact_ids[1], nullfd, 1); + clock_gettime(CLOCK_MONOTONIC, &end); + (void)waitpid(notifier, &status, 0); + CHECK(!err_flag && !ctx->artifact_captured && + !ctx->artifact_spool_adopted && elapsed_ms(&start, &end) < 1500); + CHECK(job_test_state(ctx->jobs, job_id, &cap_state) == 0 && + !cap_state.storage_error && !cap_state.exited && + cap_state.producer_open && cap_state.artifact_open && + cap_state.pending_bytes > 0); + frozen_expected = cap_state.observed; + CHECK(job_turn_abort(ctx->jobs) == 0); + managed_clear(); + notifier = fork(); + if (notifier == -1) + err(1, "fork Artifact drain helper"); + if (notifier == 0) { + ssize_t n; + + close(p1[1]); + for (;;) { + n = read(p1[0], sink, sizeof(sink)); + if (n > 0 || (n == -1 && errno == EINTR)) + continue; + if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + usleep(1000); + continue; + } + _exit(n == 0 ? 0 : 2); + } + } + close(p1[0]); + snapshot = job_test_output_bytes(ctx->jobs, job_id); + CHECK(snapshot > 0 && snapshot <= job_test_pump_per_job_limit()); + frozen_expected += snapshot; + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell_output", arg, artifact_ids[1], nullfd, 1); + CHECK(!err_flag && ctx->artifact_captured && + ctx->artifact_spool_adopted && ctx->artifact_spool_detach && + ctx->artifact_observed == frozen_expected && + ctx->artifact_result_available && + strcmp(ctx->artifact_result_id, artifact_ids[0]) == 0); + (void)waitpid(notifier, &status, 0); + CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0); + CHECK(job_artifact_release(ctx->jobs, job_id, artifact_ids[0]) == 0); + CHECK(job_turn_abort(ctx->jobs) == 0); + managed_clear(); + run("shell_kill", arg); + CHECK(!err_flag); + + /* Foreground writes share the execution deadline and SIGUSR1 cancels only + * the active group; the persistent executor has no poisoned next-call latch. */ + CHECK(pipe(p1) == 0); + fill_pipe(p1[1]); + ctx->shell_timeout_ms = 300; + clock_gettime(CLOCK_MONOTONIC, &start); + managed_run("shell", "{\"command\":\"printf x; sleep 30\"}", + artifact_ids[2], p1[1], 1); + clock_gettime(CLOCK_MONOTONIC, &end); + CHECK(err_flag && ctx->artifact_termination == ARTIFACT_TERM_TIMEOUT && + elapsed_ms(&start, &end) < 1500); + managed_clear(); + close(p1[0]); + + CHECK(pipe(p1) == 0); + fill_pipe(p1[1]); + ctx->shell_timeout_ms = 5000; + notifier = fork(); + if (notifier == -1) + err(1, "fork cancel notifier"); + if (notifier == 0) { + usleep(150000); + (void)kill(getppid(), SIGUSR1); + _exit(0); + } + clock_gettime(CLOCK_MONOTONIC, &start); + managed_run("shell", "{\"command\":\"printf x; sleep 30\"}", + artifact_ids[3], p1[1], 1); + clock_gettime(CLOCK_MONOTONIC, &end); + (void)waitpid(notifier, &status, 0); + CHECK(elapsed_ms(&start, &end) < 1500); + managed_clear(); + close(p1[0]); + ctx->shell_timeout_ms = 0; + run("shell", "{\"command\":\"printf next-tool-ok\"}"); + CHECK(!err_flag && outhas("next-tool-ok")); +} + +static void +test_bounded_job_retirement(void) +{ + struct tool_ctx *saved = ctx, *bounded; + struct artifact_spool_drop drop, bad_drop; + struct job_test_state state; + char arg[64], zero_id[FUGU_ARTIFACT_ID_BUFSZ] = { 0 }; + int p[2], old_job, i, nullfd, settled; + uint32_t old_correlation, correlation; + uint64_t spooled; + ssize_t n; + + bounded = tool_ctx_new(1, 0); + ctx = bounded; + job_set_cap(ctx->jobs, 4); + job_test_set_record_limit(ctx->jobs, 1); + job_test_set_tombstone_limit(ctx->jobs, 4); + + /* The record bound is independent of the running-job cap. Admitting a + * second job retires the completed, unpolled first job and publishes its + * exact current-spool key exactly once. */ + CHECK(job_turn_begin(ctx->jobs) == 0 && pipe(p) == 0); + nonblock_fd(p[0]); + managed_run("shell_bg", "{\"command\":\"printf retired-by-bound\"}", + artifact_ids[0], p[1], 1); + old_job = ctx->artifact_job_id; + old_correlation = ctx->artifact_correlation; + CHECK(!err_flag && old_job > 0 && job_turn_commit(ctx->jobs) == 0); + managed_clear(); + spooled = 0; + settled = 0; + for (i = 0; i < 200; i++) { + job_pump(ctx->jobs); + spooled += drain_bytes(p[0]); + if (job_test_state(ctx->jobs, old_job, &state) == 0 && + state.exited && !state.producer_open && !state.artifact_open && + state.pending_bytes == 0) { + settled = 1; + break; + } + usleep(5000); + } + CHECK(settled && spooled == strlen("retired-by-bound")); + n = read(p[0], &i, 1); + CHECK(n == 0); + close(p[0]); + + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell_bg", "{\"command\":\"sleep 30\"}", + artifact_ids[1], nullfd, 1); + CHECK(!err_flag && ctx->artifact_job_id != old_job && + job_test_record_count(ctx->jobs) == 1 && + job_test_tombstone_count(ctx->jobs) == 1); + memset(&drop, 0, sizeof(drop)); + correlation = 0; + CHECK(job_artifact_drop_next(ctx->jobs, &correlation, &drop) == 1 && + correlation == old_correlation && drop.job_id == (uint32_t)old_job && + strcmp(drop.artifact_id, artifact_ids[0]) == 0); + CHECK(job_artifact_drop_next(ctx->jobs, &correlation, &drop) == 0); + managed_clear(); + (void)snprintf(arg, sizeof(arg), "{\"id\":%d}", old_job); + run("shell_output", arg); + CHECK(err_flag && outhas("no such job")); + CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[0]) == -1); + CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[2]) == -1); + CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[2]) == -1); + CHECK(job_artifact_drop_ack(ctx->jobs, old_correlation + 1, &drop) == -1); + CHECK(job_artifact_drop_ack(ctx->jobs, old_correlation, &drop) == 0 && + job_test_tombstone_count(ctx->jobs) == 0); + CHECK(job_artifact_drop_ack(ctx->jobs, old_correlation, &drop) == -1); + CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[0]) == -1); + CHECK(job_turn_abort(ctx->jobs) == 0); + + /* STORE_ERROR races retire the same kind of current DROP tombstone with + * one exact CANCEL instead of DROP_ACK. When the DROP is still unsent, + * cancellation removes it before main emits the exact CANCEL_ACK. The two + * terminal controls are mutually exclusive, and RELEASE is never valid for + * an unpolled current spool. */ + CHECK(job_turn_begin(ctx->jobs) == 0 && pipe(p) == 0); + nonblock_fd(p[0]); + managed_run("shell_bg", "{\"command\":\"printf cancelled-drop\"}", + artifact_ids[5], p[1], 1); + old_job = ctx->artifact_job_id; + old_correlation = ctx->artifact_correlation; + CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0); + managed_clear(); + settled = 0; + for (i = 0; i < 200; i++) { + job_pump(ctx->jobs); + (void)drain_bytes(p[0]); + if (job_test_state(ctx->jobs, old_job, &state) == 0 && + state.exited && !state.producer_open && !state.artifact_open && + state.pending_bytes == 0) { + settled = 1; + break; + } + usleep(5000); + } + CHECK(settled); + close(p[0]); + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell_bg", "{\"command\":\"sleep 30\"}", + artifact_ids[6], nullfd, 1); + CHECK(!err_flag && job_test_record_count(ctx->jobs) == 1); + memset(&drop, 0, sizeof(drop)); + CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[5]) == 0 && + job_test_tombstone_count(ctx->jobs) == 0); + correlation = 0; + CHECK(job_artifact_drop_next(ctx->jobs, &correlation, &drop) == 0); + CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[5]) == -1); + CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[5]) == -1); + drop.job_id = (uint32_t)old_job; + strlcpy(drop.artifact_id, artifact_ids[5], sizeof(drop.artifact_id)); + CHECK(job_artifact_drop_ack(ctx->jobs, old_correlation, &drop) == -1); + managed_clear(); + CHECK(job_turn_abort(ctx->jobs) == 0); + + /* Live current keys, prior-poll history, and tombstones share one global + * ownership bound. A repeated running poll at the bound stops without + * adopting another key and retains the current key as one settlement slot. */ + job_test_set_ownership_limit(ctx->jobs, 2); + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell_bg", "{\"command\":\"printf bounded; sleep 30\"}", + artifact_ids[0], nullfd, 1); + old_job = ctx->artifact_job_id; + CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0 && + job_test_ownership_count(ctx->jobs) == 1); + managed_clear(); + for (i = 0; i < 20; i++) { + job_pump(ctx->jobs); + usleep(5000); + } + (void)snprintf(arg, sizeof(arg), "{\"id\":%d}", old_job); + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell_output", arg, artifact_ids[1], nullfd, 1); + CHECK(!err_flag && ctx->artifact_spool_adopted && + ctx->artifact_result_available && + strcmp(ctx->artifact_result_id, artifact_ids[0]) == 0 && + job_test_ownership_count(ctx->jobs) == 2); + managed_clear(); + CHECK(job_turn_abort(ctx->jobs) == 0); + + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell_output", arg, artifact_ids[2], nullfd, 1); + CHECK(err_flag && ctx->artifact_captured && + ctx->artifact_termination == ARTIFACT_TERM_CANCELLED && + !ctx->artifact_spool_adopted && ctx->artifact_result_available && + strcmp(ctx->artifact_result_id, artifact_ids[1]) == 0 && + job_test_ownership_count(ctx->jobs) == 2 && + job_test_record_count(ctx->jobs) == 1); + managed_clear(); + run("shell_output", arg); + CHECK(err_flag && outhas("awaiting Artifact settlement") && + job_test_ownership_count(ctx->jobs) == 2); + CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[0]) == 0 && + job_test_ownership_count(ctx->jobs) == 1); + CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[0]) == -1); + CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[0]) == -1); + CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[1]) == 0 && + job_test_ownership_count(ctx->jobs) == 0 && + job_test_record_count(ctx->jobs) == 0); + CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[1]) == -1); + CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[1]) == -1); + CHECK(job_turn_abort(ctx->jobs) == 0); + + /* The same no-replacement result identifies an unavailable old spool by + * job plus the all-zero id. Tool has no exact available key to settle; + * the Coordinator can retire its unavailable reservation from this result. */ + job_test_set_ownership_limit(ctx->jobs, 1); + CHECK(job_turn_begin(ctx->jobs) == 0); + managed_run("shell_bg", "{\"command\":\"sleep 30\"}", + artifact_ids[2], -1, 0); + old_job = ctx->artifact_job_id; + CHECK(!err_flag && old_job > 0 && job_turn_commit(ctx->jobs) == 0 && + job_test_ownership_count(ctx->jobs) == 1); + managed_clear(); + (void)snprintf(arg, sizeof(arg), "{\"id\":%d}", old_job); + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell_output", arg, artifact_ids[3], nullfd, 1); + CHECK(err_flag && ctx->artifact_captured && + ctx->artifact_termination == ARTIFACT_TERM_CANCELLED && + ctx->artifact_job_id == old_job && !ctx->artifact_spool_adopted && + !ctx->artifact_result_available && ctx->artifact_result_id[0] == '\0' && + job_test_ownership_count(ctx->jobs) == 0 && + job_test_record_count(ctx->jobs) == 0); + managed_clear(); + CHECK(job_turn_abort(ctx->jobs) == 0); + + /* One valid STORE_ERROR cancellation is job-wide. Matching an old live + * history key retires every other history and the current replacement; no + * second control for that job may succeed. */ + job_test_set_ownership_limit(ctx->jobs, 16); + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell_bg", "{\"command\":\"sleep 30\"}", + artifact_ids[0], nullfd, 1); + old_job = ctx->artifact_job_id; + CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0); + managed_clear(); + (void)snprintf(arg, sizeof(arg), "{\"id\":%d}", old_job); + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell_output", arg, artifact_ids[1], nullfd, 1); + CHECK(!err_flag && ctx->artifact_spool_adopted); + managed_clear(); + CHECK(job_turn_abort(ctx->jobs) == 0); + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell_output", arg, artifact_ids[2], nullfd, 1); + CHECK(!err_flag && ctx->artifact_spool_adopted && + job_test_ownership_count(ctx->jobs) == 3); + managed_clear(); + CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[0]) == 0 && + job_test_ownership_count(ctx->jobs) == 0); + CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[0]) == -1); + CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[1]) == -1); + CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[2]) == -1); + CHECK(job_turn_abort(ctx->jobs) == 0); + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell_output", arg, artifact_ids[3], nullfd, 1); + CHECK(err_flag && ctx->artifact_termination == ARTIFACT_TERM_STORAGE_ERROR && + job_test_record_count(ctx->jobs) == 0); + managed_clear(); + CHECK(job_turn_abort(ctx->jobs) == 0); + + /* The same job-wide rule covers an archived job with both historical + * tombstones and a published current DROP. Socket ordering puts that DROP + * before main's exact CANCEL_ACK; cancellation then makes DROP_ACK and every + * sibling control stale. */ + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell_bg", "{\"command\":\"printf archived; sleep 1\"}", + artifact_ids[0], nullfd, 1); + old_job = ctx->artifact_job_id; + CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0); + managed_clear(); + (void)snprintf(arg, sizeof(arg), "{\"id\":%d}", old_job); + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell_output", arg, artifact_ids[1], nullfd, 1); + old_correlation = ctx->artifact_correlation; + CHECK(!err_flag && ctx->artifact_spool_adopted && + job_test_ownership_count(ctx->jobs) == 2); + managed_clear(); + CHECK(job_turn_abort(ctx->jobs) == 0); + settled = 0; + for (i = 0; i < 300; i++) { + job_pump(ctx->jobs); + if (job_test_state(ctx->jobs, old_job, &state) == 0 && + state.exited && !state.producer_open && !state.artifact_open && + state.pending_bytes == 0) { + settled = 1; + break; + } + usleep(5000); + } + CHECK(settled); + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell_bg", "{\"command\":\"sleep 30\"}", + artifact_ids[4], nullfd, 1); + CHECK(!err_flag && job_test_record_count(ctx->jobs) == 1 && + job_test_tombstone_count(ctx->jobs) == 2); + memset(&drop, 0, sizeof(drop)); + correlation = 0; + CHECK(job_artifact_drop_next(ctx->jobs, &correlation, &drop) == 1 && + correlation == old_correlation && drop.job_id == (uint32_t)old_job && + strcmp(drop.artifact_id, artifact_ids[1]) == 0); + CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[0]) == 0 && + job_test_tombstone_count(ctx->jobs) == 0); + CHECK(job_artifact_drop_ack(ctx->jobs, old_correlation, &drop) == -1); + CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[0]) == -1); + CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[1]) == -1); + managed_clear(); + CHECK(job_turn_abort(ctx->jobs) == 0 && + job_test_ownership_count(ctx->jobs) == 0); + + /* Unavailable reservations are retained ownership too. Their retirement + * uses the same exact correlation/job DROP, with only an all-zero id valid. */ + job_test_set_ownership_limit(ctx->jobs, 16); + CHECK(job_turn_begin(ctx->jobs) == 0); + managed_run("shell_bg", "{\"command\":\"printf unavailable-drop\"}", + artifact_ids[3], -1, 0); + old_job = ctx->artifact_job_id; + old_correlation = ctx->artifact_correlation; + CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0 && + job_test_ownership_count(ctx->jobs) == 1); + managed_clear(); + settled = 0; + for (i = 0; i < 200; i++) { + job_pump(ctx->jobs); + if (job_test_state(ctx->jobs, old_job, &state) == 0 && + state.exited && !state.producer_open && !state.artifact_open && + state.pending_bytes == 0) { + settled = 1; + break; + } + usleep(5000); + } + CHECK(settled); + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell_bg", "{\"command\":\"sleep 30\"}", + artifact_ids[4], nullfd, 1); + CHECK(!err_flag && job_test_record_count(ctx->jobs) == 1 && + job_test_tombstone_count(ctx->jobs) == 1); + memset(&drop, 0, sizeof(drop)); + correlation = 0; + CHECK(job_artifact_drop_next(ctx->jobs, &correlation, &drop) == 1 && + correlation == old_correlation && drop.job_id == (uint32_t)old_job && + memcmp(drop.artifact_id, zero_id, sizeof(zero_id)) == 0); + CHECK(job_artifact_cancel(ctx->jobs, old_job, drop.artifact_id) == -1); + bad_drop = drop; + bad_drop.artifact_id[0] = 'a'; + CHECK(job_artifact_drop_ack(ctx->jobs, old_correlation, &bad_drop) == -1); + CHECK(job_artifact_drop_ack(ctx->jobs, old_correlation, &drop) == 0 && + job_test_tombstone_count(ctx->jobs) == 0); + CHECK(job_artifact_drop_ack(ctx->jobs, old_correlation, &drop) == -1); + managed_clear(); + CHECK(job_turn_abort(ctx->jobs) == 0 && + job_test_ownership_count(ctx->jobs) == 0); + + /* A full exact-key tombstone table cannot grow without bound. With no + * running jobs, admission fails as settlement backlog rather than + * pretending the independent max_bg_jobs concurrency gate was reached. */ + job_test_set_tombstone_limit(ctx->jobs, 0); + CHECK(job_turn_begin(ctx->jobs) == 0 && pipe(p) == 0); + nonblock_fd(p[0]); + managed_run("shell_bg", "{\"command\":\"printf held-by-backlog\"}", + artifact_ids[2], p[1], 1); + old_job = ctx->artifact_job_id; + CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0); + managed_clear(); + settled = 0; + for (i = 0; i < 200; i++) { + job_pump(ctx->jobs); + (void)drain_bytes(p[0]); + if (job_test_state(ctx->jobs, old_job, &state) == 0 && + state.exited && !state.producer_open && !state.artifact_open && + state.pending_bytes == 0) { + settled = 1; + break; + } + usleep(5000); + } + CHECK(settled); + close(p[0]); + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + managed_run("shell_bg", "{\"command\":\"sleep 30\"}", + artifact_ids[3], nullfd, 1); + CHECK(err_flag && outhas("settlement backlog") && + !outhas("background-job cap") && + job_test_record_count(ctx->jobs) == 1 && + job_test_tombstone_count(ctx->jobs) == 0); + managed_clear(); + CHECK(job_turn_abort(ctx->jobs) == 0); + + /* A final poll can still settle that retained live record; once its exact + * control arrives, no tombstone is needed and the record disappears. */ + CHECK(job_turn_begin(ctx->jobs) == 0); + nullfd = open("/dev/null", O_WRONLY); + CHECK(nullfd != -1); + (void)snprintf(arg, sizeof(arg), "{\"id\":%d}", old_job); + managed_run("shell_output", arg, artifact_ids[4], nullfd, 1); + CHECK(!err_flag && ctx->artifact_termination == ARTIFACT_TERM_EXIT && + job_test_record_count(ctx->jobs) == 1); + CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[2]) == 0 && + job_test_record_count(ctx->jobs) == 0); + CHECK(job_turn_abort(ctx->jobs) == 0); + managed_clear(); + + tool_ctx_free(bounded); + ctx = saved; +} + +static void +test_background_pump_fairness(void) +{ + struct tool_ctx *saved = ctx, *fair; + int i, round, seen[3] = { 0, 0, 0 }; + + fair = tool_ctx_new(1, 0); + ctx = fair; + job_set_cap(ctx->jobs, 3); + CHECK(job_test_pump_per_job_limit() == 64 * 1024); + CHECK(job_test_pump_total_limit() == 1024 * 1024); + CHECK(job_turn_begin(ctx->jobs) == 0); + for (i = 0; i < 3; i++) { + run("shell_bg", "{\"command\":\"yes fair\"}"); + CHECK(!err_flag && ctx->artifact_job_id == i + 1); + } + for (round = 0; round < 200; round++) { + for (i = 0; i < 3; i++) + if (job_test_output_bytes(ctx->jobs, i + 1) < 4096) + break; + if (i == 3) + break; + usleep(5000); + } + CHECK(i == 3); + /* Compress the same scheduler policy so two jobs saturate one pump; + * the next pump must begin with the skipped third job. */ + job_test_set_pump_limits(4096, 8192); + for (round = 0; round < 2; round++) { + job_pump(ctx->jobs); + CHECK(job_test_last_pump_total(ctx->jobs) == 8192); + for (i = 0; i < 3; i++) { + size_t n = job_test_last_pump_bytes(ctx->jobs, i + 1); + + CHECK(n <= 4096); + if (n > 0) + seen[i] = 1; + } + if (round == 0) + CHECK(job_test_last_pump_bytes(ctx->jobs, 1) == 4096 && + job_test_last_pump_bytes(ctx->jobs, 2) == 4096 && + job_test_last_pump_bytes(ctx->jobs, 3) == 0); + else + CHECK(job_test_last_pump_bytes(ctx->jobs, 3) == 4096); + } + CHECK(seen[0] && seen[1] && seen[2]); + job_test_reset_pump_limits(); + CHECK(job_turn_abort(ctx->jobs) == 0); + tool_ctx_free(fair); + ctx = saved; +} + int main(int argc, char *argv[]) { + struct sigaction sa; + log_init(LOG_TO_STDERR, 0, LOG_USER); if (argc != 2) errx(1, "usage: tools_test "); if (chdir(argv[1]) == -1) err(1, "chdir %s", argv[1]); + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = cancel_active_signal; + sigemptyset(&sa.sa_mask); + if (sigaction(SIGUSR1, &sa, NULL) == -1) + err(1, "sigaction SIGUSR1"); buf_init(&out); @@ -555,6 +1465,9 @@ main(int argc, char *argv[]) CHECK(err_flag && outhas("no such job")); /* abort removed it */ run("shell_kill", "{\"id\":999}"); CHECK(err_flag && outhas("no such job")); + test_background_pump_fairness(); + test_managed_artifacts(); + test_bounded_job_retirement(); /* --- unknown tool ----------------------------------------- */ run("frobnicate", "{}"); blob - 262a0930c5ca8ea077a200efe4dcabb185a30b21 blob + 7ec62a2cb9334534b80aa371ac9fb0dd5858aae6 --- regress/turn/reqcheck.c +++ regress/turn/reqcheck.c @@ -62,6 +62,34 @@ string_is(const struct json *j, int tok, const char *w return (ok); } +static int +artifact_text_is(const struct json *j, int tok, const char *want) +{ + struct json nested; + char *serialized; + size_t len; + int root, ok = 0; + + if (tok == -1 || !json_is_string(j, tok) || + (serialized = json_get_str(j, tok, &len)) == NULL) + return (0); + if (json_parse(&nested, serialized, len, 0) != 0) + goto done; + root = json_root(&nested); + ok = json_is_object(&nested, root) && + string_is(&nested, json_obj_get(&nested, root, "preview_head"), + want) && + string_is(&nested, json_obj_get(&nested, root, "preview_tail"), "") && + string_is(&nested, json_obj_get(&nested, root, "preview_encoding"), + "utf-8") && + json_get_bool(&nested, + json_obj_get(&nested, root, "preview_abbreviated")) == 0; + json_done(&nested); +done: + free(serialized); + return (ok); +} + static const u_char * request_body(const struct buf *b, size_t *len) { @@ -134,7 +162,8 @@ tool_text_message_is(const struct json *j, int msg, co !json_is_object(j, block) || !string_is(j, json_obj_get(j, block, "type"), "tool_result") || !string_is(j, json_obj_get(j, block, "tool_use_id"), tool_id) || - !string_is(j, json_obj_get(j, block, "content"), "slept-ok\n") || + !artifact_text_is(j, json_obj_get(j, block, "content"), + "slept-ok\n") || (block = json_arr_get(j, content, 1)) == -1 || !json_is_object(j, block) || !string_is(j, json_obj_get(j, block, "type"), "text") || @@ -180,7 +209,8 @@ tool_continuation_is(const struct json *j, int message !json_is_object(j, block) || !string_is(j, json_obj_get(j, block, "type"), "tool_result") || !string_is(j, json_obj_get(j, block, "tool_use_id"), "toolu_zz") || - !string_is(j, json_obj_get(j, block, "content"), "slept-ok\n")) + !artifact_text_is(j, json_obj_get(j, block, "content"), + "slept-ok\n")) return (0); return (1); } blob - a59368d8a61b93cd383478cf92563bee950aa163 blob + 47b00ed92f85eeb33c03f3fb4ad6a84b91973756 --- regress/turn/run.sh +++ regress/turn/run.sh @@ -33,10 +33,13 @@ fail() { checks=$((checks + 1)); fails=$((fails + 1)); ok() { if [ "$2" -eq 0 ]; then pass "$1"; else fail "$1"; fi; } dir=$(mktemp -d /tmp/fugu-turn.XXXXXXXXXX) +test_home=$(mktemp -d /tmp/fugu-turn-home.XXXXXXXXXX) stubpid='' +fugupid='' cleanup() { + [ -n "$fugupid" ] && { kill "$fugupid" 2>/dev/null; wait "$fugupid" 2>/dev/null; } [ -n "$stubpid" ] && { kill "$stubpid" 2>/dev/null; wait "$stubpid" 2>/dev/null; } - rm -rf "$dir" + rm -rf "$dir" "$test_home" } trap cleanup EXIT INT TERM @@ -49,7 +52,7 @@ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curv # stage the real binaries lx=$dir/libexec/fugu mkdir -p "$lx" -for w in fugu-tty fugu-api fugu-web fugu-tool fugu-editor; do +for w in fugu-tty fugu-api fugu-web fugu-tool fugu-artifact fugu-editor; do b=$root/src/$w/obj/$w [ -x "$b" ] || b=$root/src/$w/$w [ -x "$b" ] || { echo "FAIL - $w not built"; exit 1; } @@ -58,8 +61,7 @@ done fugu=$root/src/fugu/obj/fugu [ -x "$fugu" ] || fugu=$root/src/fugu/fugu -export HOME=$dir/home -mkdir -p "$HOME" +export HOME=$test_home KEY=sk-test-abc123XYZ start_stub() { # $1 = scenario words @@ -168,6 +170,76 @@ done cp "$dir/fugu-editor.real" "$lx/fugu-editor" stop_stub +# The Lead Tool's project and fixed runtime unveils must never cover managed +# Artifact storage. Refuse each physical overlap before spawning a worker, +# opening a Journal, or contacting the Provider. +owner_home=$HOME +rm -rf "$owner_home/.fugu" +rm -f "$dir/reqout" +start_stub "OK" +write_conf "$stubport" +( cd "$owner_home" && FUGU_CONF=$dir/fugu.conf \ + FUGU_LIBEXEC=$dir/libexec/fugu FUGU_CA_FILE=$dir/cert.pem \ + "$fugu" -p "scope probe" "$dir/out" 2>"$dir/errout" ) +rc=$? +ok "Artifact scope: HOME as project root is refused" \ + $([ "$rc" -ne 0 ]; echo $?) +grep -q "Artifact storage overlaps Tool filesystem authority" "$dir/errout" +ok "Artifact scope: overlap refusal is conspicuous" $? +! test -e "$dir/reqout" && + ! ls "$owner_home/.fugu/sessions"/*.ndjson >/dev/null 2>&1 +ok "Artifact scope: refusal precedes Journal and Provider effects" $? +stop_stub + +rm -rf "$owner_home/.fugu" +mkdir -p "$owner_home/.fugu/artifacts/project" +printf 'private-marker\n' > "$owner_home/.fugu/artifacts/raw-marker" +rm -f "$dir/reqout" +start_stub "OK" +write_conf "$stubport" +( cd "$owner_home/.fugu/artifacts/project" && \ + FUGU_CONF=$dir/fugu.conf FUGU_LIBEXEC=$dir/libexec/fugu \ + FUGU_CA_FILE=$dir/cert.pem "$fugu" -p "inside probe" \ + "$dir/out" 2>"$dir/errout" ) +rc=$? +ok "Artifact scope: project inside the managed root is refused" \ + $([ "$rc" -ne 0 ]; echo $?) +test "$(cat "$owner_home/.fugu/artifacts/raw-marker")" = private-marker && + ! test -e "$dir/reqout" +ok "Artifact scope: refusal leaves raw storage untouched" $? +stop_stub + +rm -rf "$owner_home/.fugu" "$dir/home-alias" +ln -s "$owner_home" "$dir/home-alias" +rm -f "$dir/reqout" +start_stub "OK" +write_conf "$stubport" +( cd "$owner_home" && HOME="$dir/home-alias" \ + FUGU_CONF=$dir/fugu.conf FUGU_LIBEXEC=$dir/libexec/fugu \ + FUGU_CA_FILE=$dir/cert.pem "$fugu" -p "alias probe" \ + "$dir/out" 2>"$dir/errout" ) +rc=$? +ok "Artifact scope: HOME symlink aliases cannot hide an overlap" \ + $([ "$rc" -ne 0 ]; echo $?) +! test -e "$dir/reqout" && + ! ls "$owner_home/.fugu/sessions"/*.ndjson >/dev/null 2>&1 +ok "Artifact scope: aliased refusal has no external side effect" $? +stop_stub + +rm -f "$dir/reqout" +start_stub "OK" +write_conf "$stubport" +HOME=/usr FUGU_CONF=$dir/fugu.conf FUGU_LIBEXEC=$dir/libexec/fugu \ + FUGU_CA_FILE=$dir/cert.pem "$fugu" -p "fixed root probe" \ + "$dir/out" 2>"$dir/errout" +rc=$? +ok "Artifact scope: HOME below a fixed Tool unveil is refused" \ + $([ "$rc" -ne 0 ]; echo $?) +grep -q "Artifact storage overlaps Tool filesystem authority" "$dir/errout" && + ! test -e "$dir/reqout" +ok "Artifact scope: fixed-root refusal precedes Provider access" $? +stop_stub + # --- 1: a clean OK turn --------------------------------------------- start_stub "OK" write_conf "$stubport" @@ -563,6 +635,87 @@ ok "tool loop: first and continuation Generations reta $([ "$n" -eq 2 ]; echo $?) stop_stub +# --- 13-artifact: abbreviated raw output stays path-free ------------- +rm -rf "$HOME/.fugu" +start_stub "ARTIFACTTOOL OUTPUTREAD OUTPUTREADDEFAULT OK" +write_conf "$stubport" +run_fugu -p "produce retained output" +ok "artifact tool loop: abbreviated result completes" \ + $([ "$rc" -eq 0 ]; echo $?) +request_log_entry 2 "$dir/artifact-request" +grep -Fq '\"preview_abbreviated\":true' "$dir/artifact-request" +ok "artifact tool loop: next Generation receives an abbreviated result" $? +grep -Eq '\\"id\\":\\"[0-9a-f]{32}\\"' "$dir/artifact-request" +ok "artifact tool loop: result exposes only an opaque output id" $? +journal=$(ls "$sessions"/*.ndjson 2>/dev/null | head -1) +grep -a -E '(\.fugu/artifacts|\.pending-)' \ + "$dir/artifact-request" "$journal" "$dir/out" "$dir/errout" \ + >/dev/null 2>&1 +ok "artifact tool loop: no private storage path crosses runtime surfaces" \ + $([ $? -ne 0 ]; echo $?) +grep -q '"t":"artifact"' "$journal" +ok "artifact tool loop: the structured Artifact record commits" $? +request_log_entry 3 "$dir/output-read-request" +grep -Fq '"name":"output_read"' "$dir/output-read-request" && + grep -Fq '"offset":65536' "$dir/output-read-request" && + grep -Fq '"limit":4' "$dir/output-read-request" && + grep -Fq '"encoding":"base64"' "$dir/output-read-request" +ok "output_read loop: Provider arguments cross the Coordinator seam" $? +grep -Fq '\"offset\":65536' "$dir/output-read-request" && + grep -Fq '\"returned_bytes\":4' "$dir/output-read-request" && + grep -Fq '\"next_offset\":65540' "$dir/output-read-request" && + grep -Fq '\"state\":\"pending\"' "$dir/output-read-request" && + grep -Fq '\"data\":\"QQD/Qg==\"' \ + "$dir/output-read-request" +ok "output_read loop: pending base64 page reaches the next Generation" $? +request_log_entry 4 "$dir/output-read-default-request" +grep -Eq '"id":"toolu_output_read_default","name":"output_read",'\ +'"input":\{"id":"[0-9a-f]{32}"\}' "$dir/output-read-default-request" && + grep -Fq '\"offset\":0' "$dir/output-read-default-request" && + grep -Fq '\"returned_bytes\":65536' \ + "$dir/output-read-default-request" && + grep -Fq '\"next_offset\":65536' \ + "$dir/output-read-default-request" && + grep -Fq '\"encoding\":\"text\"' \ + "$dir/output-read-default-request" && + grep -Fq '\"state\":\"pending\"' \ + "$dir/output-read-default-request" +ok "output_read loop: id-only call applies text and 64 KiB defaults" $? +stop_stub + +# A read response remains an exact bounded transaction after Ctrl-C. The +# regression-only Role delay widens the cancellation window; Coordinator must +# drain DATA/READ_RESULT before Artifact Turn abort reuses the same channel. +rm -rf "$HOME/.fugu" +start_stub "ARTIFACTTOOL OUTPUTREAD OK" +write_conf "$stubport" +FUGU_CONF=$dir/fugu.conf FUGU_LIBEXEC=$dir/libexec/fugu \ + FUGU_CA_FILE=$dir/cert.pem FUGU_RETRY_BASE_MS=50 \ + FUGU_ARTIFACT_READ_DELAY_MS=1000 \ + "$fugu" -p "cancel retained output read" "$dir/out" 2>"$dir/errout" & +fugupid=$! +i=0 +while ! grep -q '^--- OUTPUTREAD ---$' "$dir/reqlog" 2>/dev/null && + [ "$i" -lt 100 ]; do + sleep 0.05 + i=$((i + 1)) +done +sleep 0.1 +/bin/kill -INT "$fugupid" 2>/dev/null +wait "$fugupid" +rc=$? +fugupid='' +ok "output_read cancel: interrupted read exits 3" \ + $([ "$rc" -eq 3 ]; echo $?) +if grep -Eq 'bad Artifact read|unexpected Artifact event' \ + "$dir/errout" 2>/dev/null; then + fail "output_read cancel: exact delayed response drains before rollback" +else + pass "output_read cancel: exact delayed response drains before rollback" +fi +stop_stub + # --- 13a: a full brokered web loop over the real mesh (M8) ---------- # The development-only private-address seam lets the isolated fixture act # as both provider and web origin; installed/setgid binaries ignore it. @@ -1019,7 +1172,7 @@ stop_stub # --- 23b: automatic Compaction preflight --------------------------- # With the minimal five-tool catalogue below, the conservative estimate for -# System "S" plus User "strict boundary" is exactly 463 tokens. Equality at +# System "S" plus User "strict boundary" is exactly 591 tokens. Equality at # window-reserve is intentionally not a cut. rm -rf "$HOME/.fugu" start_stub "OK" @@ -1036,7 +1189,7 @@ allow_write no web_search no max_bg_jobs 0 max_subagents 0 -context_limit 464 +context_limit 592 compact_reserve_tokens 1 compact_keep_tokens 1 auto_compact yes @@ -1100,9 +1253,9 @@ ok "auto Compaction disabled: only the requested Gener $([ "$n" -eq 1 ]; echo $?) stop_stub -# A 1,000-byte first User request is 709 estimated tokens, below the 750-token -# threshold. Its completed pair plus the second User reaches 765; replacing -# that history with short notes falls to 502 before the second Generation. +# A 1,000-byte first User request is 837 estimated tokens, below the 878-token +# threshold. Its completed pair plus the second User reaches 893; replacing +# that history with short notes falls to 630 before the second Generation. rm -rf "$HOME/.fugu" start_stub "OK SUMMARY OK" cat > "$dir/fugu.conf" < "$dir/fugu.conf" < "$dir/lin" +run_fugu_in "$dir/lin" +ok "background rotation: committed job poll completes" \ + $([ "$rc" -eq 0 ]; echo $?) +grep -q '"tool_use_id":"toolu_poll"' "$dir/reqout" 2>/dev/null +ok "background rotation: frozen result reaches the provider" $? +n=$(grep -c '"outcome":"ok"' "$sessions"/*.ndjson 2>/dev/null) +ok "background rotation: both Turns commit" \ + $([ "$n" = 2 ]; echo $?) +if grep -Eq 'bad Artifact|mismatched|duplicate' "$dir/errout" 2>/dev/null; then + fail "background rotation: ownership protocol stays singular" +else + pass "background rotation: ownership protocol stays singular" +fi +stop_stub + +# A committed short job retires its current available spool while the Lead is +# waiting through a Provider retry. DROP is asynchronous to Tool results; +# Coordinator must discard only that exact current spool and ACK it once. +rm -rf "$HOME/.fugu" +rm -f "$dir/reqout" +start_stub "BGSHORT OK RETRY429 OK" +write_conf "$stubport" +printf 'launch short background\nwait for available retirement\n/quit\n' \ + > "$dir/lin" +run_fugu_in "$dir/lin" +ok "background retirement: available DROP round-trip completes" \ + $([ "$rc" -eq 0 ]; echo $?) +n=$(grep -c '"outcome":"ok"' "$sessions"/*.ndjson 2>/dev/null) +ok "background retirement: both available-DROP Turns commit" \ + $([ "$n" = 2 ]; echo $?) +if grep -Eq 'bad Artifact|mismatched|stale Artifact' \ + "$dir/errout" 2>/dev/null; then + fail "background retirement: available key is acknowledged once" +else + pass "background retirement: available key is acknowledged once" +fi +stop_stub + +# Eight detached logical reservations fill the 128 MiB Session quota without +# writing 128 MiB of physical data. The ninth job receives an unavailable +# zero-id reservation, commits, and later retires it over the same exact +# DROP/ACK transaction without waiting for an Artifact terminal. +rm -rf "$HOME/.fugu" +rm -f "$dir/reqout" +scenario='' +: > "$dir/lin" +i=0 +while [ "$i" -lt 8 ]; do + scenario="$scenario BGTOOL OK" + printf 'reserve background spool %s\n' "$i" >> "$dir/lin" + i=$((i + 1)) +done +scenario="$scenario BGSHORT OK RETRY429 OK" +printf 'launch unavailable short background\nwait for unavailable retirement\n/quit\n' \ + >> "$dir/lin" +start_stub "$scenario" +write_conf "$stubport" +printf 'max_bg_jobs 9\n' >> "$dir/fugu.conf" +run_fugu_in "$dir/lin" +ok "background retirement: unavailable DROP round-trip completes" \ + $([ "$rc" -eq 0 ]; echo $?) +n=$(grep -c '"outcome":"ok"' "$sessions"/*.ndjson 2>/dev/null) +ok "background retirement: all unavailable-DROP Turns commit" \ + $([ "$n" = 10 ]; echo $?) +if grep -Eq 'bad Artifact|mismatched|stale Artifact' \ + "$dir/errout" 2>/dev/null; then + fail "background retirement: zero-id key is acknowledged once" +else + pass "background retirement: zero-id key is acknowledged once" +fi +stop_stub + +# A ROTATE frame is ownership state in its own right. Publish it with a +# blocking selftest send, then cancel before the ordinary Tool result. The +# background job is still pending in this Turn, so rollback must consume the +# frame without trying to release committed job ownership. +rm -rf "$HOME/.fugu" +rm -f "$dir/out" "$dir/errout" "$dir/reqout" +start_stub "BGTOOL JOBOUTPUT" +write_conf "$stubport" +FUGU_SELFTEST=1 FUGU_ARTIFACT_FRAME0_DELAY_MS=3000 \ + FUGU_CONF=$dir/fugu.conf FUGU_LIBEXEC=$dir/libexec/fugu \ + FUGU_CA_FILE=$dir/cert.pem FUGU_RETRY_BASE_MS=50 \ + "$fugu" -d -p "cancel pending Artifact rotation" "$dir/out" 2>"$dir/errout" & +fugupid=$! +i=0 +while ! grep -Fq 'selftest frame0 Artifact rotation published' \ + "$dir/errout" 2>/dev/null && [ "$i" -lt 200 ] && \ + kill -0 "$fugupid" 2>/dev/null; do + sleep 0.05 + i=$((i + 1)) +done +grep -Fq 'selftest frame0 Artifact rotation published' \ + "$dir/errout" 2>/dev/null +reached=$? +if [ "$reached" -eq 0 ]; then + /bin/kill -INT "$fugupid" 2>/dev/null +else + /bin/kill -TERM "$fugupid" 2>/dev/null +fi +wait "$fugupid" +rc=$? +fugupid='' +ok "background rotation cancel: pending frame publishes before Ctrl-C" \ + "$reached" +ok "background rotation cancel: pending Turn exits 3" \ + $([ "$rc" -eq 3 ]; echo $?) +n=$(grep -h -c '"outcome":"abandoned"' \ + "$sessions"/*.ndjson 2>/dev/null | awk '{ n += $1 } END { print n + 0 }') +ok "background rotation cancel: pending Turn is abandoned once" \ + $([ "$n" -eq 1 ]; echo $?) +n=$(grep -h -c '"outcome":"ok"' "$sessions"/*.ndjson 2>/dev/null | \ + awk '{ n += $1 } END { print n + 0 }') +ok "background rotation cancel: pending job never commits" \ + $([ "$n" -eq 0 ]; echo $?) +n=$(grep -Fc 'selftest frame0 Artifact rotation published' \ + "$dir/errout" 2>/dev/null) +ok "background rotation cancel: pending frame publishes exactly once" \ + $([ "$n" -eq 1 ]; echo $?) +if grep -Eq 'selftest Artifact RELEASE|bad Artifact|mismatched Artifact|stale Artifact|missing cancelled Artifact' \ + "$dir/errout" 2>/dev/null; then + fail "background rotation cancel: pending ownership rolls back cleanly" +else + pass "background rotation cancel: pending ownership rolls back cleanly" +fi +stop_stub + +# The same cancellation after the launch Turn commits must preserve the +# background job but release the frozen side of the published rotation. +# Curses routes Ctrl-C through the normal UI cancellation handshake and exits +# only after Coordinator has settled that ownership frame. +if [ -x "$obj/ptydrive" ]; then + rm -rf "$HOME/.fugu" + rm -f "$dir/reqout" "$dir/reqlog" "$dir/rotation-cursesout" + start_stub "BGTOOL OK JOBOUTPUT" + write_conf "$stubport" + printf 'launch committed background\r\000\000\000poll committed background\r\000\003' | \ + TERM=vt100 HOME=$HOME FUGU_SELFTEST=1 \ + FUGU_ARTIFACT_FRAME0_DELAY_MS=3000 \ + FUGU_CONF=$dir/fugu.conf FUGU_LIBEXEC=$dir/libexec/fugu \ + FUGU_CA_FILE=$dir/cert.pem FUGU_RETRY_BASE_MS=50 \ + "$obj/ptydrive" "$fugu" -d \ + >"$dir/rotation-cursesout" 2>&1 + rc=$? + ok "background rotation cancel: curses exits after Ctrl-C" \ + $([ "$rc" -eq 0 ]; echo $?) + n=$(grep -h -c '"outcome":"ok"' "$sessions"/*.ndjson 2>/dev/null | \ + awk '{ n += $1 } END { print n + 0 }') + ok "background rotation cancel: launch Turn remains committed" \ + $([ "$n" -eq 1 ]; echo $?) + n=$(grep -h -c '"outcome":"abandoned"' \ + "$sessions"/*.ndjson 2>/dev/null | \ + awk '{ n += $1 } END { print n + 0 }') + ok "background rotation cancel: poll Turn is abandoned once" \ + $([ "$n" -eq 1 ]; echo $?) + n=$(grep -Fc 'selftest frame0 Artifact rotation published' \ + "$dir/rotation-cursesout" 2>/dev/null) + ok "background rotation cancel: committed frame publishes once" \ + $([ "$n" -eq 1 ]; echo $?) + n=$(grep -Fc 'selftest Artifact RELEASE job=1' \ + "$dir/rotation-cursesout" 2>/dev/null) + ok "background rotation cancel: committed frozen spool releases once" \ + $([ "$n" -eq 1 ]; echo $?) + if grep -Eq 'selftest Artifact CANCEL_ACK|bad Artifact|mismatched Artifact|stale Artifact|missing cancelled Artifact' \ + "$dir/rotation-cursesout" 2>/dev/null; then + fail "background rotation cancel: committed ownership settles cleanly" + else + pass "background rotation cancel: committed ownership settles cleanly" + fi + stop_stub +else + fail "background rotation cancel: ptydrive helper not built" +fi + +# Force a completed committed job into normal archival while its Artifact +# terminal is delayed and converted to STORE_ERROR. DROP is already queued +# on the Tool socket when Coordinator sends CANCEL; the ordered Tool output +# must therefore be DROP followed by the exact job-wide CANCEL_ACK. +rm -rf "$HOME/.fugu" +rm -f "$dir/out" "$dir/errout" "$dir/reqout" +start_stub "BGFAST OK RETRY429 OK JOBOUTPUT OK" +write_conf "$stubport" +printf 'launch short background\nwait for store failure\nprobe cancelled background\n/quit\n' \ + > "$dir/lin" +FUGU_SELFTEST=1 FUGU_ARTIFACT_ARCHIVE_COMPLETED=1 \ + FUGU_ARTIFACT_DROP_DELAY_MS=1500 \ + FUGU_ARTIFACT_STORE_ERROR_DELAY_MS=500 \ + FUGU_CONF=$dir/fugu.conf FUGU_LIBEXEC=$dir/libexec/fugu \ + FUGU_CA_FILE=$dir/cert.pem FUGU_RETRY_BASE_MS=50 \ + "$fugu" -d <"$dir/lin" >"$dir/out" 2>"$dir/errout" +rc=$? +ok "background retirement race: session completes" \ + $([ "$rc" -eq 0 ]; echo $?) +n=$(grep -h -c '"outcome":"ok"' "$sessions"/*.ndjson 2>/dev/null | \ + awk '{ n += $1 } END { print n + 0 }') +ok "background retirement race: all three Turns commit" \ + $([ "$n" -eq 3 ]; echo $?) +grep -q 'shell_output: no such job 1' "$dir/reqout" 2>/dev/null +ok "background retirement race: failed job ownership is gone" $? +drop_line=$(grep -n -m 1 'selftest Artifact DROP queued' \ + "$dir/errout" 2>/dev/null | cut -d: -f1) +error_line=$(grep -n -m 1 'selftest STORE_ERROR injected' \ + "$dir/errout" 2>/dev/null | cut -d: -f1) +ack_line=$(grep -n -m 1 'selftest Artifact CANCEL_ACK job=1' \ + "$dir/errout" 2>/dev/null | cut -d: -f1) +[ -n "$drop_line" ] && [ -n "$error_line" ] && [ -n "$ack_line" ] && \ + [ "$drop_line" -lt "$error_line" ] && [ "$error_line" -lt "$ack_line" ] +ok "background retirement race: DROP precedes STORE_ERROR and CANCEL_ACK" $? +n=$(grep -Ec 'selftest Artifact DROP queued|selftest STORE_ERROR injected|selftest Artifact CANCEL_ACK job=1' \ + "$dir/errout" 2>/dev/null) +ok "background retirement race: each protocol trace occurs exactly once" \ + $([ "$n" -eq 3 ]; echo $?) +if grep -Eq 'bad Artifact|mismatched Artifact|stale Artifact|unexpected Artifact|worker .* exited' \ + "$dir/errout" 2>/dev/null; then + fail "background retirement race: exact-key composition stays valid" +else + pass "background retirement race: exact-key composition stays valid" +fi +stop_stub + echo "$checks checks, $fails failures" [ "$fails" -eq 0 ] blob - 2f966dd42bec9929878d783035555b9a72ec860e blob + 4c738bf1b95897ccc589f844b521cd6d3f82746d --- regress/turn/stub.c +++ regress/turn/stub.c @@ -26,8 +26,11 @@ * stream with no content blocks), BADARGS (a complete but malformed * subagent tool input), PROTODRAIN (a malformed Generation followed by a * delayed Provider terminal), NOARGS (a subagent tool call with absent input), - * WEBFETCH (a brokered web_fetch), - * BGTOOL (shell_bg sleep 300), ERROR_BG (the provider failure after + * WEBFETCH (a brokered web_fetch), OUTPUTREAD (an explicit brokered Artifact + * page), OUTPUTREADDEFAULT (a defaults-only Artifact page), + * BGTOOL (shell_bg sleep 300), BGSHORT (shell_bg sleep 1), + * BGFAST (shell_bg immediate output), + * ERROR_BG (the provider failure after * that launch), JOBOUTPUT (shell_output job 1), DOUBLE (a hostile error event * appended after a complete OK stream), AGENTCALL (lead delegates to a * labelled subagent), AGENTREAD (the subagent reads a fixture), AGENTREPORT @@ -205,6 +208,31 @@ static const char sse_tooluse[] = "event: message_stop\n" "data: {\"type\":\"message_stop\"}\n\n"; +/* A printable result just over the provider-visible ceiling must be replaced + * by Fugu's abbreviated Artifact document before the next Generation. */ +static const char sse_artifact_tool[] = +"event: message_start\n" +"data: {\"type\":\"message_start\",\"message\":{\"usage\":" + "{\"input_tokens\":9,\"output_tokens\":1}}}\n\n" +"event: content_block_start\n" +"data: {\"type\":\"content_block_start\",\"index\":0," + "\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_artifact\"," + "\"name\":\"shell\"}}\n\n" +"event: content_block_delta\n" +"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":" + "{\"type\":\"input_json_delta\",\"partial_json\":" + "\"{\\\"command\\\":\\\"perl -e 'print q(ARTIFACT-MARKER); " + "print q(x) x 65521; print pack(q(C*),65,0,255,66); " + "print q(ARTIFACT-MARKER) " + "x 24000'\\\"}\"}}\n\n" +"event: content_block_stop\n" +"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n" +"event: message_delta\n" +"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":" + "\"tool_use\"},\"usage\":{\"output_tokens\":12}}\n\n" +"event: message_stop\n" +"data: {\"type\":\"message_stop\"}\n\n"; + /* The lead delegates a self-contained task to a labelled subagent. */ static const char sse_agentcall[] = "event: message_start\n" @@ -859,7 +887,108 @@ send_webfetch(struct tls *conn, int port) send_webfetch_path(conn, port, "web"); } +static int +hex_lower(int ch) +{ + return ((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')); +} + static void +output_read_id(const char *req, char id[33]) +{ + static const char marker[] = "\\\"id\\\":\\\""; + const char *p; + size_t i; + + p = strstr(req, marker); + if (p == NULL) + errx(1, "OUTPUTREAD fixture missing Artifact id"); + p += sizeof(marker) - 1; + for (i = 0; i < 32; i++) { + if (!hex_lower((unsigned char)p[i])) + errx(1, "OUTPUTREAD fixture malformed Artifact id"); + id[i] = p[i]; + } + id[32] = '\0'; + if (p[32] != '\\' || p[33] != '"') + errx(1, "OUTPUTREAD fixture unterminated Artifact id"); +} + +/* Recover the opaque id from the preceding generated Artifact result, then + * ask the Coordinator-local output_read tool for an exact binary page. */ +static void +send_output_read(struct tls *conn, const char *req) +{ + char id[33], sse[2048]; + int n; + + output_read_id(req, id); + + n = snprintf(sse, sizeof(sse), + "event: message_start\n" + "data: {\"type\":\"message_start\",\"message\":{\"usage\":" + "{\"input_tokens\":9,\"output_tokens\":1}}}\n\n" + "event: content_block_start\n" + "data: {\"type\":\"content_block_start\",\"index\":0," + "\"content_block\":{\"type\":\"tool_use\",\"id\":" + "\"toolu_output_read\",\"name\":\"output_read\"}}\n\n" + "event: content_block_delta\n" + "data: {\"type\":\"content_block_delta\",\"index\":0," + "\"delta\":{\"type\":\"input_json_delta\"," + "\"partial_json\":\"{\\\"id\\\":\\\"%s\\\"," + "\\\"offset\\\":65536,\\\"limit\\\":4," + "\\\"encoding\\\":\\\"base64\\\"}\"}}\n\n" + "event: content_block_stop\n" + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n" + "event: message_delta\n" + "data: {\"type\":\"message_delta\",\"delta\":{" + "\"stop_reason\":\"tool_use\"},\"usage\":{" + "\"output_tokens\":12}}\n\n" + "event: message_stop\n" + "data: {\"type\":\"message_stop\"}\n\n", id); + if (n < 0 || (size_t)n >= sizeof(sse)) + errx(1, "OUTPUTREAD fixture too large"); + (void)tls_write_all(conn, body_ok, strlen(body_ok)); + write_chunk(conn, sse, (size_t)n); + (void)tls_write_all(conn, "0\r\n\r\n", 5); +} + +/* Exercise Coordinator defaults with id as the only supplied argument. */ +static void +send_output_read_default(struct tls *conn, const char *req) +{ + char id[33], sse[2048]; + int n; + + output_read_id(req, id); + n = snprintf(sse, sizeof(sse), + "event: message_start\n" + "data: {\"type\":\"message_start\",\"message\":{\"usage\":" + "{\"input_tokens\":9,\"output_tokens\":1}}}\n\n" + "event: content_block_start\n" + "data: {\"type\":\"content_block_start\",\"index\":0," + "\"content_block\":{\"type\":\"tool_use\",\"id\":" + "\"toolu_output_read_default\",\"name\":\"output_read\"}}\n\n" + "event: content_block_delta\n" + "data: {\"type\":\"content_block_delta\",\"index\":0," + "\"delta\":{\"type\":\"input_json_delta\"," + "\"partial_json\":\"{\\\"id\\\":\\\"%s\\\"}\"}}\n\n" + "event: content_block_stop\n" + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n" + "event: message_delta\n" + "data: {\"type\":\"message_delta\",\"delta\":{" + "\"stop_reason\":\"tool_use\"},\"usage\":{" + "\"output_tokens\":12}}\n\n" + "event: message_stop\n" + "data: {\"type\":\"message_stop\"}\n\n", id); + if (n < 0 || (size_t)n >= sizeof(sse)) + errx(1, "OUTPUTREADDEFAULT fixture too large"); + (void)tls_write_all(conn, body_ok, strlen(body_ok)); + write_chunk(conn, sse, (size_t)n); + (void)tls_write_all(conn, "0\r\n\r\n", 5); +} + +static void send_wave_webfetch(struct tls *conn, int port, const char *req) { if (strstr(req, "WAVE-PROMPT-A") != NULL) @@ -952,6 +1081,51 @@ static const char sse_bgtool[] = "event: message_stop\n" "data: {\"type\":\"message_stop\"}\n\n"; +/* A short job completes after its launching Turn can commit. A later + * retrying request keeps the Coordinator event loop alive long enough to + * exercise asynchronous current-spool retirement. */ +static const char sse_bgshort[] = +"event: message_start\n" +"data: {\"type\":\"message_start\",\"message\":{\"usage\":" + "{\"input_tokens\":9,\"output_tokens\":1}}}\n\n" +"event: content_block_start\n" +"data: {\"type\":\"content_block_start\",\"index\":0," + "\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_short\"," + "\"name\":\"shell_bg\"}}\n\n" +"event: content_block_delta\n" +"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":" + "{\"type\":\"input_json_delta\",\"partial_json\":" + "\"{\\\"command\\\":\\\"sleep 1; echo short-done\\\"}\"}}\n\n" +"event: content_block_stop\n" +"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n" +"event: message_delta\n" +"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":" + "\"tool_use\"},\"usage\":{\"output_tokens\":12}}\n\n" +"event: message_stop\n" +"data: {\"type\":\"message_stop\"}\n\n"; + +/* Complete comfortably before the retrying next Turn so its current spool + * can be archived without a model-requested shell_output poll. */ +static const char sse_bgfast[] = +"event: message_start\n" +"data: {\"type\":\"message_start\",\"message\":{\"usage\":" + "{\"input_tokens\":9,\"output_tokens\":1}}}\n\n" +"event: content_block_start\n" +"data: {\"type\":\"content_block_start\",\"index\":0," + "\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_fast\"," + "\"name\":\"shell_bg\"}}\n\n" +"event: content_block_delta\n" +"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":" + "{\"type\":\"input_json_delta\",\"partial_json\":" + "\"{\\\"command\\\":\\\"echo fast-done\\\"}\"}}\n\n" +"event: content_block_stop\n" +"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n" +"event: message_delta\n" +"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":" + "\"tool_use\"},\"usage\":{\"output_tokens\":12}}\n\n" +"event: message_stop\n" +"data: {\"type\":\"message_stop\"}\n\n"; + /* Probe the exact id allocated by the preceding BGTOOL response. If the * failed turn was rolled back, shell_output returns "no such job 1" and * that tool error appears in the following request. */ @@ -1578,6 +1752,16 @@ main(int argc, char *argv[]) write_chunk(conn, sse_tooluse, strlen(sse_tooluse)); (void)tls_write_all(conn, "0\r\n\r\n", 5); + } else if (strcmp(argv[i], "ARTIFACTTOOL") == 0) { + (void)tls_write_all(conn, body_ok, + strlen(body_ok)); + write_chunk(conn, sse_artifact_tool, + strlen(sse_artifact_tool)); + (void)tls_write_all(conn, "0\r\n\r\n", 5); + } else if (strcmp(argv[i], "OUTPUTREAD") == 0) { + send_output_read(conn, req); + } else if (strcmp(argv[i], "OUTPUTREADDEFAULT") == 0) { + send_output_read_default(conn, req); } else if (strcmp(argv[i], "AGENTCALL") == 0) { (void)tls_write_all(conn, body_ok, strlen(body_ok)); @@ -1923,6 +2107,18 @@ main(int argc, char *argv[]) write_chunk(conn, sse_bgtool, strlen(sse_bgtool)); (void)tls_write_all(conn, "0\r\n\r\n", 5); + } else if (strcmp(argv[i], "BGSHORT") == 0) { + (void)tls_write_all(conn, body_ok, + strlen(body_ok)); + write_chunk(conn, sse_bgshort, + strlen(sse_bgshort)); + (void)tls_write_all(conn, "0\r\n\r\n", 5); + } else if (strcmp(argv[i], "BGFAST") == 0) { + (void)tls_write_all(conn, body_ok, + strlen(body_ok)); + write_chunk(conn, sse_bgfast, + strlen(sse_bgfast)); + (void)tls_write_all(conn, "0\r\n\r\n", 5); } else if (strcmp(argv[i], "ERROR_BG") == 0) { (void)tls_write_all(conn, body_ok, strlen(body_ok)); blob - 017c177fb52c67a49420620a94e387cd98208b87 blob + c41d37e01f962721f2c0d9941f3795cbc491b823 --- regress/web/tooldefs_test.c +++ regress/web/tooldefs_test.c @@ -27,6 +27,12 @@ main(void) memset(&cf, 0, sizeof(cf)); defs = tooldefs(&cf, NULL, &n); + d = find(defs, n, "output_read"); + CHECK(d != NULL && strstr(d->schema, "\"required\":[\"id\"]") != NULL); + CHECK(d != NULL && strstr(d->schema, "\"offset\"") != NULL && + strstr(d->schema, "\"limit\"") != NULL && + strstr(d->schema, "\"encoding\"") != NULL && + strstr(d->schema, "\"path\"") == NULL); CHECK(find(defs, n, "web_search") == NULL); CHECK(find(defs, n, "web_fetch") == NULL); CHECK(find(defs, n, "http_request") == NULL); blob - a0d582a0446f829a8eb7822ad01aaecd3936273e blob + 700421220e2a3822ce1396669b4f4dce9a7609e1 --- scripts/live-check +++ scripts/live-check @@ -956,7 +956,7 @@ privileged() "root:_fugu:2555" ] ok "installed front binary is root:_fugu mode 2555" $? - workers='fugu-api fugu-editor fugu-tool fugu-tty fugu-web' + workers='fugu-api fugu-artifact fugu-editor fugu-tool fugu-tty fugu-web' rc=0 for worker in $workers; do path=$prefix/libexec/fugu/$worker @@ -965,7 +965,7 @@ privileged() "root:wheel:555" ] || rc=1 done - ok "all five workers are root:wheel mode 0555" "$rc" + ok "all six workers are root:wheel mode 0555" "$rc" [ -f "$prefix/man/man1/fugu.1" ] && [ -f "$prefix/man/man5/fugu.conf.5" ] && [ "$(stat -f '%Su:%Sg:%Lp' "$prefix/man/man1/fugu.1")" = \ blob - 3a5b3e5ba5f5dbdea6852e9508b54cb9e21709bb blob + c805edb8d73ef9b945e5385a660518f1fa6fe68f --- src/fugu/Makefile +++ src/fugu/Makefile @@ -7,8 +7,8 @@ SRCS= main.c coord.c priv.c conf.c parse.y \ generation.c compaction.c msg.c \ provider_state.c \ json.c utf8.c artifact_result.c artifact_page.c artifact_lock.c \ - model_window.c effort.c \ - buf.c imsgev.c log.c xmalloc.c + artifact_pipe.c artifact_scope.c model_window.c effort.c buf.c \ + imsgev.c log.c xmalloc.c MAN= fugu.1 fugu.conf.5 MANDIR= ${PREFIX}/man/man blob - 7fad8eb0c201bda22f5fd69cfd1f0bd99e5a4c4c blob + 627654da188367f06d91eed00db8e43157e03c0c --- src/fugu/artifact_capture.c +++ src/fugu/artifact_capture.c @@ -100,6 +100,19 @@ artifact_capture_free(struct artifact_capture *capture memset(capture, 0, sizeof(*capture)); } +size_t +artifact_capture_read_limit(const struct artifact_capture *capture, size_t want) +{ + uint64_t left; + + if (capture->observed >= (uint64_t)ARTIFACT_RAW_MAX + 1) + return (0); + left = (uint64_t)ARTIFACT_RAW_MAX + 1 - capture->observed; + if ((uint64_t)want > left) + want = (size_t)left; + return (want); +} + int artifact_capture_add(struct artifact_capture *capture, const void *data, size_t len) blob - 3759b94c1981bf6a95b6862330ebfc0e7a7fe3a2 blob + 6d043e6f5cf6d3c9eec713aeeca0feef479f5972 --- src/fugu/artifact_capture.h +++ src/fugu/artifact_capture.h @@ -34,6 +34,8 @@ struct artifact_capture { void artifact_capture_init(struct artifact_capture *); void artifact_capture_reset(struct artifact_capture *); void artifact_capture_free(struct artifact_capture *); +/* Bound one producer read to the first byte that proves a hard-cap crossing. */ +size_t artifact_capture_read_limit(const struct artifact_capture *, size_t); /* Adds observed bytes, retaining at most 16 MiB. Returns 1 on cap crossing. */ int artifact_capture_add(struct artifact_capture *, const void *, size_t); void artifact_capture_view(const struct artifact_capture *, blob - c32217e2d36f75fa53dfe11fdcd20543936bc89a blob + 8be7494b9a5dec8d0f37df4612a802afac4bc1bf --- src/fugu/artifact_lock.c +++ src/fugu/artifact_lock.c @@ -7,6 +7,7 @@ */ #include +#include #include #include @@ -37,7 +38,8 @@ artifact_lifetime_lock_valid(int fd, const char *root, (flags & O_ACCMODE) != O_RDWR || (flags & (O_APPEND | O_NONBLOCK)) != 0 || (fdflags = fcntl(fd, F_GETFD)) == -1 || - (fdflags & FD_CLOEXEC) == 0) + (fdflags & FD_CLOEXEC) == 0 || + flock(fd, LOCK_EX | LOCK_NB) == -1) return (0); return (1); } blob - cba1d4dc9abc56081db28756f3f9181b58d24424 blob + 96c31c3b305d3ce2dcb11879f7b2effe2f43ba73 --- src/fugu/artifact_page.c +++ src/fugu/artifact_page.c @@ -171,14 +171,14 @@ artifact_page_build(struct buf *out, const char *id, u if (encoding == ARTIFACT_PAGE_TEXT) { while (used < len) { unit = escaped_unit(raw + used, len - used, &nraw); - if (unit == 0) { - *retry_base64 = 1; - return (retry_result(out, id, offset)); + if (unit == 0 || + (unit == SIZE_MAX && offset + len == total)) { + if (used == 0) { + *retry_base64 = 1; + return (retry_result(out, id, offset)); + } + break; } - if (unit == SIZE_MAX && offset + len == total) { - *retry_base64 = 1; - return (retry_result(out, id, offset)); - } if (unit == SIZE_MAX || unit > ARTIFACT_PAGE_DATA_ENCODED_MAX - enc) break; blob - /dev/null blob + 123933b8353f2b99060d6a3f071750b22d0ad4c5 (mode 644) --- /dev/null +++ src/fugu/artifact_pipe.c @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 Isaac + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + */ + +#include +#include + +#include +#include + +#include "artifact_pipe.h" + +int +artifact_producer_endpoint_valid(int fd) +{ + struct stat st; + int flags, fdflags; + + /* OpenBSD's bidirectional pipe extension reports both pipe(2) + * endpoints as exactly O_RDWR. Protocol assigns this endpoint only + * the producer direction; it must also remain a blocking FIFO and + * close across exec. */ + return (fd >= 0 && fstat(fd, &st) == 0 && S_ISFIFO(st.st_mode) && + (flags = fcntl(fd, F_GETFL)) != -1 && flags == O_RDWR && + (fdflags = fcntl(fd, F_GETFD)) != -1 && + (fdflags & FD_CLOEXEC) != 0); +} blob - /dev/null blob + ffeec137dd2076e7b78851f5e643a924b9c2d11f (mode 644) --- /dev/null +++ src/fugu/artifact_pipe.h @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2026 Isaac + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + */ + +#ifndef ARTIFACT_PIPE_H +#define ARTIFACT_PIPE_H + +/* Exact validation for the producer endpoint brokered by Artifact. */ +int artifact_producer_endpoint_valid(int); + +#endif /* ARTIFACT_PIPE_H */ blob - 444af14d763fae39f59299bfcb868bf57892a4d5 blob + c121936fd24e3eb1237528dc5cba5d5fded4e091 --- src/fugu/coord.c +++ src/fugu/coord.c @@ -15,6 +15,7 @@ */ #include +#include #include #include @@ -24,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -64,6 +66,8 @@ #include "artifact_result.h" #include "artifact_page.h" #include "artifact_lock.h" +#include "artifact_pipe.h" +#include "artifact_scope.h" #define TURN_ITER_MAX 50 /* behavior.md section 5 */ #define MAX_TOOL_CALLS GENERATION_CALL_MAX @@ -101,18 +105,36 @@ struct tool_call { struct coord; struct artifact_live { - struct artifact_live *next; + LIST_ENTRY(artifact_live) entry; uint32_t correlation; + unsigned int refs; + int removed; int background; int adopted; + int job_id; + int job_failed; + int failed_discarded; int turn_owned; int discard_when_terminal; + int release_when_terminal; + int rotation_done; + int rotation_adopted; + int rotation_detach; + int drop_seen; + int drop_pending; + int cancel_hold; + int cancel_retire; + int cancel_pending; + uint32_t frozen_correlation; + char frozen_id[FUGU_ARTIFACT_ID_BUFSZ]; int stream_done; int terminal_done; struct artifact_stream stream; struct artifact_terminal terminal; }; +LIST_HEAD(artifact_live_head, artifact_live); + /* One on-demand fugu-tool -r instance. Its conversation and local tool * exchanges stay inside that process; only the final report is retained. */ struct subagent { @@ -261,9 +283,12 @@ struct coord { uint32_t artifact_ctl_expected; int artifact_ctl_done; uint32_t artifact_call_id; + int artifact_call_job_id; + int artifact_call_shell_bg; + int artifact_call_shell_output; struct artifact_live *artifact_call; struct artifact_live *artifact_wait; - struct artifact_live *artifacts; + struct artifact_live_head artifacts; struct buf artifact_read_data; struct artifact_read_result artifact_read_result; struct artifact_read_req artifact_read_req; @@ -313,6 +338,22 @@ static const char *validate_epoch_projection(struct co enum turn_tools_mode, int); static enum turn_io compact_projection(struct coord *, int, int, enum turn_tools_mode, struct generation_usage *, char *, size_t); +static void artifact_background_failure(struct coord *, + struct artifact_live *); +static void artifact_rotation_event(struct coord *, uint32_t, + const u_char *, size_t); +static void artifact_drop_event(struct coord *, uint32_t, + const u_char *, size_t); +static void artifact_cancel_ack_event(struct coord *, uint32_t, + const u_char *, size_t); +static void artifact_cancelled_rotation(struct coord *, + struct artifact_live *); +static void artifact_discard(struct coord *, const struct artifact_live *); +static void artifact_job_control(struct coord *, uint32_t, + const struct artifact_live *); +static void artifact_cancel_retire(struct coord *, int); +static int artifact_cancelled_tool_result_valid(struct coord *, + const struct artifact_live *, const struct tool_result *); static struct subagent * agent_by_api_id(struct coord *c, uint32_t id) @@ -395,7 +436,7 @@ artifact_live_by_correlation(struct coord *c, uint32_t { struct artifact_live *live; - for (live = c->artifacts; live != NULL; live = live->next) + LIST_FOREACH(live, &c->artifacts, entry) if (live->correlation == correlation) return (live); return (NULL); @@ -408,7 +449,7 @@ artifact_live_by_id(struct coord *c, const char *id) if (!artifact_id_wire_valid(id)) return (NULL); - for (live = c->artifacts; live != NULL; live = live->next) + LIST_FOREACH(live, &c->artifacts, entry) if (live->stream_done && live->stream.status == ARTIFACT_STREAM_AVAILABLE && strcmp(live->stream.id, id) == 0) @@ -416,6 +457,48 @@ artifact_live_by_id(struct coord *c, const char *id) return (NULL); } +static int +artifact_result_names_call_stream(const struct tool_result *result, + const struct artifact_live *live) +{ + static const char zero[FUGU_ARTIFACT_ID_BUFSZ]; + + if (live == NULL || !live->stream_done) + return (0); + if (live->stream.status == ARTIFACT_STREAM_AVAILABLE) + return (artifact_id_wire_valid(result->artifact_id) && + strcmp(result->artifact_id, live->stream.id) == 0); + if (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE) + return (memcmp(result->artifact_id, zero, sizeof(zero)) == 0); + return (0); +} + +static int +artifact_result_fields_zero(const struct tool_result *result) +{ + static const char zero[FUGU_ARTIFACT_ID_BUFSZ]; + + return (!result->artifact && !result->keep_artifact && + !result->artifact_cap_reached && !result->spool_frozen && + !result->spool_adopted && !result->spool_detach && + result->background_job_id == 0 && + result->termination == ARTIFACT_TERM_EXIT && + result->observed_bytes == 0 && result->retained_bytes == 0 && + memcmp(result->artifact_id, zero, sizeof(zero)) == 0); +} + +static int +artifact_result_matches_rotation(const struct tool_result *result, + const struct artifact_live *live) +{ + return (live != NULL && live->rotation_done && result->spool_frozen && + result->spool_adopted == live->rotation_adopted && + result->spool_detach == live->rotation_detach && + result->background_job_id == live->job_id && + memcmp(result->artifact_id, live->frozen_id, + sizeof(live->frozen_id)) == 0); +} + static struct artifact_live * artifact_live_new(struct coord *c, uint32_t correlation, int background) { @@ -428,26 +511,70 @@ artifact_live_new(struct coord *c, uint32_t correlatio live->correlation = correlation; live->background = background; live->turn_owned = 1; - live->next = c->artifacts; - c->artifacts = live; + LIST_INSERT_HEAD(&c->artifacts, live, entry); return (live); } static void +artifact_live_pin(struct artifact_live *live) +{ + if (live == NULL || live->refs == UINT_MAX) + fatalx("invalid Artifact live pin"); + live->refs++; +} + +static void +artifact_live_unpin(struct artifact_live *live) +{ + if (live == NULL || live->refs == 0) + fatalx("invalid Artifact live unpin"); + live->refs--; + if (live->removed && live->refs == 0) + free(live); +} + +static void +artifact_live_remove_now(struct coord *c, struct artifact_live *live) +{ + struct artifact_live *scan; + + if (live == NULL || live->removed) + fatalx("duplicate Artifact live removal"); + LIST_FOREACH(scan, &c->artifacts, entry) + if (scan == live) + break; + if (scan == NULL) + fatalx("lost Artifact live state"); + LIST_FOREACH(scan, &c->artifacts, entry) { + if (scan == live || scan->frozen_correlation != + live->correlation) + continue; + scan->frozen_correlation = 0; + } + LIST_REMOVE(live, entry); + live->removed = 1; + if (c->artifact_call == live) + c->artifact_call = NULL; + if (c->artifact_wait == live) + c->artifact_wait = NULL; + if (live->refs == 0) + free(live); +} + +static void artifact_live_remove(struct coord *c, struct artifact_live *live) { - struct artifact_live **link; - - for (link = &c->artifacts; *link != NULL; link = &(*link)->next) { - if (*link != live) - continue; - *link = live->next; + if (live == NULL || live->removed) + fatalx("duplicate Artifact live removal"); + if (live->cancel_hold) { + live->cancel_retire = 1; if (c->artifact_call == live) c->artifact_call = NULL; - free(live); + if (c->artifact_wait == live) + c->artifact_wait = NULL; return; } - fatalx("lost Artifact live state"); + artifact_live_remove_now(c, live); } static uint32_t @@ -554,7 +681,7 @@ turn_artifacts_commit(void *arg) struct artifact_live *live; artifact_turn_ctl(c, FUGU_IMSG_ARTIFACT_TURN_COMMIT); - for (live = c->artifacts; live != NULL; live = live->next) + LIST_FOREACH(live, &c->artifacts, entry) if (live->turn_owned) live->turn_owned = 0; c->nturn_artifacts = 0; @@ -567,8 +694,7 @@ turn_artifacts_abort(void *arg) struct artifact_live *live, *next; artifact_turn_ctl(c, FUGU_IMSG_ARTIFACT_TURN_ABORT); - for (live = c->artifacts; live != NULL; live = next) { - next = live->next; + LIST_FOREACH_SAFE(live, &c->artifacts, entry, next) { if (live->turn_owned) artifact_live_remove(c, live); } @@ -735,6 +861,96 @@ libexec_dir(void) } static int +path_contains(const char *parent, const char *child) +{ + size_t len; + + len = strlen(parent); + if (len == 1 && parent[0] == '/') + return (child[0] == '/'); + return (strncmp(parent, child, len) == 0 && + (child[len] == '\0' || child[len] == '/')); +} + +static int +paths_overlap(const char *a, const char *b) +{ + return (path_contains(a, b) || path_contains(b, a)); +} + +/* + * The persistent Lead Tool unveils the physical project tree plus these + * fixed runtime roots. Managed Artifact bytes must be outside every one of + * those trees: a broader unveil cannot be repaired after a Tool starts, and + * the Tool Role must never receive raw-store path authority. + */ +static void +artifact_root_scope_check(const struct coord *c, const char *root) +{ + static const char *const fixed[] = { + "/dev/null", "/usr", "/bin", "/sbin", "/lib", "/libexec" + }; + static const char *const network[] = { + "/etc/resolv.conf", "/etc/hosts", "/etc/ssl" + }; + char path[PATH_MAX]; + size_t i; + + if (realpath(".", path) == NULL) + fatal("realpath project directory"); + if (paths_overlap(root, path)) + fatalx("managed Artifact storage overlaps Tool filesystem authority"); + for (i = 0; i < sizeof(fixed) / sizeof(fixed[0]); i++) { + if (realpath(fixed[i], path) == NULL && + (errno != ENOENT || strlcpy(path, fixed[i], sizeof(path)) >= + sizeof(path))) + fatal("resolve Tool runtime path %s", fixed[i]); + if (paths_overlap(root, path)) + fatalx("managed Artifact storage overlaps Tool filesystem authority"); + } + if (!c->conf->allow_subprocess_net) + return; + for (i = 0; i < sizeof(network) / sizeof(network[0]); i++) { + if (realpath(network[i], path) == NULL && + (errno != ENOENT || strlcpy(path, network[i], sizeof(path)) >= + sizeof(path))) + fatal("resolve Tool network path %s", network[i]); + if (paths_overlap(root, path)) + fatalx("managed Artifact storage overlaps Tool filesystem authority"); + } +} + +/* Resolve HOME and ~/.fugu before any worker, Journal, or Provider action. + * The preliminary physical-HOME check catches fixed-root HOME values before + * creating ~/.fugu; the second catches a pre-existing ~/.fugu symlink. */ +static void +artifact_root_prepare(struct coord *c) +{ + struct stat st; + const char *home; + char fugu[PATH_MAX], physical[PATH_MAX], root[PATH_MAX]; + + home = getenv("HOME"); + if (home == NULL || home[0] != '/' || realpath(home, physical) == NULL || + (size_t)snprintf(root, sizeof(root), "%s/.fugu/artifacts", + physical) >= sizeof(root)) + fatalx("cannot use managed Artifact storage"); + artifact_root_scope_check(c, root); + if ((size_t)snprintf(fugu, sizeof(fugu), "%s/.fugu", home) >= + sizeof(fugu) || (mkdir(fugu, 0700) == -1 && errno != EEXIST) || + realpath(fugu, physical) == NULL || + (size_t)snprintf(root, sizeof(root), "%s/artifacts", physical) >= + sizeof(root)) + fatalx("cannot use managed Artifact storage"); + artifact_root_scope_check(c, root); + if (stat(physical, &st) == -1 || !S_ISDIR(st.st_mode) || + st.st_uid != geteuid() || (st.st_mode & 0777) != 0700 || + strlcpy(c->artifact_root, root, sizeof(c->artifact_root)) >= + sizeof(c->artifact_root)) + fatalx("cannot use managed Artifact storage"); +} + +static int protect_has(const struct buf *b, const char *path) { size_t off, len; @@ -1693,17 +1909,51 @@ tool_event(struct coord *c, struct imsg *imsg) p = payload.data; len = payload.len; switch (imsg_get_type(imsg)) { + case FUGU_IMSG_TOOL_ARTIFACT_ROTATE: + artifact_rotation_event(c, imsg_get_id(imsg), p, len); + return; case FUGU_IMSG_TOOL_OUT: case FUGU_IMSG_TOOL_DIFF: if (p == NULL || len == 0) fatalx("bad cancelled Tool chunk"); return; case FUGU_IMSG_TOOL_RESULT: - if (p == NULL || len != sizeof(struct tool_result)) + { + struct artifact_live *call_live = c->artifact_call; + struct tool_result r; + int job_id; + + if (call_live == NULL && c->artifact_call_id != 0) + call_live = artifact_live_by_correlation(c, + c->artifact_call_id); + job_id = call_live != NULL ? call_live->job_id : 0; + if (p == NULL || len != sizeof(r)) fatalx("bad cancelled Tool terminal"); + memcpy(&r, p, sizeof(r)); + if ((r.is_error != 0 && r.is_error != 1) || + (r.artifact != 0 && r.artifact != 1) || + (r.keep_artifact != 0 && r.keep_artifact != 1) || + (r.artifact_cap_reached != 0 && + r.artifact_cap_reached != 1) || + (r.spool_frozen != 0 && r.spool_frozen != 1) || + (r.spool_adopted != 0 && r.spool_adopted != 1) || + (r.spool_detach != 0 && r.spool_detach != 1) || + (r.spool_detach && !r.spool_adopted) || + r.background_job_id < 0 || + !artifact_cancelled_tool_result_valid(c, call_live, &r)) + fatalx("mismatched cancelled Tool terminal"); + artifact_cancelled_rotation(c, call_live); c->tool_cancel_draining = 0; c->tool_cancel_id = 0; + c->artifact_call = NULL; + c->artifact_call_id = 0; + c->artifact_call_job_id = 0; + c->artifact_call_shell_bg = 0; + c->artifact_call_shell_output = 0; + if (job_id > 0) + artifact_cancel_retire(c, job_id); return; + } default: fatalx("bad cancelled Tool frame"); } @@ -1718,6 +1968,9 @@ tool_event(struct coord *c, struct imsg *imsg) len = payload.len; switch (imsg_get_type(imsg)) { + case FUGU_IMSG_TOOL_ARTIFACT_ROTATE: + artifact_rotation_event(c, imsg_get_id(imsg), p, len); + break; case FUGU_IMSG_TOOL_OUT: { size_t max = c->artifact_call_id != 0 ? @@ -1749,11 +2002,24 @@ tool_event(struct coord *c, struct imsg *imsg) (r.keep_artifact != 0 && r.keep_artifact != 1) || (r.artifact_cap_reached != 0 && r.artifact_cap_reached != 1) || + (r.spool_frozen != 0 && r.spool_frozen != 1) || (r.spool_adopted != 0 && r.spool_adopted != 1) || (r.spool_detach != 0 && r.spool_detach != 1) || (r.spool_detach && !r.spool_adopted) || + (!c->artifact_call_shell_output && r.spool_frozen) || + (c->artifact_call_shell_output && + (c->artifact_call == NULL || r.spool_frozen != + c->artifact_call->rotation_done || + (r.spool_frozen && + !artifact_result_matches_rotation(&r, + c->artifact_call)) || + (!r.spool_frozen && (r.spool_adopted || + r.spool_detach || + !artifact_result_names_call_stream(&r, + c->artifact_call))))) || + r.background_job_id < 0 || (c->artifact_call_id == 0 && - (r.artifact || r.spool_adopted))) + !artifact_result_fields_zero(&r))) c->turn_data_exceeded = 1; else { c->tool_is_error = r.is_error; @@ -1793,19 +2059,6 @@ tool_ctl_event(struct coord *c, struct imsg *imsg) } static int -artifact_pipe_write_fd(int fd) -{ - struct stat st; - int flags, fdflags; - - return (fd >= 0 && fstat(fd, &st) == 0 && S_ISFIFO(st.st_mode) && - (flags = fcntl(fd, F_GETFL)) != -1 && - flags == O_WRONLY && - (fdflags = fcntl(fd, F_GETFD)) != -1 && - (fdflags & FD_CLOEXEC) != 0); -} - -static int artifact_id_zero(const char id[FUGU_ARTIFACT_ID_BUFSZ]) { static const char zero[FUGU_ARTIFACT_ID_BUFSZ]; @@ -1813,7 +2066,276 @@ artifact_id_zero(const char id[FUGU_ARTIFACT_ID_BUFSZ] return (memcmp(id, zero, sizeof(zero)) == 0); } +static struct artifact_live * +artifact_unavailable_by_job(struct coord *c, int job_id, + const struct artifact_live *exclude) +{ + struct artifact_live *live, *found = NULL; + + LIST_FOREACH(live, &c->artifacts, entry) { + if (live == exclude || !live->background || !live->adopted || + live->job_id != job_id || !live->stream_done || + live->stream.status != ARTIFACT_STREAM_UNAVAILABLE) + continue; + if (found != NULL) + fatalx("ambiguous unavailable Artifact spool"); + found = live; + } + return (found); +} + +static struct artifact_live * +artifact_rotation_frozen(struct coord *c, const struct artifact_live *live) +{ + struct artifact_live *frozen; + + if (!live->rotation_done || live->frozen_correlation == 0 || + (frozen = artifact_live_by_correlation(c, + live->frozen_correlation)) == NULL) + return (NULL); + if (frozen == live || !frozen->background || !frozen->adopted || + frozen->job_id != live->job_id || !frozen->stream_done || + (artifact_id_zero(live->frozen_id) && + frozen->stream.status != ARTIFACT_STREAM_UNAVAILABLE) || + (!artifact_id_zero(live->frozen_id) && + (frozen->stream.status != ARTIFACT_STREAM_AVAILABLE || + strcmp(frozen->stream.id, live->frozen_id) != 0))) + fatalx("lost frozen Artifact spool ownership"); + return (frozen); +} + +static int +artifact_live_is_current(struct coord *c, const struct artifact_live *target) +{ + struct artifact_live *live; + + LIST_FOREACH(live, &c->artifacts, entry) + if (live != target && live->background && live->adopted && + live->job_id == target->job_id && + live->frozen_correlation == target->correlation) + return (0); + return (1); +} + +static int +artifact_live_referenced_by_active(struct coord *c, + const struct artifact_live *target) +{ + struct artifact_live *live; + + LIST_FOREACH(live, &c->artifacts, entry) + if (live != target && live->frozen_correlation == + target->correlation && (live->refs != 0 || + live == c->artifact_call || live == c->artifact_wait)) + return (1); + return (0); +} + static void +artifact_cancel_retire(struct coord *c, int job_id) +{ + struct artifact_live *live, *next; + + LIST_FOREACH_SAFE(live, &c->artifacts, entry, next) { + if (live->job_id != job_id || live->cancel_hold || + !live->cancel_retire || live->refs != 0 || + live == c->artifact_call || live == c->artifact_wait || + artifact_live_referenced_by_active(c, live)) + continue; + artifact_live_remove_now(c, live); + } +} + +static void +artifact_drop_ack(struct coord *c, const struct artifact_live *live) +{ + struct artifact_spool_drop drop; + + memset(&drop, 0, sizeof(drop)); + drop.job_id = (uint32_t)live->job_id; + if (live->stream.status == ARTIFACT_STREAM_AVAILABLE) + strlcpy(drop.artifact_id, live->stream.id, + sizeof(drop.artifact_id)); + imsgev_send(&c->iev[ROLE_TOOL], FUGU_IMSG_TOOL_ARTIFACT_DROP_ACK, + live->correlation, &drop, sizeof(drop)); +} + +/* + * A spool swap changes ownership before the ordinary Tool result is + * discardable. Record that change as its own transaction frame so Turn + * cancellation cannot roll a committed job's replacement back into the + * aborted Turn. + */ +static void +artifact_rotation_event(struct coord *c, uint32_t id, const u_char *p, + size_t len) +{ + struct artifact_spool_rotate rotate; + struct artifact_live *live, *frozen; + struct artifact_record record; + + if (id == 0 || id != c->artifact_call_id || p == NULL || + len != sizeof(rotate) || + (live = c->artifact_call) == NULL || live->removed || + live->correlation != id || !live->background || + !live->stream_done || live->rotation_done || + c->artifact_call_job_id <= 0) + fatalx("bad Artifact spool rotation"); + memcpy(&rotate, p, sizeof(rotate)); + if (rotate.job_id == 0 || rotate.job_id > INT_MAX || + rotate.job_id != (uint32_t)c->artifact_call_job_id || + rotate.replacement_adopted > 1 || rotate.detach > 1 || + (!rotate.replacement_adopted && + (rotate.detach || !artifact_id_zero(rotate.replacement_id)))) + fatalx("invalid Artifact spool rotation state"); + if (rotate.replacement_adopted && + ((live->stream.status == ARTIFACT_STREAM_AVAILABLE && + (!artifact_id_wire_valid(rotate.replacement_id) || + strcmp(rotate.replacement_id, live->stream.id) != 0)) || + (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE && + !artifact_id_zero(rotate.replacement_id)))) + fatalx("mismatched Artifact spool replacement"); + + frozen = NULL; + if (artifact_id_wire_valid(rotate.frozen_id)) { + frozen = artifact_live_by_id(c, rotate.frozen_id); + if (frozen == NULL || frozen == live || !frozen->background || + !frozen->adopted || frozen->job_id != (int)rotate.job_id || + frozen->drop_seen || + (rotate.replacement_adopted && + rotate.detach != (uint32_t)!frozen->turn_owned)) + fatalx("mismatched frozen Artifact spool"); + } else if (!artifact_id_zero(rotate.frozen_id)) + fatalx("malformed frozen Artifact spool id"); + else { + /* An unavailable old reservation has no wire id, but its retained + * live record still proves job and Turn ownership. */ + frozen = artifact_unavailable_by_job(c, (int)rotate.job_id, + live); + if (frozen == NULL || frozen->drop_seen || + (rotate.replacement_adopted && + rotate.detach != (uint32_t)!frozen->turn_owned)) + fatalx("missing unavailable Artifact spool ownership"); + } + if (!artifact_live_is_current(c, frozen)) + fatalx("historical Artifact spool cannot rotate again"); + + live->job_id = (int)rotate.job_id; + live->rotation_done = 1; + live->rotation_adopted = (int)rotate.replacement_adopted; + live->rotation_detach = (int)rotate.detach; + live->frozen_correlation = frozen->correlation; + memcpy(live->frozen_id, rotate.frozen_id, sizeof(live->frozen_id)); + if (live->rotation_adopted) + live->adopted = 1; + if (live->rotation_detach) { + live->turn_owned = 0; + if (live->stream.status == ARTIFACT_STREAM_AVAILABLE) { + memset(&record, 0, sizeof(record)); + strlcpy(record.id, live->stream.id, sizeof(record.id)); + imsgev_send(&c->iev[ROLE_ARTIFACT], + FUGU_IMSG_ARTIFACT_DETACH, live->correlation, + &record, sizeof(record)); + } + } + if (live->rotation_adopted && live->terminal_done && + live->terminal.status == ARTIFACT_STORE_ERROR) + artifact_background_failure(c, live); +} + +/* A completed, unpolled job asks to retire only its current spool. Available + * spools wait for the store terminal before ACK; unavailable reservations have + * no Artifact-side terminal and can be acknowledged immediately. */ +static void +artifact_drop_event(struct coord *c, uint32_t id, const u_char *p, size_t len) +{ + struct artifact_spool_drop drop; + struct artifact_live *live; + + if (id == 0 || p == NULL || len != sizeof(drop) || + (live = artifact_live_by_correlation(c, id)) == NULL || + live->removed || !live->background || !live->adopted || + !live->stream_done || (live->job_failed && !live->cancel_hold) || + live->drop_seen || + !artifact_live_is_current(c, live)) + fatalx("bad Artifact spool retirement"); + memcpy(&drop, p, sizeof(drop)); + if (drop.job_id == 0 || drop.job_id > INT_MAX || + live->job_id != (int)drop.job_id || + (live->stream.status == ARTIFACT_STREAM_AVAILABLE && + (!artifact_id_wire_valid(drop.artifact_id) || + strcmp(drop.artifact_id, live->stream.id) != 0)) || + (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE && + !artifact_id_zero(drop.artifact_id))) + fatalx("mismatched Artifact spool retirement"); + live->drop_seen = 1; + if (live->job_failed) { + if (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE) { + artifact_live_remove(c, live); + return; + } + if (!live->terminal_done) { + live->drop_pending = 1; + live->discard_when_terminal = 1; + return; + } + if (!live->failed_discarded) { + artifact_discard(c, live); + live->failed_discarded = 1; + } + artifact_live_remove(c, live); + return; + } + if (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE) { + artifact_drop_ack(c, live); + artifact_live_remove(c, live); + return; + } + if (!live->terminal_done) { + live->drop_pending = 1; + live->release_when_terminal = 0; + return; + } + if (live->terminal.status == ARTIFACT_STORE_ERROR) { + artifact_background_failure(c, live); + return; + } + artifact_discard(c, live); + artifact_drop_ack(c, live); + artifact_live_remove(c, live); +} + +static void +artifact_cancel_ack_event(struct coord *c, uint32_t id, const u_char *p, + size_t len) +{ + struct artifact_job_cancel_ack ack; + struct artifact_live *live, *marker = NULL; + + if (id == 0 || id > INT_MAX || p == NULL || len != sizeof(ack)) + fatalx("bad Artifact cancellation acknowledgement"); + memcpy(&ack, p, sizeof(ack)); + if (ack.job_id != id || !artifact_id_wire_valid(ack.artifact_id)) + fatalx("invalid Artifact cancellation acknowledgement"); + LIST_FOREACH(live, &c->artifacts, entry) { + if (!live->cancel_pending || live->job_id != (int)id) + continue; + if (marker != NULL) + fatalx("ambiguous Artifact cancellation acknowledgement"); + marker = live; + } + if (marker == NULL || marker->job_id != (int)id || + marker->stream.status != ARTIFACT_STREAM_AVAILABLE || + strcmp(marker->stream.id, ack.artifact_id) != 0) + fatalx("stale Artifact cancellation acknowledgement"); + marker->cancel_pending = 0; + LIST_FOREACH(live, &c->artifacts, entry) + if (live->job_id == (int)id) + live->cancel_hold = 0; + artifact_cancel_retire(c, (int)id); +} + +static void artifact_event(struct coord *c, struct imsg *imsg) { struct imsgev_payload payload; @@ -1869,7 +2391,7 @@ artifact_event(struct coord *c, struct imsg *imsg) stream.status != ARTIFACT_STREAM_UNAVAILABLE) || (stream.status == ARTIFACT_STREAM_AVAILABLE && (!artifact_id_wire_valid(stream.id) || - !artifact_pipe_write_fd(fd))) || + !artifact_producer_endpoint_valid(fd))) || (stream.status == ARTIFACT_STREAM_UNAVAILABLE && (fd != -1 || !artifact_id_zero(stream.id)))) { if (fd != -1) @@ -1898,7 +2420,7 @@ artifact_event(struct coord *c, struct imsg *imsg) } case FUGU_IMSG_ARTIFACT_TERMINAL: { struct artifact_live *live; - struct artifact_record record; + int preserve, wake; if (imsgev_get_payload(imsg, &payload) == -1 || payload.data == NULL || @@ -1916,34 +2438,64 @@ artifact_event(struct coord *c, struct imsg *imsg) live->terminal.status != ARTIFACT_STORE_ERROR)) fatalx("invalid Artifact terminal state"); live->terminal_done = 1; - if (live->terminal.status == ARTIFACT_STORE_ERROR) { - if (live->background || live->adopted) { - memset(&record, 0, sizeof(record)); - strlcpy(record.id, live->stream.id, - sizeof(record.id)); - imsgev_send(&c->iev[ROLE_TOOL], - FUGU_IMSG_TOOL_ARTIFACT_CANCEL, 0, - &record, sizeof(record)); - } else if (live == c->artifact_call && !c->tool_done) - (void)kill(c->kids[ROLE_TOOL].pid, SIGTERM); + wake = c->wait == WAIT_ARTIFACT_TERMINAL && + c->artifact_wait == live; + if (live->drop_pending) { + if (live->terminal.status == ARTIFACT_STORE_ERROR) { + artifact_background_failure(c, live); + } else if (live->job_failed) { + if (!live->failed_discarded) { + artifact_discard(c, live); + live->failed_discarded = 1; + } + if (!live->removed) + artifact_live_remove(c, live); + } else { + artifact_discard(c, live); + artifact_drop_ack(c, live); + live->drop_pending = 0; + artifact_live_remove(c, live); + } + if (wake) + event_loopexit(NULL); + return; } + if (live->terminal.status == ARTIFACT_STORE_ERROR && + live->background && live->adopted && live->job_id > 0) { + artifact_background_failure(c, live); + if (wake) + event_loopexit(NULL); + return; + } + if (live->terminal.status == ARTIFACT_STORE_ERROR && + !live->background && live == c->artifact_call && + !c->tool_done) + (void)kill(c->kids[ROLE_TOOL].pid, SIGUSR1); if (live->discard_when_terminal) { - memset(&record, 0, sizeof(record)); - record.retained_bytes = live->terminal.retained_bytes; - strlcpy(record.id, live->terminal.id, sizeof(record.id)); - imsgev_send(&c->iev[ROLE_ARTIFACT], - FUGU_IMSG_ARTIFACT_DISCARD, live->correlation, - &record, sizeof(record)); - artifact_live_remove(c, live); + if (!live->failed_discarded) { + artifact_discard(c, live); + if (live->job_failed) + live->failed_discarded = 1; + } + if (live->release_when_terminal && !live->job_failed) + artifact_job_control(c, + FUGU_IMSG_TOOL_ARTIFACT_RELEASE, live); + live->discard_when_terminal = 0; + live->release_when_terminal = 0; + preserve = live == c->artifact_call || + live == c->artifact_wait; + if (!preserve) + artifact_live_remove(c, live); + if (wake) + event_loopexit(NULL); return; } - if (c->wait == WAIT_ARTIFACT_TERMINAL && - c->artifact_wait == live) + if (wake) event_loopexit(NULL); return; } case FUGU_IMSG_ARTIFACT_DATA: - if (c->wait != WAIT_ARTIFACT_READ || id == 0 || + if (id == 0 || c->artifact_read_id == 0 || id != c->artifact_read_id || c->artifact_read_done || imsgev_get_payload(imsg, &payload) == -1 || payload.data == NULL || payload.len == 0 || @@ -1953,7 +2505,7 @@ artifact_event(struct coord *c, struct imsg *imsg) buf_add(&c->artifact_read_data, payload.data, payload.len); return; case FUGU_IMSG_ARTIFACT_READ_RESULT: - if (c->wait != WAIT_ARTIFACT_READ || id == 0 || + if (id == 0 || c->artifact_read_id == 0 || id != c->artifact_read_id || c->artifact_read_done || imsgev_get_payload(imsg, &payload) == -1 || payload.data == NULL || @@ -2427,6 +2979,9 @@ allowed_from(enum fugu_role role, uint32_t type) case FUGU_IMSG_TOOL_OUT: case FUGU_IMSG_TOOL_DIFF: case FUGU_IMSG_TOOL_RESULT: + case FUGU_IMSG_TOOL_ARTIFACT_ROTATE: + case FUGU_IMSG_TOOL_ARTIFACT_DROP: + case FUGU_IMSG_TOOL_ARTIFACT_CANCEL_ACK: case FUGU_IMSG_TOOL_TURN_ACK: case FUGU_IMSG_CTX_DATA: case FUGU_IMSG_CTX_END: @@ -2726,8 +3281,27 @@ coord_dispatch(int fd, short event, void *arg) case FUGU_IMSG_TOOL_OUT: case FUGU_IMSG_TOOL_DIFF: case FUGU_IMSG_TOOL_RESULT: + case FUGU_IMSG_TOOL_ARTIFACT_ROTATE: tool_event(coord, &imsg); break; + case FUGU_IMSG_TOOL_ARTIFACT_DROP: { + struct imsgev_payload payload; + + if (imsgev_get_payload(&imsg, &payload) == -1) + fatalx("bad Artifact spool retirement payload"); + artifact_drop_event(coord, imsg_get_id(&imsg), + payload.data, payload.len); + break; + } + case FUGU_IMSG_TOOL_ARTIFACT_CANCEL_ACK: { + struct imsgev_payload payload; + + if (imsgev_get_payload(&imsg, &payload) == -1) + fatalx("bad Artifact cancellation ACK payload"); + artifact_cancel_ack_event(coord, imsg_get_id(&imsg), + payload.data, payload.len); + break; + } case FUGU_IMSG_TOOL_TURN_ACK: tool_ctl_event(coord, &imsg); break; @@ -2814,12 +3388,22 @@ send_request(struct coord *c, struct buf *body, uint32 } static int -artifact_tool_name(const char *name) +shell_output_job_id(const struct tool_call *tc) { - return (strcmp(name, "shell") == 0 || strcmp(name, "grep") == 0 || - strcmp(name, "find") == 0 || strcmp(name, "ls") == 0 || - strcmp(name, "shell_bg") == 0 || - strcmp(name, "shell_output") == 0); + struct json j; + int root, tok, job_id = 0; + int64_t number; + + if (json_parse(&j, tc->input.data, tc->input.len, 0) != 0) + return (0); + root = json_root(&j); + if (json_is_object(&j, root) && + (tok = json_obj_get(&j, root, "id")) != -1 && + json_get_num(&j, tok, &number) == 0 && number > 0 && + number <= INT_MAX) + job_id = (int)number; + json_done(&j); + return (job_id); } static void artifact_storage_failure(struct coord *); @@ -2837,6 +3421,124 @@ artifact_discard(struct coord *c, const struct artifac } static void +artifact_job_control(struct coord *c, uint32_t type, + const struct artifact_live *live) +{ + struct artifact_record record; + + if (live->job_id <= 0) + return; + memset(&record, 0, sizeof(record)); + strlcpy(record.id, live->stream.id, sizeof(record.id)); + imsgev_send(&c->iev[ROLE_TOOL], type, (uint32_t)live->job_id, + &record, sizeof(record)); +} + +static int +artifact_job_has_committed_spool(struct coord *c, int job_id) +{ + struct artifact_live *live; + + LIST_FOREACH(live, &c->artifacts, entry) + if (live->job_id == job_id && live->background && live->adopted && + !live->turn_owned) + return (1); + return (0); +} + +static void +artifact_background_failure(struct coord *c, struct artifact_live *failed) +{ + struct artifact_live *live, *next; + int job_id = failed->job_id; + int notify = 1, pending_abort; + + if (job_id <= 0) + return; + LIST_FOREACH(live, &c->artifacts, entry) + if (live->job_id == job_id && live->job_failed) + notify = 0; + pending_abort = c->tool_ctl_expected == FUGU_IMSG_TOOL_TURN_ABORT && + !artifact_job_has_committed_spool(c, job_id); + if (notify && !pending_abort) { + LIST_FOREACH(live, &c->artifacts, entry) + if (live->job_id == job_id) + live->cancel_hold = 1; + failed->cancel_pending = 1; + artifact_job_control(c, FUGU_IMSG_TOOL_ARTIFACT_CANCEL, failed); + } + LIST_FOREACH_SAFE(live, &c->artifacts, entry, next) { + int preserve, wake; + + if (live->job_id != job_id) + continue; + live->job_failed = 1; + live->release_when_terminal = 0; + preserve = live == c->artifact_call || + live == c->artifact_wait || + (live == failed && c->wait == WAIT_TOOL && + c->artifact_call != NULL && + c->artifact_call_job_id == job_id); + if (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE) { + if (!preserve) + artifact_live_remove(c, live); + continue; + } + if (!live->terminal_done) { + live->discard_when_terminal = 1; + continue; + } + wake = c->wait == WAIT_ARTIFACT_TERMINAL && + c->artifact_wait == live; + if (!live->failed_discarded) { + artifact_discard(c, live); + live->failed_discarded = 1; + } + live->discard_when_terminal = 0; + if (!preserve) + artifact_live_remove(c, live); + if (wake) + event_loopexit(NULL); + } +} + +/* Settle the frozen side of a published rotation when its ordinary result was + * abandoned. The replacement remains live for a detached committed job; a + * Turn-owned replacement is removed by the normal Artifact Turn abort. */ +static void +artifact_cancelled_rotation(struct coord *c, struct artifact_live *live) +{ + struct artifact_live *frozen; + + if (live == NULL || !live->rotation_done) + return; + frozen = artifact_rotation_frozen(c, live); + if (frozen == NULL) { + if (!live->job_failed) + fatalx("missing cancelled Artifact spool rotation"); + return; + } + if (frozen->job_failed) + return; + if (frozen->stream.status == ARTIFACT_STREAM_UNAVAILABLE) { + artifact_live_remove(c, frozen); + return; + } + if (frozen->terminal_done) { + if (!frozen->failed_discarded) + artifact_discard(c, frozen); + if (!frozen->turn_owned && !frozen->drop_pending) + artifact_job_control(c, + FUGU_IMSG_TOOL_ARTIFACT_RELEASE, frozen); + artifact_live_remove(c, frozen); + } else { + frozen->discard_when_terminal = 1; + frozen->release_when_terminal = !frozen->turn_owned && + !frozen->drop_pending; + } +} + +static void artifact_keep(struct coord *c, const struct artifact_live *live) { struct artifact_record *record; @@ -2876,11 +3578,58 @@ artifact_tool_result_valid(const struct tool_result *r if (live->stream.status == ARTIFACT_STREAM_AVAILABLE) return (artifact_id_wire_valid(r->artifact_id) && strcmp(r->artifact_id, live->stream.id) == 0); - return (!r->keep_artifact && artifact_id_zero(r->artifact_id) && - r->termination != ARTIFACT_TERM_STORAGE_ERROR); + return (!r->keep_artifact && artifact_id_zero(r->artifact_id)); } static int +artifact_cancelled_tool_result_valid(struct coord *c, + const struct artifact_live *live, const struct tool_result *result) +{ + struct artifact_live frozen; + + if (live == NULL) + return (c->artifact_call_id == 0 && + artifact_result_fields_zero(result)); + if (c->artifact_call_id == 0 || + live->correlation != c->artifact_call_id) + return (0); + if (c->artifact_call_shell_output) { + if (!result->artifact || + result->spool_frozen != live->rotation_done) + return (0); + if (!result->spool_frozen) + return (!result->spool_adopted && !result->spool_detach && + (result->background_job_id == 0 || + result->background_job_id == live->job_id) && + artifact_tool_result_valid(result, live)); + if (!artifact_result_matches_rotation(result, live)) + return (0); + memset(&frozen, 0, sizeof(frozen)); + frozen.stream_done = 1; + if (artifact_id_zero(live->frozen_id)) + frozen.stream.status = ARTIFACT_STREAM_UNAVAILABLE; + else { + frozen.stream.status = ARTIFACT_STREAM_AVAILABLE; + memcpy(frozen.stream.id, live->frozen_id, + sizeof(frozen.stream.id)); + } + return (artifact_tool_result_valid(result, &frozen)); + } + if (result->spool_frozen || result->spool_detach) + return (0); + if (c->artifact_call_shell_bg && result->spool_adopted) { + return (!result->is_error && !result->artifact && + !result->keep_artifact && !result->artifact_cap_reached && + result->background_job_id > 0 && + result->termination == ARTIFACT_TERM_EXIT && + result->observed_bytes == 0 && result->retained_bytes == 0 && + artifact_result_names_call_stream(result, live)); + } + return (!result->spool_adopted && result->background_job_id == 0 && + artifact_tool_result_valid(result, live)); +} + +static int artifact_inline_result_valid(const struct tool_result *r) { struct artifact_live unavailable; @@ -2894,7 +3643,7 @@ artifact_inline_result_valid(const struct tool_result static int artifact_wait_terminal(struct coord *c, struct artifact_live *live) { - int rc; + int rc, result = 0; while (!live->terminal_done) { c->artifact_wait = live; @@ -2902,37 +3651,87 @@ artifact_wait_terminal(struct coord *c, struct artifac rc = event_dispatch(); if (rc == -1) fatal("event_dispatch Artifact terminal"); - if (rc == 1 && !live->terminal_done) - return (-1); + if (rc == 1 && !live->terminal_done) { + result = -1; + break; + } } c->wait = WAIT_NONE; - c->artifact_wait = NULL; - return (0); + if (c->artifact_wait == live) + c->artifact_wait = NULL; + return (result); } static int -artifact_settle(struct coord *c, struct artifact_live *live, +artifact_settle_impl(struct coord *c, struct artifact_live *live, const struct tool_result *result) { struct artifact_record record; - if (!live->stream_done || - !artifact_tool_result_valid(result, live)) + if (!live->stream_done) return (-1); + if (live->failed_discarded || live->job_failed) { + if (live->stream.status == ARTIFACT_STREAM_AVAILABLE && + !live->terminal_done) { + live->discard_when_terminal = 1; + if (artifact_wait_terminal(c, live) == -1) + return (-1); + } + artifact_storage_failure(c); + if (!live->removed) + artifact_live_remove(c, live); + return (0); + } + if (!artifact_tool_result_valid(result, live)) { + if (live->stream.status == ARTIFACT_STREAM_AVAILABLE) { + if (artifact_wait_terminal(c, live) == -1) { + live->discard_when_terminal = 1; + return (-1); + } + if (live->failed_discarded || live->job_failed) { + artifact_storage_failure(c); + if (!live->removed) + artifact_live_remove(c, live); + return (0); + } + artifact_discard(c, live); + if (live->background && live->adopted && live->job_id > 0 && + !live->drop_pending && + live->terminal.status != ARTIFACT_STORE_ERROR) + artifact_job_control(c, + FUGU_IMSG_TOOL_ARTIFACT_RELEASE, live); + } + artifact_live_remove(c, live); + return (-1); + } if (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE) { artifact_live_remove(c, live); return (0); } if (artifact_wait_terminal(c, live) == -1) return (-1); - if (live->terminal.status == ARTIFACT_STORE_ERROR) { + if (live->failed_discarded || live->job_failed) { artifact_storage_failure(c); + if (!live->removed) + artifact_live_remove(c, live); + return (0); + } + if (live->terminal.status == ARTIFACT_STORE_ERROR || + result->termination == ARTIFACT_TERM_STORAGE_ERROR) { + artifact_storage_failure(c); artifact_discard(c, live); } else { if (live->terminal.retained_bytes != result->retained_bytes || (live->terminal.status == ARTIFACT_STORE_CAP && - !result->artifact_cap_reached)) + !result->artifact_cap_reached)) { + artifact_discard(c, live); + if (live->background && live->adopted && live->job_id > 0 && + !live->drop_pending) + artifact_job_control(c, + FUGU_IMSG_TOOL_ARTIFACT_RELEASE, live); + artifact_live_remove(c, live); return (-1); + } if (result->keep_artifact) { if (live->background) { memset(&record, 0, sizeof(record)); @@ -2947,10 +3746,26 @@ artifact_settle(struct coord *c, struct artifact_live } else artifact_discard(c, live); } + if (live->background && live->adopted && live->job_id > 0 && + !live->drop_pending && + live->terminal.status != ARTIFACT_STORE_ERROR) + artifact_job_control(c, FUGU_IMSG_TOOL_ARTIFACT_RELEASE, live); artifact_live_remove(c, live); return (0); } +static int +artifact_settle(struct coord *c, struct artifact_live *live, + const struct tool_result *result) +{ + int rc; + + artifact_live_pin(live); + rc = artifact_settle_impl(c, live, result); + artifact_live_unpin(live); + return (rc); +} + static void artifact_storage_failure(struct coord *c) { @@ -3048,7 +3863,7 @@ run_output_read(struct coord *c, struct tool_call *tc) struct artifact_read_req req; enum artifact_page_encoding encoding; const char *status, *message; - int retry, rc; + int interrupted, retry, rc; buf_reset(&c->tool_out); buf_reset(&c->diff); @@ -3069,11 +3884,25 @@ run_output_read(struct coord *c, struct tool_call *tc) c->wait = WAIT_ARTIFACT_READ; rc = event_dispatch(); c->wait = WAIT_NONE; + if (rc == -1) + fatal("event_dispatch output_read"); + interrupted = c->cancelled || c->want_switch || c->want_quit || + !c->artifact_read_done; + /* Cancellation can become active in the same libevent pass as exact + * DATA/READ_RESULT frames. Keep their correlation live and synchronously + * consume the bounded response before Turn rollback pumps this channel. */ + while (!c->artifact_read_done) { + c->wait = WAIT_ARTIFACT_READ; + rc = event_dispatch(); + c->wait = WAIT_NONE; + if (rc == -1) + fatal("event_dispatch cancelled output_read"); + if (rc == 1 && !c->artifact_read_done) + fatalx("output_read lost its event source while draining"); + } c->artifact_read_id = 0; memset(&c->artifact_read_req, 0, sizeof(c->artifact_read_req)); - if (rc == -1) - fatal("event_dispatch output_read"); - if (!c->artifact_read_done) { + if (interrupted) { output_read_error(c, "cancelled", "output_read was cancelled"); return; } @@ -3120,23 +3949,30 @@ run_tool(struct coord *c, struct tool_call *tc) { struct imsgev *iev = &c->iev[ROLE_TOOL]; struct artifact_begin begin; - struct artifact_live *live = NULL, *result_live = NULL; - struct artifact_record record; + struct artifact_live *live = NULL, *call_live = NULL, + *result_live = NULL; struct tool_req req; uint32_t id = 0; - int artifact, shell_bg, shell_output, rc; + int artifact, rotation_validated = 0, shell_bg, shell_output, rc; - artifact = artifact_tool_name(tc->name); + artifact = artifact_capture_tool_name(tc->name); shell_bg = strcmp(tc->name, "shell_bg") == 0; shell_output = strcmp(tc->name, "shell_output") == 0; c->artifact_call_id = 0; + c->artifact_call_job_id = shell_output ? shell_output_job_id(tc) : 0; + c->artifact_call_shell_bg = shell_bg; + c->artifact_call_shell_output = shell_output; c->artifact_call = NULL; memset(&c->tool_result, 0, sizeof(c->tool_result)); if (artifact) { id = agent_unique_id(c); c->artifact_call_id = id; live = artifact_live_new(c, id, shell_bg || shell_output); + artifact_live_pin(live); + call_live = live; c->artifact_call = live; + if (shell_output && c->artifact_call_job_id > 0) + live->job_id = c->artifact_call_job_id; memset(&begin, 0, sizeof(begin)); begin.kind = shell_bg || shell_output ? ARTIFACT_BEGIN_SPOOL : ARTIFACT_BEGIN_FOREGROUND; @@ -3174,22 +4010,98 @@ run_tool(struct coord *c, struct tool_call *tc) } if (rc == -1) fatal("event_dispatch tool"); - if (!c->tool_done) + if (!c->tool_done) { + if (call_live != NULL) { + int job_id = call_live->job_id; + + artifact_live_unpin(call_live); + if (job_id > 0) + artifact_cancel_retire(c, job_id); + } return; + } if (!artifact) return; + if (!shell_output && c->tool_result.spool_frozen) { + c->turn_data_exceeded = 1; + goto done; + } + if (shell_output) { + if (c->tool_result.spool_frozen != live->rotation_done) { + c->turn_data_exceeded = 1; + goto done; + } + if (c->tool_result.spool_frozen) { + if (!artifact_result_matches_rotation(&c->tool_result, + live)) { + c->turn_data_exceeded = 1; + goto done; + } + rotation_validated = 1; + } else { + struct tool_result rejected; + const struct tool_result *settle_result = &c->tool_result; + int invalid; + invalid = c->tool_result.spool_adopted || + c->tool_result.spool_detach || + !c->tool_result.artifact || + (c->tool_result.background_job_id != 0 && + c->tool_result.background_job_id != + c->artifact_call_job_id) || + !artifact_result_names_call_stream(&c->tool_result, + live); + if (invalid) { + rejected = c->tool_result; + rejected.artifact = 0; + settle_result = &rejected; + } + if (artifact_settle(c, live, settle_result) == -1 || invalid) + c->turn_data_exceeded = 1; + live = NULL; + goto done; + } + } + if (live->job_failed) { + struct artifact_live *failed, *next; + int job_id = live->job_id; + + if (artifact_settle(c, live, &c->tool_result) == -1) { + c->turn_data_exceeded = 1; + goto done; + } + live = NULL; + LIST_FOREACH_SAFE(failed, &c->artifacts, entry, next) { + if (failed->job_id != job_id || !failed->job_failed) + continue; + if (!failed->terminal_done) { + failed->discard_when_terminal = 1; + continue; + } + if (!failed->failed_discarded) + artifact_discard(c, failed); + artifact_live_remove(c, failed); + } + goto done; + } if (!shell_bg && !shell_output) { - if (c->tool_result.spool_adopted || + if (c->tool_result.spool_frozen || + c->tool_result.spool_adopted || + c->tool_result.background_job_id != 0 || artifact_settle(c, live, &c->tool_result) == -1) c->turn_data_exceeded = 1; goto done; } if (shell_bg) { + if (c->tool_result.spool_frozen) { + c->turn_data_exceeded = 1; + goto done; + } if (c->tool_result.spool_adopted) { if (c->tool_result.artifact || c->tool_result.spool_detach || c->tool_result.is_error || + c->tool_result.background_job_id <= 0 || (live->stream.status == ARTIFACT_STREAM_AVAILABLE && (!artifact_id_wire_valid(c->tool_result.artifact_id) || strcmp(c->tool_result.artifact_id, @@ -3199,10 +4111,18 @@ run_tool(struct coord *c, struct tool_call *tc) c->turn_data_exceeded = 1; goto done; } + live->job_id = c->tool_result.background_job_id; live->adopted = 1; - if (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE) - artifact_live_remove(c, live); - } else if (artifact_settle(c, live, &c->tool_result) == -1) + if (live->terminal_done && + live->terminal.status == ARTIFACT_STORE_ERROR) { + artifact_background_failure(c, live); + artifact_storage_failure(c); + if (!live->removed) + artifact_live_remove(c, live); + live = NULL; + } + } else if (c->tool_result.background_job_id != 0 || + artifact_settle(c, live, &c->tool_result) == -1) c->turn_data_exceeded = 1; goto done; } @@ -3213,6 +4133,8 @@ run_tool(struct coord *c, struct tool_call *tc) c->turn_data_exceeded = 1; goto done; } + if (c->tool_result.background_job_id > 0) + live->job_id = c->tool_result.background_job_id; if (artifact_id_wire_valid(c->tool_result.artifact_id)) result_live = artifact_live_by_id(c, c->tool_result.artifact_id); @@ -3220,35 +4142,72 @@ run_tool(struct coord *c, struct tool_call *tc) c->turn_data_exceeded = 1; goto done; } - if (c->tool_result.spool_adopted) { - live->adopted = 1; - if (c->tool_result.spool_detach) { - live->turn_owned = 0; - if (live->stream.status == ARTIFACT_STREAM_AVAILABLE) { - memset(&record, 0, sizeof(record)); - strlcpy(record.id, live->stream.id, - sizeof(record.id)); - imsgev_send(&c->iev[ROLE_ARTIFACT], - FUGU_IMSG_ARTIFACT_DETACH, live->correlation, - &record, sizeof(record)); - } - } - } else if (c->tool_result.spool_detach) { - c->turn_data_exceeded = 1; - goto done; - } + if ((c->tool_result.spool_adopted && + c->tool_result.background_job_id <= 0) || + (result_live != NULL && result_live != live && + (c->tool_result.background_job_id <= 0 || + result_live->job_id != c->tool_result.background_job_id)) || + (c->tool_result.background_job_id == 0 && result_live != live)) { + int result_is_live = result_live == live; + if (result_live != NULL) + (void)artifact_settle(c, result_live, &c->tool_result); + if (result_is_live) + live = NULL; + c->turn_data_exceeded = 1; + goto done; + } if (result_live != NULL) { + int result_is_live = result_live == live; + int result_failed = result_live->failed_discarded || + result_live->job_failed; + if (artifact_settle(c, result_live, &c->tool_result) == -1) c->turn_data_exceeded = 1; - if (result_live == live) + if (result_is_live) live = NULL; - } else if (!artifact_inline_result_valid(&c->tool_result)) - c->turn_data_exceeded = 1; + else if (result_failed && live != NULL) { + if (live->terminal_done) { + if (!live->failed_discarded) + artifact_discard(c, live); + if (!live->removed) + artifact_live_remove(c, live); + live = NULL; + } else + live->discard_when_terminal = 1; + } + } else { + struct artifact_live *unavailable; + if (rotation_validated) + unavailable = artifact_rotation_frozen(c, live); + else if (live != NULL && live->stream_done && + live->stream.status == ARTIFACT_STREAM_UNAVAILABLE) + unavailable = live; + else + unavailable = NULL; + if (!artifact_inline_result_valid(&c->tool_result) || + unavailable == NULL || unavailable->stream.status != + ARTIFACT_STREAM_UNAVAILABLE) + c->turn_data_exceeded = 1; + if (unavailable != NULL) { + int result_is_live = unavailable == live; + + artifact_live_remove(c, unavailable); + if (result_is_live) + live = NULL; + } + } + if (live != NULL && c->tool_result.spool_adopted) { - if (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE) - artifact_live_remove(c, live); + if (live->terminal_done && + live->terminal.status == ARTIFACT_STORE_ERROR) { + artifact_background_failure(c, live); + artifact_storage_failure(c); + if (!live->removed) + artifact_live_remove(c, live); + live = NULL; + } } else if (live != NULL) { if (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE) artifact_live_remove(c, live); @@ -3260,8 +4219,25 @@ run_tool(struct coord *c, struct tool_call *tc) } } done: + if (call_live != NULL && call_live->rotation_done) { + struct artifact_live *frozen; + + frozen = artifact_rotation_frozen(c, call_live); + if (frozen != NULL && !frozen->cancel_retire) + artifact_cancelled_rotation(c, call_live); + } c->artifact_call = NULL; c->artifact_call_id = 0; + c->artifact_call_job_id = 0; + c->artifact_call_shell_bg = 0; + c->artifact_call_shell_output = 0; + if (call_live != NULL) { + int job_id = call_live->job_id; + + artifact_live_unpin(call_live); + if (job_id > 0) + artifact_cancel_retire(c, job_id); + } } /* Send one brokered call to fugu-web and pump events until its result. */ @@ -5933,6 +6909,7 @@ coord_run(struct fugu_conf *cf, const char *prompt, si int role, rc; memset(&c, 0, sizeof(c)); + LIST_INIT(&c.artifacts); c.conf = cf; c.debug = debug; c.verbose = verbose; @@ -5981,6 +6958,7 @@ coord_run(struct fugu_conf *cf, const char *prompt, si event_init(); /* The Artifact Role owns the lifetime Session lock. Resolve the exact * Session id and managed root before any replay/open can mutate a Journal. */ + artifact_root_prepare(&c); if (journal_dir(sessdir, sizeof(sessdir)) == -1) fatalx("cannot use ~/.fugu/sessions"); if (resume_id != NULL) { @@ -5993,14 +6971,6 @@ coord_run(struct fugu_conf *cf, const char *prompt, si strlcpy(c.artifact_session, id, sizeof(c.artifact_session)); c.artifact_fresh = 1; } - { - const char *home = getenv("HOME"); - - if (home == NULL || home[0] != '/' || - (size_t)snprintf(c.artifact_root, sizeof(c.artifact_root), - "%s/.fugu/artifacts", home) >= sizeof(c.artifact_root)) - fatalx("cannot use ~/.fugu/artifacts"); - } protect_snapshot_build(&c); if ((c.devnull_fd = priv_open_devnull()) == -1) fatal("open /dev/null"); blob - /dev/null blob + 6f94c12c31eada82a6c98a61924ee5123dc934e7 (mode 644) --- /dev/null +++ src/fugu/artifact_scope.c @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Isaac + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + */ + +#include + +#include "artifact_scope.h" + +int +artifact_capture_tool_name(const char *name) +{ + return (strcmp(name, "shell") == 0 || strcmp(name, "grep") == 0 || + strcmp(name, "find") == 0 || strcmp(name, "ls") == 0 || + strcmp(name, "shell_bg") == 0 || + strcmp(name, "shell_output") == 0); +} blob - /dev/null blob + f0c42eb818f25d44cc67f935b5e181aa3e802ef5 (mode 644) --- /dev/null +++ src/fugu/artifact_scope.h @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2026 Isaac + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + */ + +#ifndef ARTIFACT_SCOPE_H +#define ARTIFACT_SCOPE_H + +/* Lead-local producers whose raw output enters managed Artifact storage. */ +int artifact_capture_tool_name(const char *); + +#endif /* ARTIFACT_SCOPE_H */ blob - af93fcde5229fecbd3eed0c8df674054cfb875b1 blob + bfc4f08bda7c3ccfcec4f400e53c9daa68b7f6e7 --- src/fugu/fugu.1 +++ src/fugu/fugu.1 @@ -691,6 +691,26 @@ A failed, cancelled, or abandoned turn contributes no state; a turn stopped by the 50-iteration cap is complete and remains resumable. Queued but undelivered messages are never journaled. +For Lead-local shell and search tools, raw output that does not fit the bounded +Tool result may be retained under the Owner-only managed root +.Pa ~/.fugu/artifacts/ . +The model receives only an opaque Session-local identifier; no storage path is +written to the Journal or exposed through a Tool result. +Committed retained output survives resume, +.Cm /clear , +and Compaction until quota cleanup evicts it. +Retained output can contain credentials or other secrets printed by a command; +Owners should treat the entire managed root as sensitive data. +At startup, +.Nm +may create or validate the owner-only +.Pa ~/.fugu +directory before resolving this root. +If its physical path overlaps the project tree or another persistent Lead Tool +filesystem authority, +.Nm +refuses the invocation before spawning workers, opening or changing a Journal, +or contacting a Provider. . .Sh ENVIRONMENT .Bl -tag -width FUGU_CONF @@ -724,6 +744,9 @@ Commented configuration installed by the package. Personal project-independent context. .It Pa ~/.fugu/sessions/ Per-session journals. +.It Pa ~/.fugu/artifacts/ +Owner-only managed storage for retained Tool output; contents may include +secrets printed by commands. .It Pa ~/.fugu/skills/name/SKILL.md User-installed skill. .It Pa FUGU.md blob - 175e290d0c0abc4258fa50bbff49452e7a488f0f blob + f95164804fde4d69524c372dd3204fca381c4637 --- src/fugu/proto.h +++ src/fugu/proto.h @@ -200,10 +200,18 @@ enum fugu_imsg { FUGU_IMSG_TOOL_OUT, /* one result chunk (zero or more) */ FUGU_IMSG_TOOL_DIFF, /* presentation-only diff chunk (zero or more) */ FUGU_IMSG_TOOL_RESULT, /* struct tool_result: is_error; terminal */ - /* A typed pipe write end accompanies TOOL_ARTIFACT_STREAM. It is - * accepted only by the Lead executor, never by fugu-tool -r. */ + /* A successful shell_output rotation is durable control state, not part of + * the discardable result stream. It precedes TOOL_OUT/TOOL_RESULT. */ + FUGU_IMSG_TOOL_ARTIFACT_ROTATE, /* struct artifact_spool_rotate */ + FUGU_IMSG_TOOL_ARTIFACT_DROP, /* struct artifact_spool_drop */ + FUGU_IMSG_TOOL_ARTIFACT_DROP_ACK, /* struct artifact_spool_drop */ + /* A typed producer endpoint accompanies TOOL_ARTIFACT_STREAM. OpenBSD + * reports both pipe endpoints as O_RDWR; protocol role, not access flags, + * makes this the write side. Only the Lead executor accepts it. */ FUGU_IMSG_TOOL_ARTIFACT_STREAM, /* struct artifact_stream + write fd */ - FUGU_IMSG_TOOL_ARTIFACT_CANCEL, /* exact current spool id failed */ + FUGU_IMSG_TOOL_ARTIFACT_CANCEL, /* exact job + current/historical id */ + FUGU_IMSG_TOOL_ARTIFACT_CANCEL_ACK, /* struct artifact_job_cancel_ack */ + FUGU_IMSG_TOOL_ARTIFACT_RELEASE, /* old spool reached settlement */ /* Private Artifact storage. Control is Coordinator-brokered; only the * raw-byte pipe created by BEGIN forms the narrow direct data plane. */ @@ -490,14 +498,46 @@ struct tool_result { int artifact; int keep_artifact; int artifact_cap_reached; + int spool_frozen; int spool_adopted; int spool_detach; + int background_job_id; int termination; /* enum artifact_termination */ uint64_t observed_bytes; uint64_t retained_bytes; char artifact_id[FUGU_ARTIFACT_ID_BUFSZ]; }; +/* Tool -> Coordinator publication of an immutable background spool freeze. + * replacement_adopted says whether it was also a rotation. The imsg header + * id is the replacement stream's Tool-call correlation; even when no + * replacement was adopted it identifies the unused Turn-owned stream. A + * zero id array denotes the corresponding unavailable reservation. */ +struct artifact_spool_rotate { + uint32_t job_id; + uint32_t replacement_adopted; + uint32_t detach; + char frozen_id[FUGU_ARTIFACT_ID_BUFSZ]; + char replacement_id[FUGU_ARTIFACT_ID_BUFSZ]; +}; + +/* Tool -> Coordinator retirement of one completed, unpolled background + * spool, and the Coordinator's exact-key acknowledgement. The imsg header + * id is the Tool-call correlation that originally supplied this spool. An + * exactly all-zero artifact_id denotes an unavailable reservation. */ +struct artifact_spool_drop { + uint32_t job_id; + char artifact_id[FUGU_ARTIFACT_ID_BUFSZ]; +}; + +/* Tool -> Coordinator completion of an exact job-wide cancellation. The + * imsg header id also equals job_id. A preceding DROP on this ordered socket + * was already published; an unqueued DROP for this job was removed instead. */ +struct artifact_job_cancel_ack { + uint32_t job_id; + char artifact_id[FUGU_ARTIFACT_ID_BUFSZ]; +}; + enum artifact_stream_status { ARTIFACT_STREAM_AVAILABLE = 0, ARTIFACT_STREAM_UNAVAILABLE blob - d486d07c930a48197d59feb62b0d2d5c0079ef5f blob + 99c942ff5464de8407bbe7eb2c5268172ce75424 --- src/fugu-artifact/main.c +++ src/fugu-artifact/main.c @@ -7,6 +7,7 @@ */ #include +#include #include #include @@ -14,6 +15,7 @@ #include #include #include +#include #include #include "buf.h" @@ -22,15 +24,19 @@ #include "proto.h" #include "store.h" #include "worker.h" +#include "xmalloc.h" +struct drain_event; +LIST_HEAD(drain_list, drain_event); + struct artifact_state { struct worker *worker; struct artifact_store *store; - struct drain_event *drains; + struct drain_list drains; }; struct drain_event { - struct drain_event *next; + LIST_ENTRY(drain_event) entry; struct event ev; struct artifact_state *state; struct artifact_pending *pending; @@ -40,15 +46,54 @@ struct drain_event { static struct artifact_state *cleanup_state; static void +artifact_store_error_test(struct artifact_terminal *terminal) +{ + static int injected; + const char *value, *errstr; + struct timespec delay; + long long ms; + + if (injected || issetugid() || getenv("FUGU_SELFTEST") == NULL || + (value = getenv("FUGU_ARTIFACT_STORE_ERROR_DELAY_MS")) == NULL) + return; + ms = strtonum(value, 1, 5000, &errstr); + if (errstr != NULL) + return; + injected = 1; + delay.tv_sec = (time_t)(ms / 1000); + delay.tv_nsec = (long)(ms % 1000) * 1000000L; + (void)nanosleep(&delay, NULL); + log_warnx("fugu-artifact: selftest STORE_ERROR injected"); + terminal->status = ARTIFACT_STORE_ERROR; +} + +static void +artifact_read_test_delay(void) +{ + const char *value, *errstr; + struct timespec delay; + long long ms; + + if (issetugid() || + (value = getenv("FUGU_ARTIFACT_READ_DELAY_MS")) == NULL) + return; + ms = strtonum(value, 1, 5000, &errstr); + if (errstr != NULL) + return; + delay.tv_sec = (time_t)(ms / 1000); + delay.tv_nsec = (long)(ms % 1000) * 1000000L; + (void)nanosleep(&delay, NULL); +} + +static void drain_unlink(struct drain_event *de) { - struct drain_event **link; + struct drain_event *item; - for (link = &de->state->drains; *link != NULL; - link = &(*link)->next) { - if (*link != de) + LIST_FOREACH(item, &de->state->drains, entry) { + if (item != de) continue; - *link = de->next; + LIST_REMOVE(de, entry); return; } fatalx("fugu-artifact: lost drain event"); @@ -57,15 +102,14 @@ drain_unlink(struct drain_event *de) static void drain_cancel_owned(struct artifact_state *state) { - struct drain_event **link, *de; + struct drain_event *de, *next; - for (link = &state->drains; (de = *link) != NULL;) { - if (!artifact_pending_turn_owned(de->pending)) { - link = &de->next; + for (de = LIST_FIRST(&state->drains); de != NULL; de = next) { + next = LIST_NEXT(de, entry); + if (!artifact_pending_turn_owned(de->pending)) continue; - } (void)event_del(&de->ev); - *link = de->next; + LIST_REMOVE(de, entry); free(de); } } @@ -89,6 +133,7 @@ drain_stream(int fd, short events, void *arg) rc = artifact_pending_drain(de->pending, &terminal); if (rc == 0) return; + artifact_store_error_test(&terminal); event_del(&de->ev); drain_unlink(de); imsgev_send(&de->state->worker->iev, FUGU_IMSG_ARTIFACT_TERMINAL, @@ -209,14 +254,11 @@ artifact_dispatch(struct worker *w, struct imsg *imsg) &stream, sizeof(stream)); return (0); } - de = calloc(1, sizeof(*de)); - if (de == NULL) - fatal("calloc Artifact drain"); + de = xcalloc(1, sizeof(*de)); de->state = state; de->pending = pending; de->correlation = id; - de->next = state->drains; - state->drains = de; + LIST_INSERT_HEAD(&state->drains, de, entry); event_set(&de->ev, artifact_pending_fd(pending), EV_READ | EV_PERSIST, drain_stream, de); if (event_add(&de->ev, NULL) == -1) @@ -256,6 +298,7 @@ artifact_dispatch(struct worker *w, struct imsg *imsg) if (id == 0 || p == NULL || len != sizeof(struct artifact_read_req)) fatalx("fugu-artifact: bad read"); + artifact_read_test_delay(); buf_init(&out); if (artifact_store_read(state->store, (const struct artifact_read_req *)p, &out, &result) == -1) @@ -285,6 +328,7 @@ main(int argc, char *argv[]) -1) fatal("fugu-artifact: pledge startup"); state.worker = &worker; + LIST_INIT(&state.drains); worker.title = "fugu-artifact"; worker.ctx = &state; worker.lockdown = artifact_lockdown; blob - 640de1293d9cf4220161d9bcce1632da9279784c blob + bf6bb4d01895443dae6e7d077bd2f10781b3725c --- src/fugu-artifact/store.c +++ src/fugu-artifact/store.c @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -27,13 +28,13 @@ #include "xmalloc.h" struct known_artifact { - struct known_artifact *next; + LIST_ENTRY(known_artifact) entry; uint64_t retained; char id[FUGU_ARTIFACT_ID_BUFSZ]; }; struct artifact_pending { - struct artifact_pending *next; + LIST_ENTRY(artifact_pending) entry; struct artifact_store *store; uint32_t correlation; int file_fd; @@ -41,6 +42,7 @@ struct artifact_pending { uint64_t retained; int terminal_done; int terminal_status; + int final_linked; int prepared; int background; int turn_owned; @@ -48,6 +50,14 @@ struct artifact_pending { char staging[FUGU_ARTIFACT_ID_BUFSZ + 16]; }; +LIST_HEAD(known_list, known_artifact); +LIST_HEAD(pending_list, artifact_pending); + +enum artifact_store_phase { + ARTIFACT_STORE_RECONCILING = 0, + ARTIFACT_STORE_READY +}; + struct artifact_store { char root[FUGU_ARTIFACT_ROOT_MAX]; char session[FUGU_ARTIFACT_SESSION_MAX]; @@ -56,9 +66,11 @@ struct artifact_store { int root_fd; int session_lock_fd; int global_lock_fd; + enum artifact_store_phase phase; int turn_open; - struct known_artifact *known; - struct artifact_pending *pending; + int turn_prepared; + struct known_list known; + struct pending_list pending; }; struct eviction { @@ -126,23 +138,61 @@ mkdir_private(const char *path) if (mkdir(path, 0700) == -1 && errno != EEXIST) return (-1); if (lstat(path, &st) == -1 || !S_ISDIR(st.st_mode) || - (st.st_mode & 0777) != 0700) + st.st_uid != geteuid() || (st.st_mode & 0777) != 0700) return (-1); return (0); } static int +lock_fd_normalize(int fd) +{ + int fdflags, flags; + + if ((flags = fcntl(fd, F_GETFL)) == -1 || + (flags & O_ACCMODE) != O_RDWR || (flags & O_APPEND) != 0) + return (-1); + if ((flags & O_NONBLOCK) != 0 && + fcntl(fd, F_SETFL, flags & ~O_NONBLOCK) == -1) + return (-1); + if ((flags = fcntl(fd, F_GETFL)) == -1 || + (flags & O_ACCMODE) != O_RDWR || + (flags & (O_APPEND | O_NONBLOCK)) != 0 || + (fdflags = fcntl(fd, F_GETFD)) == -1 || + (fdflags & FD_CLOEXEC) == 0) + return (-1); + return (0); +} + +static int open_private(const char *path, int flags) { - int fd; + struct stat st; + int created = 0, fd; - if ((fd = open(path, flags | O_CLOEXEC | O_NOFOLLOW, 0600)) == -1) - return (-1); - if (fchmod(fd, 0600) == -1) { - close(fd); - return (-1); + fd = open(path, (flags & ~(O_CREAT | O_EXCL)) | O_NONBLOCK | + O_CLOEXEC | O_NOFOLLOW); + if (fd == -1 && errno == ENOENT && (flags & O_CREAT)) { + fd = open(path, flags | O_EXCL | O_NONBLOCK | O_CLOEXEC | + O_NOFOLLOW, 0600); + if (fd != -1) + created = 1; } + if (fd == -1) + return (-1); + if (fstat(fd, &st) == -1 || !S_ISREG(st.st_mode) || + st.st_uid != geteuid() || st.st_nlink != 1 || st.st_size != 0 || + (!created && (st.st_mode & 0777) != 0600)) + goto fail; + if (created && fchmod(fd, 0600) == -1) + goto fail; + if (fstat(fd, &st) == -1 || !S_ISREG(st.st_mode) || + st.st_uid != geteuid() || st.st_nlink != 1 || st.st_size != 0 || + (st.st_mode & 0777) != 0600 || lock_fd_normalize(fd) == -1) + goto fail; return (fd); +fail: + close(fd); + return (-1); } static int @@ -171,7 +221,7 @@ known_find(struct artifact_store *s, const char *id) { struct known_artifact *k; - for (k = s->known; k != NULL; k = k->next) + LIST_FOREACH(k, &s->known, entry) if (strcmp(k->id, id) == 0) return (k); return (NULL); @@ -187,10 +237,9 @@ known_add(struct artifact_store *s, const char *id, ui if ((k = known_find(s, id)) != NULL) return (k->retained == retained ? 0 : -1); k = xcalloc(1, sizeof(*k)); - strlcpy(k->id, id, sizeof(k->id)); + memcpy(k->id, id, sizeof(k->id)); k->retained = retained; - k->next = s->known; - s->known = k; + LIST_INSERT_HEAD(&s->known, k, entry); return (0); } @@ -217,8 +266,10 @@ artifact_store_open(const char *root, const char *sess s->root_fd = -1; s->session_lock_fd = -1; s->global_lock_fd = -1; - strlcpy(s->root, root, sizeof(s->root)); - strlcpy(s->session, session, sizeof(s->session)); + LIST_INIT(&s->known); + LIST_INIT(&s->pending); + memcpy(s->root, root, n + 1); + memcpy(s->session, session, strlen(session) + 1); /* The parent ~/.fugu exists before worker startup; create only the * dedicated managed root and this Session below it. */ if (strlcpy(fugu, root, sizeof(fugu)) >= sizeof(fugu) || @@ -240,10 +291,12 @@ artifact_store_open(const char *root, const char *sess goto fail; if ((s->root_fd = open(root, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)) == -1 || + fstat(s->root_fd, &st) == -1 || !S_ISDIR(st.st_mode) || + st.st_uid != geteuid() || (st.st_mode & 0777) != 0700 || (s->session_fd = open(s->session_path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)) == -1 || fstat(s->session_fd, &st) == -1 || !S_ISDIR(st.st_mode) || - (st.st_mode & 0777) != 0700 || + st.st_uid != geteuid() || (st.st_mode & 0777) != 0700 || (size_t)snprintf(lock, sizeof(lock), "%s/.quota.lock", root) >= sizeof(lock) || (s->global_lock_fd = open_private(lock, O_RDWR | O_CREAT)) == -1) @@ -257,28 +310,37 @@ fail: void artifact_store_close(struct artifact_store *s) { - struct known_artifact *k, *kn; - struct artifact_pending *p, *pn; + struct known_artifact *k; + struct artifact_pending *p; + int dirty = 0, preserve; if (s == NULL) return; - for (p = s->pending; p != NULL; p = pn) { - pn = p->next; + while ((p = LIST_FIRST(&s->pending)) != NULL) { + LIST_REMOVE(p, entry); if (p->read_fd != -1) close(p->read_fd); if (p->file_fd != -1) close(p->file_fd); - /* A prepared final may already be named by a synchronized Journal - * terminal. Only replay can decide whether it is committed or an - * orphan; clean shutdown removes staging/live spool files only. */ - if (!p->prepared) - (void)unlinkat(s->session_fd, p->staging, 0); - if (!p->prepared) + /* Only a completely prepared Turn may already be named by a + * synchronized Journal terminal. A partial prepare can have both + * names linked and is wholly rolled back on shutdown. */ + preserve = s->turn_prepared && p->prepared; + if (!preserve) { + if (unlinkat(s->session_fd, p->staging, 0) == 0) + dirty = 1; + if (p->final_linked && + unlinkat(s->session_fd, p->id, 0) == 0) + dirty = 1; (void)order_unlink(s, p->id); + dirty = 1; + } free(p); } - for (k = s->known; k != NULL; k = kn) { - kn = k->next; + if (dirty) + (void)fsync(s->session_fd); + while ((k = LIST_FIRST(&s->known)) != NULL) { + LIST_REMOVE(k, entry); free(k); } if (s->session_fd != -1) @@ -302,7 +364,9 @@ int artifact_store_reconcile_add(struct artifact_store *s, const struct artifact_record *record) { - if (s == NULL || record == NULL || s->turn_open) + if (s == NULL || record == NULL || + s->phase != ARTIFACT_STORE_RECONCILING || s->turn_open || + !LIST_EMPTY(&s->pending)) return (-1); return (known_add(s, record->id, record->retained_bytes)); } @@ -313,35 +377,69 @@ final_name(const char *name) return (artifact_id_valid(name)); } +static int +pending_name(const struct dirent *de) +{ + return (strncmp(de->d_name, ".pending-", 9) == 0); +} + +static int +artifact_name(const struct dirent *de) +{ + return (final_name(de->d_name) || pending_name(de)); +} + +static int +reconcile_name(const struct dirent *de) +{ + return (artifact_name(de) || + (strncmp(de->d_name, ORDER_PREFIX, ORDER_PREFIX_LEN) == 0 && + final_name(de->d_name + ORDER_PREFIX_LEN))); +} + +static int +session_name(const struct dirent *de) +{ + return (de->d_name[0] != '.'); +} + +static void +free_dirents(struct dirent **names, int count) +{ + while (count > 0) + free(names[--count]); + free(names); +} + int artifact_store_reconcile_finish(struct artifact_store *s) { - struct dirent *de; - DIR *dir; - int dfd = -1; + struct dirent **names = NULL; + const char *name, *id; + int count, i; - if (s == NULL || (dfd = dup(s->session_fd)) == -1 || - (dir = fdopendir(dfd)) == NULL) { - if (dfd != -1) - close(dfd); + if (s == NULL || s->phase != ARTIFACT_STORE_RECONCILING || + s->turn_open || !LIST_EMPTY(&s->pending) || + (count = scandirat(s->session_fd, ".", &names, reconcile_name, + NULL)) == -1) return (-1); - } - while ((de = readdir(dir)) != NULL) { - if (strncmp(de->d_name, ".pending-", 9) == 0 || - (final_name(de->d_name) && - known_find(s, de->d_name) == NULL) || - (strncmp(de->d_name, ORDER_PREFIX, ORDER_PREFIX_LEN) == 0 && - final_name(de->d_name + ORDER_PREFIX_LEN) && - known_find(s, de->d_name + ORDER_PREFIX_LEN) == NULL)) { - if (unlinkat(s->session_fd, de->d_name, 0) == -1 && - errno != ENOENT) { - closedir(dir); - return (-1); - } + for (i = 0; i < count; i++) { + name = names[i]->d_name; + id = strncmp(name, ORDER_PREFIX, ORDER_PREFIX_LEN) == 0 ? + name + ORDER_PREFIX_LEN : name; + if (!pending_name(names[i]) && known_find(s, id) != NULL) + continue; + if (unlinkat(s->session_fd, name, 0) == -1 && errno != ENOENT) { + free_dirents(names, count); + return (-1); } } - closedir(dir); - return (fsync(s->session_fd)); + free_dirents(names, count); + if (fault_hit(ARTIFACT_STORE_FAULT_RECONCILE_FSYNC) || + fsync(s->session_fd) == -1) + return (-1); + s->phase = ARTIFACT_STORE_READY; + return (0); } static int @@ -370,15 +468,20 @@ read_order(int dirfd, const char *id, uint64_t *sequen if (order_name(id, name, sizeof(name)) == -1) return (-1); - fd = openat(dirfd, name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + fd = openat(dirfd, name, + O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW); if (fd == -1) return (errno == ENOENT ? 1 : -1); + if (fstat(fd, &st) == -1 || !S_ISREG(st.st_mode) || + st.st_uid != geteuid() || (st.st_mode & 0777) != 0600 || + st.st_nlink != 1 || st.st_size != (off_t)sizeof(*sequence)) { + close(fd); + return (-1); + } do { n = read(fd, sequence, sizeof(*sequence)); } while (n == -1 && errno == EINTR); - if (fstat(fd, &st) == -1 || !S_ISREG(st.st_mode) || - st.st_size != (off_t)sizeof(*sequence) || - n != (ssize_t)sizeof(*sequence) || *sequence == 0) { + if (n != (ssize_t)sizeof(*sequence) || *sequence == 0) { close(fd); return (-1); } @@ -393,97 +496,120 @@ scan_session(struct artifact_store *s, const char *ses { char path[PATH_MAX], full[PATH_MAX]; struct stat st; - struct dirent *de; - DIR *dir; - int dfd, dirty = 0; + struct dirent **names = NULL; + const char *name; + int count, dfd, i, rc = -1; if ((size_t)snprintf(path, sizeof(path), "%s/%s", s->root, session) >= sizeof(path)) return (-1); - if ((dfd = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | - O_NOFOLLOW)) == -1) + if (current) + dfd = dup(s->session_fd); + else + dfd = openat(s->root_fd, session, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (dfd == -1) return (errno == ENOENT ? 0 : -1); - if ((dir = fdopendir(dfd)) == NULL) { + if (fstat(dfd, &st) == -1 || !S_ISDIR(st.st_mode) || + st.st_uid != geteuid() || (st.st_mode & 0777) != 0700) { close(dfd); return (-1); } - while ((de = readdir(dir)) != NULL) { - if (!final_name(de->d_name) && - strncmp(de->d_name, ".pending-", 9) != 0) - continue; - /* Once its lifetime lock is held, another Session cannot have a - * live producer. Staging entries there are crash orphans, not quota - * reservations; remove and synchronize them before accounting. */ - if (!current && evictable && - strncmp(de->d_name, ".pending-", 9) == 0) { + /* A lifetime-lock-proven inactive Session has no live producer. Remove + * all crash staging names as one durable pass before validating or + * counting finals, because a crash after linkat can leave the two names + * temporarily sharing an otherwise valid inode. */ + if (!current && evictable) { + count = scandirat(dfd, ".", &names, pending_name, NULL); + if (count == -1) + goto done; + for (i = 0; i < count; i++) { char order[FUGU_ARTIFACT_ID_BUFSZ + ORDER_PREFIX_LEN]; + const char *id; + int have_final = 0; - if (unlinkat(dirfd(dir), de->d_name, 0) == -1 && - errno != ENOENT) { - closedir(dir); - return (-1); + name = names[i]->d_name; + id = name + 9; + if (final_name(id)) { + if (fstatat(dfd, id, &st, + AT_SYMLINK_NOFOLLOW) == 0) + have_final = 1; + else if (errno != ENOENT) + goto done; } - if (final_name(de->d_name + 9) && - order_name(de->d_name + 9, order, sizeof(order)) == 0 && - unlinkat(dirfd(dir), order, 0) == -1 && errno != ENOENT) { - closedir(dir); - return (-1); - } - dirty = 1; - continue; + if (unlinkat(dfd, name, 0) == -1 && errno != ENOENT) + goto done; + /* The order record belongs to an already-linked final when one + * exists; pending-only crash state owns its order record. */ + if (!have_final && final_name(id) && + order_name(id, order, sizeof(order)) == 0 && + unlinkat(dfd, order, 0) == -1 && errno != ENOENT) + goto done; } - if ((size_t)snprintf(full, sizeof(full), "%s/%s", path, - de->d_name) >= sizeof(full) || lstat(full, &st) == -1 || - !S_ISREG(st.st_mode) || st.st_size < 0) { - closedir(dir); - return (-1); - } - if ((uint64_t)st.st_size > UINT64_MAX - *global_total) { - closedir(dir); - return (-1); - } + free_dirents(names, count); + names = NULL; + if (fault_hit(ARTIFACT_STORE_FAULT_INACTIVE_CLEANUP_FSYNC) || + fsync(dfd) == -1) + goto done; + } + count = scandirat(dfd, ".", &names, artifact_name, NULL); + if (count == -1) + goto done; + for (i = 0; i < count; i++) { + int is_final; + + name = names[i]->d_name; + is_final = final_name(name); + if (fstatat(dfd, name, &st, + AT_SYMLINK_NOFOLLOW) == -1 || !S_ISREG(st.st_mode) || + st.st_uid != geteuid() || (st.st_mode & 0777) != 0600 || + st.st_nlink != 1 || st.st_size < 0 || + (uint64_t)st.st_size > ARTIFACT_RAW_MAX) + goto done; + if ((uint64_t)st.st_size > UINT64_MAX - *global_total) + goto done; *global_total += (uint64_t)st.st_size; - if (current) + if (current) { + if ((uint64_t)st.st_size > UINT64_MAX - *session_total) + goto done; *session_total += (uint64_t)st.st_size; - if (!evictable || !final_name(de->d_name)) + } + if (!evictable || !is_final) continue; + if ((size_t)snprintf(full, sizeof(full), "%s/%s", path, + name) >= sizeof(full)) + goto done; if (*nlist == *cap) { *cap = *cap == 0 ? 16 : *cap * 2; *list = xreallocarray(*list, *cap, sizeof(**list)); } memset(&(*list)[*nlist], 0, sizeof(**list)); - strlcpy((*list)[*nlist].path, full, - sizeof((*list)[*nlist].path)); + memcpy((*list)[*nlist].path, full, strlen(full) + 1); (*list)[*nlist].size = st.st_size; (*list)[*nlist].mtime = st.st_mtim; { - int rc = read_order(dirfd(dir), de->d_name, + int order_rc = read_order(dfd, name, &(*list)[*nlist].sequence); - if (rc == -1) { - closedir(dir); - return (-1); - } - (*list)[*nlist].have_sequence = rc == 0; + if (order_rc == -1) + goto done; + (*list)[*nlist].have_sequence = order_rc == 0; } if ((size_t)snprintf((*list)[*nlist].order_path, sizeof((*list)[*nlist].order_path), "%s/%s%s", path, - ORDER_PREFIX, de->d_name) >= - sizeof((*list)[*nlist].order_path)) { - closedir(dir); - return (-1); - } - strlcpy((*list)[*nlist].dir_path, path, - sizeof((*list)[*nlist].dir_path)); + ORDER_PREFIX, name) >= + sizeof((*list)[*nlist].order_path)) + goto done; + memcpy((*list)[*nlist].dir_path, path, strlen(path) + 1); (*list)[*nlist].current = current; (*nlist)++; } - if (dirty && fsync(dirfd(dir)) == -1) { - closedir(dir); - return (-1); - } - closedir(dir); - return (0); + rc = 0; +done: + if (names != NULL) + free_dirents(names, count); + close(dfd); + return (rc); } static int @@ -539,18 +665,29 @@ eviction_sync(struct eviction *list, size_t nlist) static int other_session_lock(struct artifact_store *s, const char *session) { - char path[PATH_MAX]; + struct stat st; int dfd, fd; - if ((size_t)snprintf(path, sizeof(path), "%s/%s", s->root, - session) >= sizeof(path) || - (dfd = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | - O_NOFOLLOW)) == -1) + if (!session_valid(session) || (dfd = openat(s->root_fd, session, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)) == -1) return (-1); - fd = openat(dfd, ".session.lock", O_RDWR | O_CLOEXEC | O_NOFOLLOW); + if (fstat(dfd, &st) == -1 || !S_ISDIR(st.st_mode) || + st.st_uid != geteuid() || (st.st_mode & 0777) != 0700) { + close(dfd); + return (-1); + } + fd = openat(dfd, ".session.lock", + O_RDWR | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW); close(dfd); if (fd == -1) return (-1); + if (fstat(fd, &st) == -1 || !S_ISREG(st.st_mode) || + st.st_uid != geteuid() || (st.st_mode & 0777) != 0600 || + st.st_nlink != 1 || st.st_size != 0 || + lock_fd_normalize(fd) == -1) { + close(fd); + return (-1); + } if (flock(fd, LOCK_EX | LOCK_NB) == -1) { close(fd); return (-1); @@ -562,42 +699,37 @@ static int reserve_space(struct artifact_store *s) { struct eviction *list = NULL; - struct dirent *de; - DIR *root; + struct dirent **names = NULL; int *locks = NULL; uint64_t session_total = 0, global_total = 0; size_t nlist = 0, cap = 0, i, nlocks = 0; - int dfd = -1, lockfd, ok = -1, scan_ok = 0; + int count = 0, j, lockfd, ok = -1, scan_ok = 0; if (flock(s->global_lock_fd, LOCK_EX) == -1) return (-1); if (scan_session(s, s->session, 1, 1, &session_total, &global_total, &list, &nlist, &cap) == -1) goto done; - if ((dfd = open(s->root, O_RDONLY | O_DIRECTORY | O_CLOEXEC | - O_NOFOLLOW)) == -1 || (root = fdopendir(dfd)) == NULL) { - if (dfd != -1) - close(dfd); + if ((count = scandirat(s->root_fd, ".", &names, session_name, + NULL)) == -1) goto done; - } - while ((de = readdir(root)) != NULL) { - if (strcmp(de->d_name, ".") == 0 || - strcmp(de->d_name, "..") == 0 || - strcmp(de->d_name, s->session) == 0 || - de->d_name[0] == '.') + for (j = 0; j < count; j++) { + const char *name = names[j]->d_name; + + if (strcmp(name, s->session) == 0) continue; - lockfd = other_session_lock(s, de->d_name); + lockfd = other_session_lock(s, name); if (lockfd != -1) { locks = xreallocarray(locks, nlocks + 1, sizeof(*locks)); locks[nlocks++] = lockfd; } - if (scan_session(s, de->d_name, 0, lockfd != -1, &session_total, + if (scan_session(s, name, 0, lockfd != -1, &session_total, &global_total, &list, &nlist, &cap) == -1) { - closedir(root); goto done; } } - closedir(root); + free_dirents(names, count); + names = NULL; qsort(list, nlist, sizeof(*list), eviction_cmp); /* Satisfy the per-Session bound from that Session's own oldest finals. */ for (i = 0; session_total > @@ -633,6 +765,8 @@ done: * or quota lock is released, including partial-error paths. */ if (eviction_sync(list, nlist) == -1) ok = -1; + if (names != NULL) + free_dirents(names, count); free(list); for (i = 0; i < nlocks; i++) { (void)flock(locks[i], LOCK_UN); @@ -651,9 +785,10 @@ artifact_store_turn_begin(struct artifact_store *s) { struct artifact_pending *p; - if (s == NULL || s->turn_open) + if (s == NULL || s->phase != ARTIFACT_STORE_READY || s->turn_open || + s->turn_prepared) return (-1); - for (p = s->pending; p != NULL; p = p->next) + LIST_FOREACH(p, &s->pending, entry) if (p->turn_owned) return (-1); s->turn_open = 1; @@ -684,7 +819,9 @@ artifact_store_begin(struct artifact_store *s, uint32_ int pipes[2], tries; if (s == NULL || out == NULL || write_fd == NULL || stream == NULL || - !s->turn_open || correlation == 0 || + s->phase != ARTIFACT_STORE_READY || !s->turn_open || + s->turn_prepared || + correlation == 0 || pending_find_correlation(s, correlation) != NULL || (kind != ARTIFACT_BEGIN_FOREGROUND && kind != ARTIFACT_BEGIN_SPOOL)) return (-1); @@ -723,8 +860,9 @@ artifact_store_begin(struct artifact_store *s, uint32_ continue; if (errno != ENOENT) break; - (void)snprintf(p->staging, sizeof(p->staging), ".pending-%s", - p->id); + if ((size_t)snprintf(p->staging, sizeof(p->staging), + ".pending-%s", p->id) >= sizeof(p->staging)) + break; p->file_fd = openat(s->session_fd, p->staging, O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); if (p->file_fd != -1 || errno != EEXIST) @@ -756,10 +894,9 @@ artifact_store_begin(struct artifact_store *s, uint32_ } (void)flock(s->global_lock_fd, LOCK_UN); p->read_fd = pipes[0]; - p->next = s->pending; - s->pending = p; + LIST_INSERT_HEAD(&s->pending, p, entry); stream->status = ARTIFACT_STREAM_AVAILABLE; - strlcpy(stream->id, p->id, sizeof(stream->id)); + memcpy(stream->id, p->id, sizeof(stream->id)); *out = p; *write_fd = pipes[1]; return (0); @@ -785,18 +922,37 @@ pending_order(struct artifact_pending *p) struct stat st; uint64_t sequence = 0; ssize_t n; - int counter = -1, order = -1, rc = -1; + int counter = -1, counter_created = 0, order = -1, rc = -1; if (fault_hit(ARTIFACT_STORE_FAULT_DRAIN_ORDER) || order_name(p->id, name, sizeof(name)) == -1 || flock(s->global_lock_fd, LOCK_EX) == -1) return (-1); counter = openat(s->root_fd, SEQUENCE_FILE, - O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0600); - if (counter == -1 || fchmod(counter, 0600) == -1 || - fstat(counter, &st) == -1 || !S_ISREG(st.st_mode) || + O_RDWR | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW); + if (counter == -1 && errno == ENOENT) { + counter = openat(s->root_fd, SEQUENCE_FILE, + O_RDWR | O_NONBLOCK | O_CREAT | O_EXCL | O_CLOEXEC | + O_NOFOLLOW, 0600); + if (counter != -1) + counter_created = 1; + } + /* Existing metadata is authenticated before any chmod or write. A new + * O_EXCL inode may be repaired after an inherited umask, then undergoes + * the same complete authentication before its first write. */ + if (counter == -1 || fstat(counter, &st) == -1 || + !S_ISREG(st.st_mode) || st.st_uid != geteuid() || + st.st_nlink != 1 || + (!counter_created && (st.st_mode & 0777) != 0600) || (st.st_size != 0 && st.st_size != (off_t)sizeof(sequence))) goto done; + if (counter_created && fchmod(counter, 0600) == -1) + goto done; + if (fstat(counter, &st) == -1 || !S_ISREG(st.st_mode) || + st.st_uid != geteuid() || (st.st_mode & 0777) != 0600 || + st.st_nlink != 1 || + (st.st_size != 0 && st.st_size != (off_t)sizeof(sequence))) + goto done; if (st.st_size != 0) { do { n = pread(counter, &sequence, sizeof(sequence), 0); @@ -814,8 +970,12 @@ pending_order(struct artifact_pending *p) ftruncate(counter, sizeof(sequence)) == -1 || fsync(counter) == -1) goto done; order = openat(s->session_fd, name, - O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600); - if (order == -1 || fchmod(order, 0600) == -1) + O_WRONLY | O_NONBLOCK | O_CREAT | O_EXCL | O_CLOEXEC | + O_NOFOLLOW, 0600); + if (order == -1 || fchmod(order, 0600) == -1 || + fstat(order, &st) == -1 || + !S_ISREG(st.st_mode) || st.st_uid != geteuid() || + (st.st_mode & 0777) != 0600 || st.st_nlink != 1 || st.st_size != 0) goto done; do { n = write(order, &sequence, sizeof(sequence)); @@ -897,23 +1057,18 @@ artifact_pending_drain(struct artifact_pending *p, p->terminal_done = 1; p->terminal_status = terminal->status; terminal->retained_bytes = p->retained; - strlcpy(terminal->id, p->id, sizeof(terminal->id)); + memcpy(terminal->id, p->id, sizeof(terminal->id)); return (result); } static struct artifact_pending * -pending_find(struct artifact_store *s, const char *id, - struct artifact_pending ***linkp) +pending_find(struct artifact_store *s, const char *id) { - struct artifact_pending **link, *p; + struct artifact_pending *p; - for (link = &s->pending; (p = *link) != NULL; link = &p->next) { - if (strcmp(p->id, id) == 0) { - if (linkp != NULL) - *linkp = link; + LIST_FOREACH(p, &s->pending, entry) + if (strcmp(p->id, id) == 0) return (p); - } - } return (NULL); } @@ -922,7 +1077,7 @@ pending_find_correlation(struct artifact_store *s, uin { struct artifact_pending *p; - for (p = s->pending; p != NULL; p = p->next) + LIST_FOREACH(p, &s->pending, entry) if (p->correlation == correlation) return (p); return (NULL); @@ -930,12 +1085,12 @@ pending_find_correlation(struct artifact_store *s, uin static struct artifact_pending * pending_find_pair(struct artifact_store *s, uint32_t correlation, - const char *id, struct artifact_pending ***linkp) + const char *id) { struct artifact_pending *p; if (correlation == 0 || - (p = pending_find(s, id, linkp)) == NULL || + (p = pending_find(s, id)) == NULL || p->correlation != correlation) return (NULL); return (p); @@ -945,12 +1100,13 @@ int artifact_store_discard(struct artifact_store *s, uint32_t correlation, const char *id) { - struct artifact_pending **link, *p; + struct artifact_pending *p; - if (s == NULL || !artifact_id_wire_valid(id) || - (p = pending_find_pair(s, correlation, id, &link)) == NULL || + if (s == NULL || s->phase != ARTIFACT_STORE_READY || + !artifact_id_wire_valid(id) || + (p = pending_find_pair(s, correlation, id)) == NULL || !p->terminal_done || - p->prepared) + p->final_linked || p->prepared) return (-1); if (p->file_fd != -1) { close(p->file_fd); @@ -961,7 +1117,7 @@ artifact_store_discard(struct artifact_store *s, uint3 return (-1); if (order_unlink(s, p->id) == -1) return (-1); - *link = p->next; + LIST_REMOVE(p, entry); free(p); return (0); } @@ -972,12 +1128,13 @@ artifact_store_retain(struct artifact_store *s, uint32 { struct artifact_pending *p; - if (s == NULL || !s->turn_open || !artifact_id_wire_valid(id) || - (p = pending_find_pair(s, correlation, id, NULL)) == NULL || + if (s == NULL || s->phase != ARTIFACT_STORE_READY || !s->turn_open || + !artifact_id_wire_valid(id) || + (p = pending_find_pair(s, correlation, id)) == NULL || !p->terminal_done || (p->terminal_status != ARTIFACT_STORE_OK && p->terminal_status != ARTIFACT_STORE_CAP) || !p->background || - p->prepared) + p->final_linked || p->prepared) return (-1); p->background = 0; p->turn_owned = 1; @@ -990,10 +1147,11 @@ artifact_store_detach(struct artifact_store *s, uint32 { struct artifact_pending *p; - if (s == NULL || !s->turn_open || !artifact_id_wire_valid(id) || - (p = pending_find_pair(s, correlation, id, NULL)) == NULL || + if (s == NULL || s->phase != ARTIFACT_STORE_READY || !s->turn_open || + !artifact_id_wire_valid(id) || + (p = pending_find_pair(s, correlation, id)) == NULL || !p->background || - !p->turn_owned || p->prepared) + !p->turn_owned || p->final_linked || p->prepared) return (-1); p->turn_owned = 0; return (0); @@ -1004,9 +1162,10 @@ artifact_store_turn_prepare(struct artifact_store *s) { struct artifact_pending *p; - if (s == NULL || !s->turn_open) + if (s == NULL || s->phase != ARTIFACT_STORE_READY || !s->turn_open || + s->turn_prepared) return (-1); - for (p = s->pending; p != NULL; p = p->next) { + LIST_FOREACH(p, &s->pending, entry) { if (p->terminal_done && p->terminal_status == ARTIFACT_STORE_ERROR) return (-1); @@ -1022,52 +1181,62 @@ artifact_store_turn_prepare(struct artifact_store *s) p->terminal_status != ARTIFACT_STORE_CAP) || p->prepared) return (-1); } - for (p = s->pending; p != NULL; p = p->next) { + LIST_FOREACH(p, &s->pending, entry) { if (p->background) continue; /* linkat is the no-replace publication primitive: renameat would * silently overwrite a final and let an expired id alias new bytes. */ if (fsync(p->file_fd) == -1 || linkat(s->session_fd, p->staging, s->session_fd, p->id, 0) == - -1 || unlinkat(s->session_fd, p->staging, 0) == -1) + -1) return (-1); + p->final_linked = 1; + if (fault_hit(ARTIFACT_STORE_FAULT_PREPARE_STAGING_UNLINK) || + unlinkat(s->session_fd, p->staging, 0) == -1) + return (-1); p->prepared = 1; } - return (fsync(s->session_fd)); + if (fsync(s->session_fd) == -1) + return (-1); + s->turn_prepared = 1; + return (0); } int artifact_store_turn_commit(struct artifact_store *s) { - struct artifact_pending **link, *p; + struct artifact_pending *p, *next; - if (s == NULL || !s->turn_open) + if (s == NULL || s->phase != ARTIFACT_STORE_READY || !s->turn_open || + !s->turn_prepared) return (-1); - for (link = &s->pending; (p = *link) != NULL;) { + for (p = LIST_FIRST(&s->pending); p != NULL; p = next) { + next = LIST_NEXT(p, entry); if (p->background) { p->turn_owned = 0; - link = &p->next; continue; } if (!p->prepared || known_add(s, p->id, p->retained) == -1) return (-1); close(p->file_fd); - *link = p->next; + LIST_REMOVE(p, entry); free(p); } s->turn_open = 0; + s->turn_prepared = 0; return (0); } int artifact_store_turn_abort(struct artifact_store *s) { - struct artifact_pending **link, *p; + struct artifact_pending *p, *next; int failed = 0, dirty = 0; - if (s == NULL || !s->turn_open) + if (s == NULL || s->phase != ARTIFACT_STORE_READY || !s->turn_open) return (-1); - for (p = s->pending; p != NULL; p = p->next) { + s->turn_prepared = 0; + LIST_FOREACH(p, &s->pending, entry) { if (!p->turn_owned) continue; if (p->read_fd != -1) { @@ -1078,15 +1247,19 @@ artifact_store_turn_abort(struct artifact_store *s) close(p->file_fd); p->file_fd = -1; } - if (fault_hit(p->prepared ? - ARTIFACT_STORE_FAULT_ABORT_FINAL_UNLINK : - ARTIFACT_STORE_FAULT_ABORT_STAGING_UNLINK) || - (unlinkat(s->session_fd, - p->prepared ? p->id : p->staging, 0) == -1 && + if (fault_hit(ARTIFACT_STORE_FAULT_ABORT_STAGING_UNLINK) || + (unlinkat(s->session_fd, p->staging, 0) == -1 && errno != ENOENT)) failed = 1; else dirty = 1; + if (p->final_linked && + (fault_hit(ARTIFACT_STORE_FAULT_ABORT_FINAL_UNLINK) || + (unlinkat(s->session_fd, p->id, 0) == -1 && + errno != ENOENT))) + failed = 1; + else if (p->final_linked) + dirty = 1; if (fault_hit(ARTIFACT_STORE_FAULT_ABORT_ORDER_UNLINK) || order_unlink(s, p->id) == -1) failed = 1; @@ -1098,12 +1271,11 @@ artifact_store_turn_abort(struct artifact_store *s) failed = 1; if (failed) return (-1); - for (link = &s->pending; (p = *link) != NULL;) { - if (!p->turn_owned) { - link = &p->next; + for (p = LIST_FIRST(&s->pending); p != NULL; p = next) { + next = LIST_NEXT(p, entry); + if (!p->turn_owned) continue; - } - *link = p->next; + LIST_REMOVE(p, entry); free(p); } s->turn_open = 0; @@ -1122,9 +1294,12 @@ artifact_store_read(struct artifact_store *s, int fd = -1; int pending = 0; + if (s == NULL || out == NULL || result == NULL || + s->phase != ARTIFACT_STORE_READY) + return (-1); buf_reset(out); memset(result, 0, sizeof(*result)); - if (s == NULL || req == NULL || !artifact_id_wire_valid(req->id)) { + if (req == NULL || !artifact_id_wire_valid(req->id)) { result->status = ARTIFACT_READ_MALFORMED; return (0); } @@ -1132,7 +1307,7 @@ artifact_store_read(struct artifact_store *s, result->status = ARTIFACT_READ_RANGE; return (0); } - if ((p = pending_find(s, req->id, NULL)) != NULL) { + if ((p = pending_find(s, req->id)) != NULL) { fd = p->file_fd; result->total_bytes = p->retained; result->status = ARTIFACT_READ_PENDING; @@ -1140,7 +1315,7 @@ artifact_store_read(struct artifact_store *s, } else if ((known = known_find(s, req->id)) != NULL) { result->total_bytes = known->retained; fd = openat(s->session_fd, req->id, - O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW); if (fd == -1 && errno == ENOENT) { result->status = ARTIFACT_READ_EXPIRED; return (0); @@ -1151,7 +1326,8 @@ artifact_store_read(struct artifact_store *s, return (0); } if (fd == -1 || fstat(fd, &st) == -1 || !S_ISREG(st.st_mode) || - st.st_size < 0 || (pending ? + st.st_uid != geteuid() || (st.st_mode & 0777) != 0600 || + st.st_nlink != 1 || st.st_size < 0 || (pending ? (uint64_t)st.st_size < result->total_bytes : (uint64_t)st.st_size != result->total_bytes)) { result->status = ARTIFACT_READ_ERROR; blob - e0eb536cbc2b88f91b61d63beeed573ae12392ab blob + 53ad88a2dd0728d10fef8858ecadf4dcace41e3d --- src/fugu-artifact/store.h +++ src/fugu-artifact/store.h @@ -59,11 +59,14 @@ enum artifact_store_test_fault { ARTIFACT_STORE_FAULT_DRAIN_FTRUNCATE, ARTIFACT_STORE_FAULT_DRAIN_FUTIMENS, ARTIFACT_STORE_FAULT_DRAIN_ORDER, + ARTIFACT_STORE_FAULT_RECONCILE_FSYNC, + ARTIFACT_STORE_FAULT_PREPARE_STAGING_UNLINK, ARTIFACT_STORE_FAULT_ABORT_STAGING_UNLINK, ARTIFACT_STORE_FAULT_ABORT_FINAL_UNLINK, ARTIFACT_STORE_FAULT_ABORT_ORDER_UNLINK, ARTIFACT_STORE_FAULT_ABORT_FSYNC, ARTIFACT_STORE_FAULT_DISCARD_UNLINK, + ARTIFACT_STORE_FAULT_INACTIVE_CLEANUP_FSYNC, ARTIFACT_STORE_FAULT_EVICTION_FSYNC }; blob - 25d5a1715dc1a828482e9a376c84e3e696389500 blob + d20aac06cfa37b15618a2fb9c3355db0abf7cb9f --- src/fugu-tool/Makefile +++ src/fugu-tool/Makefile @@ -3,8 +3,8 @@ SRCS= main.c agent.c agent_boot.c tools.c job.c superv anthropic_req.c claude_sub_req.c claude_sub_profile.c openai_req.c \ codex_sub_req.c codex_sub_profile.c \ turn_mechanics.c generation.c msg.c provider_state.c json.c \ - artifact_result.c artifact_capture.c effort.c buf.c diff.c log.c \ - xmalloc.c + artifact_capture.c artifact_pipe.c artifact_scope.c \ + artifact_result.c effort.c buf.c diff.c log.c xmalloc.c BINDIR= ${LIBEXECDIR}/fugu MAN= blob - 94d6edd0df8f48460e37801d9c44410c5fe00091 blob + ba537a15eb22ee38f21810488368ceb153bbbf93 --- src/fugu-tool/job.c +++ src/fugu-tool/job.c @@ -15,11 +15,14 @@ */ #include +#include +#include #include #include #include #include +#include #include #include #include @@ -40,7 +43,26 @@ #define JOB_PUMP_TOTAL (1024 * 1024) #define JOB_WRITE_PENDING_MAX (64 * 1024) #define DEFAULT_CAP 4 /* max_bg_jobs default */ +#define JOB_COMPLETED_RECORD_MAX 64 /* separate from running-job cap */ +#define JOB_TOMBSTONE_MAX 512 /* exact delayed-control keys */ +#define JOB_OWNERSHIP_MAX 1024 /* all unresolved job/spool keys */ +struct job_artifact_ref { + LIST_ENTRY(job_artifact_ref) entry; + char id[ARTIFACT_ID_HEX_LEN + 1]; +}; +LIST_HEAD(job_artifact_ref_list, job_artifact_ref); + +struct job_artifact_tombstone { + LIST_ENTRY(job_artifact_tombstone) entry; + int job_id; + uint32_t correlation; + int drop_pending; + int drop_sent; + char id[ARTIFACT_ID_HEX_LEN + 1]; +}; +LIST_HEAD(job_artifact_tombstone_list, job_artifact_tombstone); + struct job { struct job *next; int id; @@ -52,7 +74,10 @@ struct job { int artifact_fd; /* current spool write end, nonblocking */ int artifact_managed; int artifact_available; + int artifact_settling; + uint32_t artifact_correlation; char artifact_id[ARTIFACT_ID_HEX_LEN + 1]; + struct job_artifact_ref_list artifact_history; struct artifact_capture capture; /* bounded current segment preview */ struct buf pending_write; /* bounded partial spool write */ int cap_reached; @@ -61,26 +86,317 @@ struct job { int exited; int status; int pending; /* spawned by the open, uncommitted Turn */ + int reported_terminal; +#ifdef JOB_TESTING + size_t test_pump_bytes; +#endif }; struct joblist { struct job *head; + struct job_artifact_tombstone_list tombstones; int64_t next_id; int cap; + size_t record_limit; + size_t tombstone_count; + size_t tombstone_limit; + size_t ownership_limit; int turn_open; int pump_cursor; /* last id visited by round-robin pump */ +#ifdef JOB_TESTING + size_t test_pump_total; +#endif }; static void job_stop(struct job *); +static struct job *job_find(struct joblist *, int); +static void job_prune_reported(struct joblist *); +static size_t job_ownership_count(struct joblist *); +#ifdef JOB_TESTING +static int test_backpressure_at_boundary; +static size_t test_pump_per_job = JOB_PUMP_PER_JOB; +static size_t test_pump_total = JOB_PUMP_TOTAL; + +void +job_test_backpressure_at_boundary(void) +{ + test_backpressure_at_boundary = 1; +} + +int +job_test_boundary_pending(struct joblist *jl, int id) +{ + struct job *j = job_find(jl, id); + + return (j != NULL && j->capture.retained == ARTIFACT_RAW_MAX && + j->pending_write.len > 0); +} + +int +job_test_storage_unavailable(struct joblist *jl, int id) +{ + struct job *j = job_find(jl, id); + + return (j != NULL && j->storage_unavailable); +} + +size_t +job_test_pending_bytes(struct joblist *jl, int id) +{ + struct job *j = job_find(jl, id); + + return (j != NULL ? j->pending_write.len : 0); +} + +size_t +job_test_output_bytes(struct joblist *jl, int id) +{ + struct job *j = job_find(jl, id); + int bytes = 0; + + if (j == NULL || j->fd == -1 || ioctl(j->fd, FIONREAD, &bytes) == -1 || + bytes < 0) + return (0); + return ((size_t)bytes); +} + +void +job_test_set_pump_limits(size_t per_job, size_t total) +{ + test_pump_per_job = per_job; + test_pump_total = total; +} + +void +job_test_reset_pump_limits(void) +{ + test_pump_per_job = JOB_PUMP_PER_JOB; + test_pump_total = JOB_PUMP_TOTAL; +} + +size_t +job_test_pump_per_job_limit(void) +{ + return (JOB_PUMP_PER_JOB); +} + +size_t +job_test_pump_total_limit(void) +{ + return (JOB_PUMP_TOTAL); +} + +size_t +job_test_last_pump_total(struct joblist *jl) +{ + return (jl->test_pump_total); +} + +size_t +job_test_last_pump_bytes(struct joblist *jl, int id) +{ + struct job *j = job_find(jl, id); + + return (j != NULL ? j->test_pump_bytes : 0); +} + +int +job_test_state(struct joblist *jl, int id, struct job_test_state *state) +{ + struct job *j = job_find(jl, id); + + if (j == NULL || state == NULL) + return (-1); + memset(state, 0, sizeof(*state)); + state->observed = j->capture.observed; + state->retained = j->capture.retained; + state->pending_bytes = j->pending_write.len; + state->cap_reached = j->cap_reached; + state->exited = j->exited; + state->producer_open = j->fd != -1; + state->artifact_open = j->artifact_fd != -1; + state->storage_error = j->storage_error; + state->storage_unavailable = j->storage_unavailable; + return (0); +} + +void +job_test_set_record_limit(struct joblist *jl, size_t limit) +{ + jl->record_limit = limit; +} + +void +job_test_set_tombstone_limit(struct joblist *jl, size_t limit) +{ + jl->tombstone_limit = limit; +} + +size_t +job_test_record_count(struct joblist *jl) +{ + struct job *j; + size_t n = 0; + + for (j = jl->head; j != NULL; j = j->next) + n++; + return (n); +} + +size_t +job_test_tombstone_count(struct joblist *jl) +{ + return (jl->tombstone_count); +} + +void +job_test_set_ownership_limit(struct joblist *jl, size_t limit) +{ + jl->ownership_limit = limit; +} + +size_t +job_test_ownership_count(struct joblist *jl) +{ + return (job_ownership_count(jl)); +} +#endif + +static void +job_history_free(struct job *j) +{ + struct job_artifact_ref *ref; + + while ((ref = LIST_FIRST(&j->artifact_history)) != NULL) { + LIST_REMOVE(ref, entry); + free(ref); + } +} + +static int +job_history_has(const struct job *j, const char *id) +{ + const struct job_artifact_ref *ref; + + LIST_FOREACH(ref, &j->artifact_history, entry) + if (strcmp(ref->id, id) == 0) + return (1); + return (0); +} + +static int +job_history_add(struct job *j, const char *id) +{ + struct job_artifact_ref *ref; + + if (!artifact_id_wire_valid(id) || job_history_has(j, id)) + return (-1); + ref = xcalloc(1, sizeof(*ref)); + strlcpy(ref->id, id, sizeof(ref->id)); + LIST_INSERT_HEAD(&j->artifact_history, ref, entry); + return (0); +} + +static int +job_history_remove(struct job *j, const char *id) +{ + struct job_artifact_ref *ref; + + LIST_FOREACH(ref, &j->artifact_history, entry) { + if (strcmp(ref->id, id) != 0) + continue; + LIST_REMOVE(ref, entry); + free(ref); + return (0); + } + return (-1); +} + +static size_t +job_history_count(const struct job *j) +{ + const struct job_artifact_ref *ref; + size_t n = 0; + + LIST_FOREACH(ref, &j->artifact_history, entry) + n++; + return (n); +} + +static int +job_artifact_id_zero(const char id[ARTIFACT_ID_HEX_LEN + 1]) +{ + static const char zero[ARTIFACT_ID_HEX_LEN + 1]; + + return (memcmp(id, zero, sizeof(zero)) == 0); +} + +static struct job_artifact_tombstone * +job_tombstone_find(struct joblist *jl, int job_id, const char *id) +{ + struct job_artifact_tombstone *tomb; + + LIST_FOREACH(tomb, &jl->tombstones, entry) + if (tomb->job_id == job_id && strcmp(tomb->id, id) == 0) + return (tomb); + return (NULL); +} + +static int +job_tombstone_add(struct joblist *jl, int job_id, const char *id, + uint32_t correlation, int drop_pending) +{ + struct job_artifact_tombstone *tomb; + + if ((!artifact_id_wire_valid(id) && + !(drop_pending && job_artifact_id_zero(id))) || + jl->tombstone_count >= + jl->tombstone_limit || job_tombstone_find(jl, job_id, id) != NULL || + (drop_pending && correlation == 0)) + return (-1); + tomb = xcalloc(1, sizeof(*tomb)); + tomb->job_id = job_id; + tomb->correlation = correlation; + tomb->drop_pending = drop_pending; + strlcpy(tomb->id, id, sizeof(tomb->id)); + LIST_INSERT_HEAD(&jl->tombstones, tomb, entry); + jl->tombstone_count++; + return (0); +} + +static void +job_tombstone_remove(struct joblist *jl, struct job_artifact_tombstone *tomb) +{ + LIST_REMOVE(tomb, entry); + free(tomb); + jl->tombstone_count--; +} + +static void +job_tombstones_remove_job(struct joblist *jl, int job_id) +{ + struct job_artifact_tombstone *tomb, *next; + + for (tomb = LIST_FIRST(&jl->tombstones); tomb != NULL; tomb = next) { + next = LIST_NEXT(tomb, entry); + if (tomb->job_id == job_id) + job_tombstone_remove(jl, tomb); + } +} + struct joblist * job_list_new(void) { struct joblist *jl; jl = xcalloc(1, sizeof(*jl)); + LIST_INIT(&jl->tombstones); jl->next_id = 1; jl->cap = DEFAULT_CAP; + jl->record_limit = DEFAULT_CAP + JOB_COMPLETED_RECORD_MAX; + jl->tombstone_limit = JOB_TOMBSTONE_MAX; + jl->ownership_limit = JOB_OWNERSHIP_MAX; return (jl); } @@ -88,8 +404,17 @@ void job_set_cap(struct joblist *jl, int cap) { jl->cap = cap; + jl->record_limit = (size_t)cap + JOB_COMPLETED_RECORD_MAX; } +void +job_selftest_set_ownership_limit(struct joblist *jl, size_t limit) +{ + if (jl == NULL || limit == 0 || limit > JOB_OWNERSHIP_MAX) + abort(); + jl->ownership_limit = limit; +} + static struct job * job_find(struct joblist *jl, int id) { @@ -110,7 +435,152 @@ job_spool_close(struct job *j) } } +static size_t +job_record_count(struct joblist *jl) +{ + struct job *j; + size_t n = 0; + + for (j = jl->head; j != NULL; j = j->next) + n++; + return (n); +} + +static size_t +job_ownership_count(struct joblist *jl) +{ + struct job *j; + size_t n = jl->tombstone_count; + + for (j = jl->head; j != NULL; j = j->next) { + n += job_history_count(j); + if (j->artifact_managed) + n++; + } + return (n); +} + static void +job_destroy(struct job *j) +{ + job_stop(j); + if (j->fd != -1) + close(j->fd); + if (j->life_fd != -1) + close(j->life_fd); + if (j->status_fd != -1) + close(j->status_fd); + job_spool_close(j); + artifact_capture_free(&j->capture); + buf_free(&j->pending_write); + job_history_free(j); + free(j); +} + +static int +job_archive_possible(struct joblist *jl, const struct job *j, + int drop_current) +{ + const struct job_artifact_ref *ref; + size_t needed = job_history_count(j) + (drop_current ? 1 : 0); + + if (jl->tombstone_count > jl->tombstone_limit || + needed > jl->tombstone_limit - jl->tombstone_count) + return (0); + LIST_FOREACH(ref, &j->artifact_history, entry) + if (!artifact_id_wire_valid(ref->id) || + job_tombstone_find(jl, j->id, ref->id) != NULL) + return (0); + if (drop_current && (!j->artifact_managed || j->artifact_correlation == 0 || + (j->artifact_available ? !artifact_id_wire_valid(j->artifact_id) : + !job_artifact_id_zero(j->artifact_id)) || + job_tombstone_find(jl, j->id, j->artifact_id) != NULL)) + return (0); + return (1); +} + +static int +job_archive(struct joblist *jl, struct job **link, int drop_current) +{ + struct job_artifact_ref *ref; + struct job *j = *link; + + if (!job_archive_possible(jl, j, drop_current)) + return (0); + LIST_FOREACH(ref, &j->artifact_history, entry) + if (job_tombstone_add(jl, j->id, ref->id, 0, 0) == -1) + abort(); + if (drop_current && job_tombstone_add(jl, j->id, j->artifact_id, + j->artifact_correlation, 1) == -1) + abort(); + *link = j->next; + job_destroy(j); + return (1); +} + +static void +job_prune_reported(struct joblist *jl) +{ + struct job **link, *j; + + for (link = &jl->head; (j = *link) != NULL; ) { + if (!j->pending && j->reported_terminal && + !j->artifact_available && + job_archive(jl, link, 0)) + continue; + link = &j->next; + } +} + +static int +job_prune_completed_one(struct joblist *jl) +{ + struct job **link, **oldest_link = NULL, *j; + + for (link = &jl->head; (j = *link) != NULL; link = &j->next) { + int drop_current = j->artifact_managed; + + if (j->pending || j->artifact_settling || !j->exited || j->fd != -1 || + j->artifact_fd != -1 || j->pending_write.len != 0 || + !job_archive_possible(jl, j, drop_current)) + continue; + if (oldest_link == NULL || j->id < (*oldest_link)->id) + oldest_link = link; + } + if (oldest_link == NULL) + return (0); + return (job_archive(jl, oldest_link, + (*oldest_link)->artifact_managed)); +} + +int +job_selftest_archive_completed(struct joblist *jl) +{ + struct job *j; + + if (jl == NULL) + abort(); + for (j = jl->head; j != NULL; j = j->next) { + if (j->pending || j->artifact_settling || j->fd != -1 || + j->artifact_fd != -1 || j->pending_write.len != 0) + continue; + job_stop(j); + break; + } + return (job_prune_completed_one(jl)); +} + +static int +job_make_record_room(struct joblist *jl) +{ + job_prune_reported(jl); + while (job_record_count(jl) >= jl->record_limit) + if (!job_prune_completed_one(jl)) + return (-1); + return (0); +} + +static void job_fail_segment(struct job *j, int storage_error, int unavailable) { if (storage_error) @@ -127,6 +597,18 @@ job_fail_segment(struct job *j, int storage_error, int job_spool_close(j); } +static void +job_cap_segment(struct job *j) +{ + j->cap_reached = 1; + if (!j->exited) + (void)killpg(j->pgid, SIGKILL); + if (j->fd != -1) { + close(j->fd); + j->fd = -1; + } +} + /* Flush the bounded partial write before reading another producer byte. * 1 means empty, 0 means pipe backpressure, -1 means storage failure. */ static int @@ -135,6 +617,14 @@ job_flush(struct job *j) ssize_t n; while (j->pending_write.len > 0) { +#ifdef JOB_TESTING + if (test_backpressure_at_boundary && + j->capture.retained == ARTIFACT_RAW_MAX) { + test_backpressure_at_boundary = 0; + errno = EAGAIN; + return (0); + } +#endif n = write(j->artifact_fd, j->pending_write.data, j->pending_write.len); if (n > 0) { @@ -172,6 +662,11 @@ job_drain(struct job *j, size_t budget) want = sizeof(chunk); if (want > budget - drained) want = budget - drained; + want = artifact_capture_read_limit(&j->capture, want); + if (want == 0) { + job_cap_segment(j); + break; + } n = read(j->fd, chunk, want); if (n == -1) { if (errno == EINTR) @@ -191,6 +686,8 @@ job_drain(struct job *j, size_t budget) job_fail_segment(j, 1, 0); break; } + if (capture_rc == 1) + job_cap_segment(j); keep = (size_t)j->capture.retained - before; if (j->artifact_available && keep > 0) { if (j->pending_write.len > JOB_WRITE_PENDING_MAX - keep) { @@ -208,8 +705,8 @@ job_drain(struct job *j, size_t budget) break; } if (capture_rc == 1) { - j->cap_reached = 1; - job_fail_segment(j, 0, 0); + if (j->pending_write.len == 0) + job_spool_close(j); break; } } @@ -281,11 +778,24 @@ job_pump(struct joblist *jl) { struct job *j, *candidate; size_t total = 0; + size_t per_job_limit = JOB_PUMP_PER_JOB; + size_t total_limit = JOB_PUMP_TOTAL; int cursor = jl->pump_cursor, visits = 0, count = 0; - for (j = jl->head; j != NULL; j = j->next) + for (j = jl->head; j != NULL; j = j->next) { count++; - while (visits++ < count && total < JOB_PUMP_TOTAL) { +#ifdef JOB_TESTING + j->test_pump_bytes = 0; +#endif + } +#ifdef JOB_TESTING + per_job_limit = test_pump_per_job; + total_limit = test_pump_total; + jl->test_pump_total = 0; +#endif + while (visits++ < count && total < total_limit) { + size_t drained; + candidate = NULL; for (j = jl->head; j != NULL; j = j->next) if (j->id > cursor && @@ -299,13 +809,20 @@ job_pump(struct joblist *jl) } if (candidate == NULL) break; - total += job_drain(candidate, - JOB_PUMP_TOTAL - total < JOB_PUMP_PER_JOB ? - JOB_PUMP_TOTAL - total : JOB_PUMP_PER_JOB); + drained = job_drain(candidate, + total_limit - total < per_job_limit ? + total_limit - total : per_job_limit); + total += drained; +#ifdef JOB_TESTING + candidate->test_pump_bytes = drained; +#endif job_reap(candidate); cursor = candidate->id; jl->pump_cursor = cursor; } +#ifdef JOB_TESTING + jl->test_pump_total = total; +#endif } static int @@ -315,16 +832,24 @@ job_adopt_stream(struct job *j, struct tool_ctx *ctx) j->artifact_managed = ctx->artifact_mode; j->artifact_available = ctx->artifact_mode && ctx->artifact_available; + j->artifact_settling = 0; + j->artifact_correlation = ctx->artifact_mode ? + ctx->artifact_correlation : 0; j->artifact_fd = -1; + if (ctx->artifact_mode && j->artifact_correlation == 0) + return (-1); if (j->artifact_available) { - if ((flags = fcntl(ctx->artifact_fd, F_GETFL)) == -1 || + if (!artifact_id_wire_valid(ctx->artifact_id) || + job_history_has(j, ctx->artifact_id) || + (flags = fcntl(ctx->artifact_fd, F_GETFL)) == -1 || fcntl(ctx->artifact_fd, F_SETFL, flags | O_NONBLOCK) == -1) return (-1); j->artifact_fd = ctx->artifact_fd; ctx->artifact_fd = -1; strlcpy(j->artifact_id, ctx->artifact_id, sizeof(j->artifact_id)); - } + } else + memset(j->artifact_id, 0, sizeof(j->artifact_id)); if (ctx->artifact_mode) ctx->artifact_spool_adopted = 1; return (0); @@ -373,6 +898,21 @@ job_start(struct joblist *jl, struct tool_ctx *ctx, co free(cmd); return; } + if (job_make_record_room(jl) == -1) { + buf_addstr(out, "shell_bg: background-job settlement backlog; " + "poll an existing completed job first"); + *is_error = 1; + free(cmd); + return; + } + if (ctx->artifact_mode && + job_ownership_count(jl) >= jl->ownership_limit) { + buf_addstr(out, "shell_bg: Artifact settlement backlog; " + "settle an earlier background spool first"); + *is_error = 1; + free(cmd); + return; + } if (pipe(pipefd) == -1) { buf_addf(out, "shell_bg: pipe: %s", strerror(errno)); *is_error = 1; @@ -405,6 +945,7 @@ job_start(struct joblist *jl, struct tool_ctx *ctx, co free(cmd); j = xcalloc(1, sizeof(*j)); + LIST_INIT(&j->artifact_history); j->id = (int)jl->next_id++; j->pid = sp.pid; j->pgid = sp.pgid; @@ -430,6 +971,7 @@ job_start(struct joblist *jl, struct tool_ctx *ctx, co } j->next = jl->head; jl->head = j; + ctx->artifact_job_id = j->id; buf_addf(out, "started background job %d", j->id); } @@ -468,34 +1010,118 @@ job_stop(struct job *j) } static int -job_flush_blocking(struct job *j) +job_flush_settle(struct job *j) { - int flags; + struct pollfd pfd; + int rc; if (!j->artifact_available || j->artifact_fd == -1) return (0); - if ((flags = fcntl(j->artifact_fd, F_GETFL)) == -1 || - fcntl(j->artifact_fd, F_SETFL, flags & ~O_NONBLOCK) == -1) { - job_fail_segment(j, 1, 0); - return (-1); - } while (j->pending_write.len > 0) { - ssize_t n = write(j->artifact_fd, j->pending_write.data, - j->pending_write.len); - - if (n > 0) { - buf_drain(&j->pending_write, (size_t)n); - continue; + if (tool_cancel_requested()) + return (1); + rc = job_flush(j); + if (rc == 1) + return (0); + if (rc == -1) + return (-1); + pfd.fd = j->artifact_fd; + pfd.events = POLLOUT; + pfd.revents = 0; + rc = poll(&pfd, 1, 100); + if (rc == -1) { + if (errno == EINTR) + continue; + goto fail; } - if (n == -1 && errno == EINTR) - continue; + if (pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) + goto fail; + } + return (0); +fail: + job_fail_segment(j, 1, 0); + return (-1); +} + +/* Settle the old spool while consuming no more than the single entry + * snapshot. Cancellation leaves both producer and spool ownership intact. */ +static int +job_freeze_snapshot(struct job *j, size_t snapshot) +{ + size_t drained = 0, n; + int rc; + + for (;;) { + rc = job_flush_settle(j); + if (rc != 0) + return (rc); + if (drained == snapshot || j->fd == -1 || j->cap_reached || + j->storage_error || j->storage_unavailable) + return (0); + n = job_drain(j, snapshot - drained); + drained += n; + if (n == 0 && j->pending_write.len == 0) + return (0); + } +} + +/* Freeze only bytes already queued by the producer when shell_output entered. + * The per-job pump budget is also the synchronous poll budget; bytes queued + * later remain in the producer pipe for the replacement segment. */ +static int +job_poll_snapshot(struct job *j, size_t *snapshot) +{ + int bytes = 0; + + *snapshot = 0; + if (j->fd == -1) + return (0); + if (ioctl(j->fd, FIONREAD, &bytes) == -1 || bytes < 0) { job_fail_segment(j, 1, 0); return (-1); } + *snapshot = (size_t)bytes; + if (*snapshot > JOB_PUMP_PER_JOB) + *snapshot = JOB_PUMP_PER_JOB; + *snapshot = artifact_capture_read_limit(&j->capture, *snapshot); return (0); } +/* Reaping fixes the producer set: the supervisor has killed the process group + * and closed every writer. FIONREAD==0 plus POLLHUP can therefore close EOF + * without consuming a byte that belongs to a later poll segment. */ static void +job_close_reaped_eof(struct job *j) +{ + struct pollfd pfd; + int bytes = 0, rc; + + if (!j->exited || j->fd == -1) + return; + if (ioctl(j->fd, FIONREAD, &bytes) == -1 || bytes < 0) { + job_fail_segment(j, 1, 0); + return; + } + if (bytes != 0) + return; + pfd.fd = j->fd; + pfd.events = POLLIN; + pfd.revents = 0; + rc = poll(&pfd, 1, 0); + if (rc == -1) { + if (errno != EINTR) + job_fail_segment(j, 1, 0); + return; + } + if (rc == 1 && (pfd.revents & POLLHUP)) { + close(j->fd); + j->fd = -1; + if (j->pending_write.len == 0) + job_spool_close(j); + } +} + +static void job_result_terminal(struct job *j, struct tool_ctx *ctx, int *is_error) { ctx->artifact_exit_status = 0; @@ -528,7 +1154,8 @@ job_output(struct joblist *jl, struct tool_ctx *ctx, c int obj, struct buf *out, int *is_error) { struct job *j; - int id; + size_t snapshot = 0; + int id, terminal = 0; if (job_id_arg(js, obj, "shell_output", out, is_error, &id) == -1) return; @@ -537,15 +1164,39 @@ job_output(struct joblist *jl, struct tool_ctx *ctx, c *is_error = 1; return; } - (void)job_drain(j, JOB_PUMP_PER_JOB); + if (j->artifact_settling) { + buf_addf(out, "shell_output: job %d is awaiting Artifact " + "settlement", id); + *is_error = 1; + return; + } + ctx->artifact_job_id = j->id; + (void)job_poll_snapshot(j, &snapshot); + if (job_freeze_snapshot(j, snapshot) == 1) + return; job_reap(j); - if (j->exited) - (void)job_drain(j, JOB_PUMP_PER_JOB); + job_close_reaped_eof(j); if (ctx->artifact_mode) { - int adopt; + int adopt, ownership_backlog = 0; - (void)job_flush_blocking(j); + adopt = !j->cap_reached && !j->storage_error && + !j->storage_unavailable && (!j->exited || j->fd != -1); + if (adopt && + job_ownership_count(jl) >= jl->ownership_limit) { + /* Moving current to history is ownership-neutral, but adopting + * this replacement would add another unresolved exact key. */ + ownership_backlog = 1; + adopt = 0; + job_stop(j); + if (j->fd != -1) { + close(j->fd); + j->fd = -1; + } + } + if (j->artifact_available && !ownership_backlog && + job_history_add(j, j->artifact_id) == -1) + abort(); job_spool_close(j); ctx->artifact_captured = 1; ctx->artifact_observed = j->capture.observed; @@ -555,25 +1206,45 @@ job_output(struct joblist *jl, struct tool_ctx *ctx, c strlcpy(ctx->artifact_result_id, j->artifact_id, sizeof(ctx->artifact_result_id)); job_result_terminal(j, ctx, is_error); + if (ownership_backlog) { + ctx->artifact_termination = ARTIFACT_TERM_CANCELLED; + *is_error = 1; + } artifact_capture_free(&ctx->artifact_capture); ctx->artifact_capture = j->capture; artifact_capture_init(&j->capture); - adopt = !j->cap_reached && !j->storage_error && - !j->storage_unavailable && (!j->exited || j->fd != -1); + terminal = !adopt; j->cap_reached = 0; j->storage_error = 0; j->storage_unavailable = 0; - j->artifact_available = 0; - j->artifact_managed = 0; - memset(j->artifact_id, 0, sizeof(j->artifact_id)); + if (ownership_backlog && j->artifact_available) { + /* The already-published current key is the bounded settlement + * slot. RELEASE/CANCEL retires it; no replacement is adopted. */ + j->artifact_settling = 1; + } else { + j->artifact_available = 0; + j->artifact_settling = 0; + j->artifact_managed = 0; + j->artifact_correlation = 0; + memset(j->artifact_id, 0, sizeof(j->artifact_id)); + } if (adopt) { ctx->artifact_spool_detach = !j->pending; if (job_adopt_stream(j, ctx) == -1) { job_stop(j); + if (j->fd != -1) { + close(j->fd); + j->fd = -1; + } + j->artifact_available = 0; + j->artifact_settling = 0; + j->artifact_managed = 0; + j->artifact_correlation = 0; ctx->artifact_termination = ARTIFACT_TERM_STORAGE_ERROR; *is_error = 1; + terminal = 1; } } } else { @@ -590,10 +1261,13 @@ job_output(struct joblist *jl, struct tool_ctx *ctx, c "some was dropped]\n"); artifact_capture_reset(&j->capture); j->cap_reached = 0; + terminal = j->exited && j->fd == -1; } - - if (ctx->artifact_mode) + j->reported_terminal = terminal; + if (ctx->artifact_mode) { + job_prune_reported(jl); return; + } if (j->exited) { if (WIFEXITED(j->status)) buf_addf(out, "\n[fugu: job %d exited, status %d]\n", @@ -603,6 +1277,7 @@ job_output(struct joblist *jl, struct tool_ctx *ctx, c id, WTERMSIG(j->status)); } else buf_addf(out, "\n[fugu: job %d running]\n", id); + job_prune_reported(jl); } void @@ -646,22 +1321,114 @@ job_kill_all(struct joblist *jl) } } -void -job_artifact_cancel(struct joblist *jl, const char *id) +int +job_artifact_cancel(struct joblist *jl, int job_id, const char *id) { + struct job_artifact_tombstone *tomb; struct job *j; + int live_match; if (!artifact_id_wire_valid(id)) - return; - for (j = jl->head; j != NULL; j = j->next) { - if (!j->artifact_available || strcmp(j->artifact_id, id) != 0) - continue; + return (-1); + j = job_find(jl, job_id); + live_match = j != NULL && ((j->artifact_available && + strcmp(j->artifact_id, id) == 0) || job_history_has(j, id)); + tomb = job_tombstone_find(jl, job_id, id); + if (!live_match && (tomb == NULL || + (tomb->drop_pending && job_artifact_id_zero(tomb->id)))) + return (-1); + if (live_match) { job_fail_segment(j, 1, 0); - return; + j->artifact_available = 0; + j->artifact_settling = 0; + j->artifact_managed = 0; + j->artifact_correlation = 0; + memset(j->artifact_id, 0, sizeof(j->artifact_id)); + job_history_free(j); + job_tombstones_remove_job(jl, job_id); + job_prune_reported(jl); + return (0); } + job_tombstones_remove_job(jl, job_id); + return (0); } int +job_artifact_release(struct joblist *jl, int job_id, const char *id) +{ + struct job_artifact_tombstone *tomb; + struct job *j; + int rc; + + if (!artifact_id_wire_valid(id)) + return (-1); + j = job_find(jl, job_id); + if (j != NULL && j->artifact_settling && j->artifact_available && + strcmp(j->artifact_id, id) == 0) { + j->artifact_available = 0; + j->artifact_settling = 0; + j->artifact_managed = 0; + j->artifact_correlation = 0; + memset(j->artifact_id, 0, sizeof(j->artifact_id)); + job_prune_reported(jl); + return (0); + } + if (j != NULL && (rc = job_history_remove(j, id)) == 0) { + job_prune_reported(jl); + return (0); + } + tomb = job_tombstone_find(jl, job_id, id); + if (tomb == NULL || tomb->drop_pending) + return (-1); + job_tombstone_remove(jl, tomb); + return (0); +} + +int +job_artifact_drop_next(struct joblist *jl, uint32_t *correlation, + struct artifact_spool_drop *drop) +{ + struct job_artifact_tombstone *tomb; + + if (correlation == NULL || drop == NULL) + return (-1); + LIST_FOREACH(tomb, &jl->tombstones, entry) { + if (!tomb->drop_pending || tomb->drop_sent) + continue; + if (tomb->correlation == 0 || tomb->job_id <= 0) + return (-1); + memset(drop, 0, sizeof(*drop)); + drop->job_id = (uint32_t)tomb->job_id; + strlcpy(drop->artifact_id, tomb->id, + sizeof(drop->artifact_id)); + *correlation = tomb->correlation; + tomb->drop_sent = 1; + return (1); + } + return (0); +} + +int +job_artifact_drop_ack(struct joblist *jl, uint32_t correlation, + const struct artifact_spool_drop *drop) +{ + struct job_artifact_tombstone *tomb; + + if (correlation == 0 || drop == NULL || drop->job_id == 0 || + drop->job_id > INT_MAX || (!artifact_id_wire_valid(drop->artifact_id) && + !job_artifact_id_zero(drop->artifact_id))) + return (-1); + tomb = job_tombstone_find(jl, (int)drop->job_id, drop->artifact_id); + if (tomb == NULL) + return (-1); + if (!tomb->drop_pending || !tomb->drop_sent || + tomb->correlation != correlation) + return (-1); + job_tombstone_remove(jl, tomb); + return (0); +} + +int job_turn_begin(struct joblist *jl) { if (jl->turn_open) @@ -680,6 +1447,7 @@ job_turn_commit(struct joblist *jl) for (j = jl->head; j != NULL; j = j->next) j->pending = 0; jl->turn_open = 0; + job_prune_reported(jl); return (0); } @@ -715,6 +1483,7 @@ job_turn_abort(struct joblist *jl) job_spool_close(j); artifact_capture_free(&j->capture); buf_free(&j->pending_write); + job_history_free(j); *link = j->next; free(j); } @@ -726,6 +1495,7 @@ void job_list_free(struct joblist *jl) { struct job *j, *n; + struct job_artifact_tombstone *tomb; if (jl == NULL) return; @@ -739,7 +1509,12 @@ job_list_free(struct joblist *jl) job_spool_close(j); artifact_capture_free(&j->capture); buf_free(&j->pending_write); + job_history_free(j); free(j); } + while ((tomb = LIST_FIRST(&jl->tombstones)) != NULL) { + LIST_REMOVE(tomb, entry); + free(tomb); + } free(jl); } blob - 725cc0a2c5451c676c51809104829f76d81ed03f blob + 8e3b9e77a89f6c8b6d7171161b8dbd1721937fad --- src/fugu-tool/job.h +++ src/fugu-tool/job.h @@ -17,7 +17,10 @@ #ifndef JOB_H #define JOB_H +#include + #include "buf.h" +#include "proto.h" /* * Background shell jobs (behavior.md section 5): shell_bg starts a @@ -37,6 +40,9 @@ void job_list_free(struct joblist *); /* the max_bg_jobs gate; 0 (the default here) means unconfigured */ void job_set_cap(struct joblist *, int cap); +/* Regression-only bound selected by fugu-tool under guarded selftest mode. */ +void job_selftest_set_ownership_limit(struct joblist *, size_t); +int job_selftest_archive_completed(struct joblist *); void job_start(struct joblist *, struct tool_ctx *, const struct json *, int obj, struct buf *out, int *is_error); @@ -47,7 +53,12 @@ void job_kill(struct joblist *, const struct json *, /* drain every running job's pipe into its ring (called opportunistically) */ void job_pump(struct joblist *); -void job_artifact_cancel(struct joblist *, const char *); +int job_artifact_cancel(struct joblist *, int, const char *); +int job_artifact_release(struct joblist *, int, const char *); +int job_artifact_drop_next(struct joblist *, uint32_t *, + struct artifact_spool_drop *); +int job_artifact_drop_ack(struct joblist *, uint32_t, + const struct artifact_spool_drop *); /* kill and reap every job (turn abandon, session end) */ void job_kill_all(struct joblist *); @@ -56,4 +67,37 @@ int job_turn_begin(struct joblist *); int job_turn_commit(struct joblist *); int job_turn_abort(struct joblist *); +#ifdef JOB_TESTING +struct job_test_state { + uint64_t observed; + uint64_t retained; + size_t pending_bytes; + int cap_reached; + int exited; + int producer_open; + int artifact_open; + int storage_error; + int storage_unavailable; +}; + +void job_test_backpressure_at_boundary(void); +int job_test_boundary_pending(struct joblist *, int); +int job_test_storage_unavailable(struct joblist *, int); +size_t job_test_pending_bytes(struct joblist *, int); +size_t job_test_output_bytes(struct joblist *, int); +void job_test_set_pump_limits(size_t, size_t); +void job_test_reset_pump_limits(void); +size_t job_test_pump_per_job_limit(void); +size_t job_test_pump_total_limit(void); +size_t job_test_last_pump_total(struct joblist *); +size_t job_test_last_pump_bytes(struct joblist *, int); +int job_test_state(struct joblist *, int, struct job_test_state *); +void job_test_set_record_limit(struct joblist *, size_t); +void job_test_set_tombstone_limit(struct joblist *, size_t); +size_t job_test_record_count(struct joblist *); +size_t job_test_tombstone_count(struct joblist *); +void job_test_set_ownership_limit(struct joblist *, size_t); +size_t job_test_ownership_count(struct joblist *); +#endif + #endif /* JOB_H */ blob - 7836e330da2f4510de02c7738bde3617d62e0c1d blob + 20e8d5ffe78a9765287b58c24e371c50dba899ef --- src/fugu-tool/main.c +++ src/fugu-tool/main.c @@ -39,6 +39,7 @@ #include #include #include +#include #include #include "log.h" @@ -50,10 +51,18 @@ #include "tools.h" #include "agent.h" #include "artifact_result.h" +#include "artifact_pipe.h" +#include "artifact_scope.h" static int readonly; /* -r: the subagent role, always read-only */ +static long long selftest_rotation_delay_ms; +static long long selftest_drop_delay_ms; +static int selftest_archive_pending; +static int selftest_rotation_trace; +static int selftest_drop_trace; struct tool_state { + struct worker *worker; struct tool_ctx *ctx; char name[FUGU_TOOL_NAME_MAX]; int have_name; @@ -72,7 +81,82 @@ struct tool_state { * a clean shutdown that would otherwise orphan them into init. */ static struct joblist *jobs_atexit; +static long long +selftest_delay(const char *name) +{ + const char *value, *errstr; + long long ms; + + if (issetugid() || getenv("FUGU_SELFTEST") == NULL || + (value = getenv(name)) == NULL) + return (0); + ms = strtonum(value, 1, 5000, &errstr); + if (errstr != NULL) + fatalx("fugu-tool: invalid %s", name); + return (ms); +} + +static int +selftest_flag(const char *name) +{ + const char *value; + + if (issetugid() || getenv("FUGU_SELFTEST") == NULL || + (value = getenv(name)) == NULL) + return (0); + if (strcmp(value, "1") != 0) + fatalx("fugu-tool: invalid %s", name); + return (1); +} + static void +selftest_pause(long long *milliseconds, const char *marker) +{ + struct timespec delay; + long long ms; + + if ((ms = *milliseconds) == 0) + return; + *milliseconds = 0; + log_warnx("%s", marker); + delay.tv_sec = (time_t)(ms / 1000); + delay.tv_nsec = (long)(ms % 1000) * 1000000L; + (void)nanosleep(&delay, NULL); +} + +static int +spool_drop_id_valid(const char id[FUGU_ARTIFACT_ID_BUFSZ]) +{ + static const char zero[FUGU_ARTIFACT_ID_BUFSZ]; + + return (artifact_id_wire_valid(id) || + memcmp(id, zero, sizeof(zero)) == 0); +} + +static void +send_spool_drops(struct worker *w, struct tool_state *ts) +{ + struct artifact_spool_drop drop; + uint32_t correlation; + int rc; + + for (;;) { + correlation = 0; + memset(&drop, 0, sizeof(drop)); + rc = job_artifact_drop_next(ts->ctx->jobs, &correlation, &drop); + if (rc == 0) + return; + if (rc == -1 || correlation == 0 || drop.job_id == 0 || + !spool_drop_id_valid(drop.artifact_id)) + fatalx("fugu-tool: invalid Artifact spool drop"); + imsgev_send(&w->iev, FUGU_IMSG_TOOL_ARTIFACT_DROP, correlation, + &drop, sizeof(drop)); + selftest_pause(&selftest_drop_delay_ms, + "fugu-tool: selftest Artifact DROP queued"); + } +} + +static void terminate_signal(int sig) { (void)sig; @@ -100,8 +184,13 @@ pump_jobs(int fd, short ev, void *arg) (void)fd; (void)ev; - if (ts->ctx != NULL) + if (ts->ctx != NULL) { job_pump(ts->ctx->jobs); + if (selftest_archive_pending && + job_selftest_archive_completed(ts->ctx->jobs) == 1) + selftest_archive_pending = 0; + send_spool_drops(ts->worker, ts); + } } /* @@ -340,7 +429,10 @@ tool_lockdown(struct worker *w, struct imsg *imsg) } ts->ctx = tool_ctx_new(write_ok, net_ok); + ts->worker = w; job_set_cap(ts->ctx->jobs, (int)ts->cap); + if (selftest_rotation_delay_ms > 0) + job_selftest_set_ownership_limit(ts->ctx->jobs, 1); /* kill our background jobs when we exit, and keep their output * draining between polls (event_init ran before this dispatch) */ @@ -377,6 +469,43 @@ send_result(struct worker *w, uint32_t id, const struc sizeof(*result)); } +/* Publish every completed shell_output freeze independently of the ordinary + * result. replacement_adopted distinguishes a swap from a terminal/error + * freeze; no frame means the Tool call did not mutate spool ownership. The + * Coordinator may abandon the ordinary result after SIGUSR1, but this frame + * still proves exactly which old ownership was frozen. */ +static void +send_spool_rotation(struct worker *w, const struct tool_state *ts, uint32_t id) +{ + struct artifact_spool_rotate rotate; + + if (!ts->ctx->artifact_captured || ts->ctx->artifact_job_id <= 0) + return; + if (id == 0) + fatalx("fugu-tool: invalid Artifact spool rotation"); + if (!ts->ctx->artifact_spool_adopted && ts->ctx->artifact_spool_detach) + fatalx("fugu-tool: detached unadopted Artifact spool"); + memset(&rotate, 0, sizeof(rotate)); + rotate.job_id = (uint32_t)ts->ctx->artifact_job_id; + rotate.replacement_adopted = ts->ctx->artifact_spool_adopted; + rotate.detach = ts->ctx->artifact_spool_detach; + if (ts->ctx->artifact_result_available) + strlcpy(rotate.frozen_id, ts->ctx->artifact_result_id, + sizeof(rotate.frozen_id)); + if (ts->ctx->artifact_spool_adopted && ts->ctx->artifact_available) + strlcpy(rotate.replacement_id, ts->artifact.id, + sizeof(rotate.replacement_id)); + if (selftest_rotation_delay_ms > 0) { + imsgev_send_blocking(&w->iev, FUGU_IMSG_TOOL_ARTIFACT_ROTATE, id, + &rotate, sizeof(rotate)); + selftest_pause(&selftest_rotation_delay_ms, + "fugu-tool: selftest frame0 Artifact rotation published"); + return; + } + imsgev_send(&w->iev, FUGU_IMSG_TOOL_ARTIFACT_ROTATE, id, &rotate, + sizeof(rotate)); +} + /* Replace raw in-scope bytes with the single generated provider document. * artifact_result emits id first exactly when the staging object is needed. */ static void @@ -396,6 +525,7 @@ serialize_artifact_result(struct tool_state *ts, struc result->is_error = *is_error; result->spool_adopted = 1; result->spool_detach = ts->ctx->artifact_spool_detach; + result->background_job_id = ts->ctx->artifact_job_id; if (ts->ctx->artifact_available) strlcpy(result->artifact_id, ts->artifact.id, sizeof(result->artifact_id)); @@ -416,7 +546,10 @@ serialize_artifact_result(struct tool_state *ts, struc memset(&in, 0, sizeof(in)); in.raw = NULL; in.raw_len = ts->ctx->artifact_raw_len; - in.id = ts->ctx->artifact_captured ? + /* A foreground capture writes the stream brokered for this call. A + * shell_output capture instead describes the frozen prior segment and + * therefore uses only that job's recorded result id. */ + in.id = ts->ctx->artifact_captured && ts->ctx->artifact_job_id > 0 ? (ts->ctx->artifact_result_available ? ts->ctx->artifact_result_id : NULL) : (ts->ctx->artifact_available ? ts->artifact.id : NULL); @@ -441,8 +574,11 @@ serialize_artifact_result(struct tool_state *ts, struc result->artifact = 1; result->keep_artifact = keep; result->artifact_cap_reached = ts->ctx->artifact_cap_reached; + result->spool_frozen = ts->ctx->artifact_captured && + ts->ctx->artifact_job_id > 0; result->spool_adopted = ts->ctx->artifact_spool_adopted; result->spool_detach = ts->ctx->artifact_spool_detach; + result->background_job_id = ts->ctx->artifact_job_id; result->termination = ts->ctx->artifact_termination; result->observed_bytes = ts->ctx->artifact_observed; result->retained_bytes = ts->ctx->artifact_raw_len; @@ -464,10 +600,10 @@ tool_dispatch(struct worker *w, struct imsg *imsg) switch (imsg_get_type(imsg)) { case FUGU_IMSG_TOOL_ARTIFACT_STREAM: { struct artifact_stream stream; - struct stat st; - int fd = -1, flags, fdflags; + int fd = -1; - if (readonly || ts->agent || ts->have_artifact || + if (readonly || ts->agent || ts->have_artifact || ts->have_name || + ts->arg.len != 0 || imsg_get_id(imsg) == 0) fatalx("fugu-tool: unexpected Artifact stream"); if (imsgev_get_fd_payload(imsg, &fd, &payload) == -1) { @@ -484,11 +620,7 @@ tool_dispatch(struct worker *w, struct imsg *imsg) memcpy(&stream, p, sizeof(stream)); if ((stream.status == ARTIFACT_STREAM_AVAILABLE && (fd == -1 || !artifact_id_wire_valid(stream.id) || - fstat(fd, &st) == -1 || !S_ISFIFO(st.st_mode) || - (flags = fcntl(fd, F_GETFL)) == -1 || - flags != O_WRONLY || - (fdflags = fcntl(fd, F_GETFD)) == -1 || - (fdflags & FD_CLOEXEC) == 0)) || + !artifact_producer_endpoint_valid(fd))) || (stream.status == ARTIFACT_STREAM_UNAVAILABLE && (fd != -1 || memcmp(stream.id, (char[FUGU_ARTIFACT_ID_BUFSZ]){0}, sizeof(stream.id)) != 0)) || @@ -558,32 +690,72 @@ tool_dispatch(struct worker *w, struct imsg *imsg) sizeof(ack)); return (0); } - case FUGU_IMSG_TOOL_ARTIFACT_CANCEL: { + case FUGU_IMSG_TOOL_ARTIFACT_DROP_ACK: { + struct artifact_spool_drop drop; + uint32_t correlation = imsg_get_id(imsg); + + if (readonly || ts->agent || correlation == 0 || + imsgev_get_payload(imsg, &payload) == -1 || + payload.data == NULL || payload.len != sizeof(drop)) + fatalx("fugu-tool: bad Artifact drop acknowledgement"); + memcpy(&drop, payload.data, sizeof(drop)); + if (job_artifact_drop_ack(ts->ctx->jobs, correlation, &drop) == -1) + fatalx("fugu-tool: stale Artifact drop acknowledgement"); + return (0); + } + case FUGU_IMSG_TOOL_ARTIFACT_CANCEL: + case FUGU_IMSG_TOOL_ARTIFACT_RELEASE: { + struct artifact_job_cancel_ack ack; struct artifact_record record; + uint32_t type = imsg_get_type(imsg), job_id = imsg_get_id(imsg); + int rc; - if (readonly || ts->agent || imsg_get_id(imsg) != 0 || + if (readonly || ts->agent || job_id == 0 || job_id > INT_MAX || imsgev_get_payload(imsg, &payload) == -1 || payload.data == NULL || payload.len != sizeof(record)) fatalx("fugu-tool: bad Artifact cancellation"); memcpy(&record, payload.data, sizeof(record)); if (!artifact_id_wire_valid(record.id)) fatalx("fugu-tool: malformed Artifact cancellation id"); - job_artifact_cancel(ts->ctx->jobs, record.id); + rc = type == FUGU_IMSG_TOOL_ARTIFACT_CANCEL ? + job_artifact_cancel(ts->ctx->jobs, (int)job_id, record.id) : + job_artifact_release(ts->ctx->jobs, (int)job_id, record.id); + if (rc == -1) + fatalx("fugu-tool: stale Artifact job ownership"); + if (type == FUGU_IMSG_TOOL_ARTIFACT_CANCEL) { + memset(&ack, 0, sizeof(ack)); + ack.job_id = job_id; + strlcpy(ack.artifact_id, record.id, + sizeof(ack.artifact_id)); + imsgev_send(&w->iev, FUGU_IMSG_TOOL_ARTIFACT_CANCEL_ACK, + job_id, &ack, sizeof(ack)); + if (selftest_drop_trace) + log_warnx("fugu-tool: selftest Artifact CANCEL_ACK " + "job=%u", job_id); + } else if (selftest_rotation_trace) { + log_warnx("fugu-tool: selftest Artifact RELEASE job=%u", + job_id); + } return (0); } case FUGU_IMSG_TOOL_REQ: { struct tool_req req; + int scoped; if (imsgev_get_payload(imsg, &payload) == -1) fatal("get TOOL_REQ payload"); p = payload.data; len = payload.len; - if (p == NULL || len != sizeof(req) || - (ts->have_artifact && imsg_get_id(imsg) != ts->artifact_id) || - (!ts->have_artifact && imsg_get_id(imsg) != 0)) + if (p == NULL || len != sizeof(req) || ts->have_name || + ts->arg.len != 0) fatalx("fugu-tool: short TOOL_REQ"); memcpy(&req, p, sizeof(req)); req.name[sizeof(req.name) - 1] = '\0'; + scoped = artifact_capture_tool_name(req.name); + if ((!readonly && !ts->agent && !!ts->have_artifact != !!scoped) || + (ts->have_artifact && imsg_get_id(imsg) != ts->artifact_id) || + (!ts->have_artifact && imsg_get_id(imsg) != 0)) + fatalx("fugu-tool: Artifact scope mismatch"); strlcpy(ts->name, req.name, sizeof(ts->name)); ts->have_name = 1; buf_reset(&ts->arg); @@ -664,6 +836,8 @@ tool_dispatch(struct worker *w, struct imsg *imsg) ts->ctx->artifact_available = ts->have_artifact && ts->artifact.status == ARTIFACT_STREAM_AVAILABLE; ts->ctx->artifact_id = ts->have_artifact ? ts->artifact.id : NULL; + ts->ctx->artifact_correlation = ts->have_artifact ? + ts->artifact_id : 0; ts->ctx->artifact_observed = 0; ts->ctx->artifact_raw_len = 0; ts->ctx->artifact_termination = ARTIFACT_TERM_EXIT; @@ -673,9 +847,11 @@ tool_dispatch(struct worker *w, struct imsg *imsg) ts->ctx->artifact_captured = 0; tool_exec(ts->ctx, ts->name, ts->arg.data, ts->arg.len, &out, &is_error); - if (ts->have_artifact) + send_spool_drops(w, ts); + if (ts->have_artifact) { + send_spool_rotation(w, ts, ts->artifact_id); serialize_artifact_result(ts, &out, &is_error, &result); - else + } else result.is_error = is_error; ts->artifact_fd = ts->ctx->artifact_fd; if (ts->artifact_fd != -1) { @@ -702,6 +878,7 @@ tool_dispatch(struct worker *w, struct imsg *imsg) ts->ctx->artifact_mode = 0; ts->ctx->artifact_available = 0; ts->ctx->artifact_id = NULL; + ts->ctx->artifact_correlation = 0; return (0); } default: @@ -723,10 +900,19 @@ main(int argc, char *argv[]) sigemptyset(&sa.sa_mask); if (sigaction(SIGTERM, &sa, NULL) == -1) fatal("sigaction SIGTERM"); + if (sigaction(SIGUSR1, &sa, NULL) == -1) + fatal("sigaction SIGUSR1"); buf_init(&ts.arg); ts.artifact_fd = -1; agent_boot_init(&ts.boot); + selftest_rotation_delay_ms = selftest_delay( + "FUGU_ARTIFACT_FRAME0_DELAY_MS"); + selftest_drop_delay_ms = selftest_delay("FUGU_ARTIFACT_DROP_DELAY_MS"); + selftest_archive_pending = selftest_flag( + "FUGU_ARTIFACT_ARCHIVE_COMPLETED"); + selftest_rotation_trace = selftest_rotation_delay_ms > 0; + selftest_drop_trace = selftest_drop_delay_ms > 0; /* * Transitional pledge. It is a superset of the steady promises; blob - b1cfd11bd33072fd502be44807cdb2d017a74aaf blob + 8091e3a43e76e43139bbe49a9f1cfb6292913d93 --- src/fugu-tool/tools.c +++ src/fugu-tool/tools.c @@ -781,23 +781,50 @@ deadline_ms(const struct timespec *deadline) #define CAP_ARTIFACT_CAP (-5) static volatile sig_atomic_t active_pgid; +static volatile sig_atomic_t active_cancelled; -static int -artifact_write_all(int fd, const void *data, size_t len) +enum artifact_write_result { + ARTIFACT_WRITE_OK = 0, + ARTIFACT_WRITE_ERROR, + ARTIFACT_WRITE_TIMEOUT, + ARTIFACT_WRITE_CANCELLED +}; + +static enum artifact_write_result +artifact_write_all(int fd, const void *data, size_t len, + const struct timespec *deadline) { const u_char *p = data; + struct pollfd pfd; ssize_t n; while (len > 0) { - do { - n = write(fd, p, len); - } while (n == -1 && errno == EINTR); - if (n <= 0) - return (-1); - p += n; - len -= (size_t)n; + if (active_cancelled) + return (ARTIFACT_WRITE_CANCELLED); + n = write(fd, p, len); + if (n > 0) { + p += n; + len -= (size_t)n; + continue; + } + if (n == -1 && errno == EINTR) + continue; + if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + int left = deadline_ms(deadline); + + if (left == 0) + return (ARTIFACT_WRITE_TIMEOUT); + pfd.fd = fd; + pfd.events = POLLOUT; + if (left > 100) + left = 100; + if (poll(&pfd, 1, left) == -1 && errno != EINTR) + return (ARTIFACT_WRITE_ERROR); + continue; + } + return (ARTIFACT_WRITE_ERROR); } - return (0); + return (ARTIFACT_WRITE_OK); } void @@ -805,10 +832,17 @@ tool_cancel_active(void) { pid_t pid = (pid_t)active_pgid; + active_cancelled = 1; if (pid > 0) (void)kill(-pid, SIGKILL); } +int +tool_cancel_requested(void) +{ + return (active_cancelled != 0); +} + /* Reap and clear the published process group with SIGTERM blocked, so a * teardown signal can never target a pid after its group leader was reaped * and made available for reuse. */ @@ -821,6 +855,7 @@ wait_active_child(struct supervised_proc *sp, int *sta sigemptyset(&block); sigaddset(&block, SIGTERM); + sigaddset(&block, SIGUSR1); if (sigprocmask(SIG_BLOCK, &block, &oldmask) == -1) return (-1); r = waitpid(sp->pid, &supervisor_status, 0); @@ -854,6 +889,7 @@ reap_active_group(struct supervised_proc *sp, int *sta sigemptyset(&block); sigaddset(&block, SIGTERM); + sigaddset(&block, SIGUSR1); if (sigprocmask(SIG_BLOCK, &block, &oldmask) == -1) return (-1); memset(&si, 0, sizeof(si)); @@ -908,12 +944,14 @@ run_capture(struct tool_ctx *ctx, char *const argv[], pid_t pid, pgid; int pipefd[2], status = 0, reaped = 0; int timed_out = 0, truncated = 0, storage_error = 0; + int flags; size_t capture_cap = ctx->artifact_mode ? ARTIFACT_RAW_MAX : CAPTURE_CAP; int tmo = ctx->shell_timeout_ms > 0 ? ctx->shell_timeout_ms : SHELL_TIMEOUT; *sig = 0; + active_cancelled = 0; if (pipe(pipefd) == -1) { result_err(out, is_error, "pipe: %s", strerror(errno)); return (CAP_SPAWNERR); @@ -926,8 +964,17 @@ run_capture(struct tool_ctx *ctx, char *const argv[], close(pipefd[1]); return (CAP_SPAWNERR); } + if (ctx->artifact_mode && ctx->artifact_available && + ((flags = fcntl(ctx->artifact_fd, F_GETFL)) == -1 || + fcntl(ctx->artifact_fd, F_SETFL, flags | O_NONBLOCK) == -1)) { + result_err(out, is_error, "artifact fcntl: %s", strerror(errno)); + close(pipefd[0]); + close(pipefd[1]); + return (CAP_STORAGE); + } sigemptyset(&block); sigaddset(&block, SIGTERM); + sigaddset(&block, SIGUSR1); if (sigprocmask(SIG_BLOCK, &block, &oldmask) == -1) { result_err(out, is_error, "sigprocmask: %s", strerror(errno)); close(pipefd[0]); @@ -959,6 +1006,7 @@ run_capture(struct tool_ctx *ctx, char *const argv[], for (;;) { u_char chunk[16384]; ssize_t n; + size_t want = sizeof(chunk); int left = deadline_ms(&deadline), w; w = 0; @@ -991,7 +1039,15 @@ run_capture(struct tool_ctx *ctx, char *const argv[], goto capture_done; continue; } - n = read(pipefd[0], chunk, sizeof(chunk)); + if (ctx->artifact_mode) { + want = artifact_capture_read_limit(&ctx->artifact_capture, + want); + if (want == 0) { + truncated = 1; + break; + } + } + n = read(pipefd[0], chunk, want); if (n == -1) { if (errno == EAGAIN || errno == EINTR) continue; @@ -1014,9 +1070,13 @@ run_capture(struct tool_ctx *ctx, char *const argv[], (size_t)ctx->artifact_capture.retained; if (ctx->artifact_available && ctx->artifact_capture.retained > before && - artifact_write_all(ctx->artifact_fd, chunk, - (size_t)ctx->artifact_capture.retained - before) == -1) { - storage_error = 1; + (w = artifact_write_all(ctx->artifact_fd, chunk, + (size_t)ctx->artifact_capture.retained - before, + &deadline)) != ARTIFACT_WRITE_OK) { + if (w == ARTIFACT_WRITE_TIMEOUT) + timed_out = 1; + else if (w == ARTIFACT_WRITE_ERROR) + storage_error = 1; break; } if (capture_rc == 1) { @@ -1351,12 +1411,14 @@ tool_exec(struct tool_ctx *ctx, const char *name, cons int obj; *is_error = 0; + active_cancelled = 0; buf_reset(out); if (ctx->artifact_mode) { artifact_capture_reset(&ctx->artifact_capture); ctx->artifact_spool_adopted = 0; ctx->artifact_spool_detach = 0; ctx->artifact_result_available = 0; + ctx->artifact_job_id = 0; memset(ctx->artifact_result_id, 0, sizeof(ctx->artifact_result_id)); ctx->artifact_observed = 0; blob - 6c12b8371e00e5da0223343f26eeab559cd28c55 blob + 34d0433d53ebf94228f96040f1f521cf5a49d667 --- src/fugu-tool/tools.h +++ src/fugu-tool/tools.h @@ -47,6 +47,7 @@ struct tool_ctx { int artifact_available; int artifact_fd; const char *artifact_id; + uint32_t artifact_correlation; uint64_t artifact_observed; size_t artifact_raw_len; enum artifact_termination artifact_termination; @@ -59,6 +60,7 @@ struct tool_ctx { int artifact_spool_detach; int artifact_result_available; char artifact_result_id[ARTIFACT_ID_HEX_LEN + 1]; + int artifact_job_id; }; struct tool_ctx *tool_ctx_new(int allow_write, int allow_subprocess_net); @@ -79,6 +81,7 @@ int tool_known(const char *name); /* SIGTERM teardown hook: kill a synchronous subprocess group, if any. */ void tool_cancel_active(void); +int tool_cancel_requested(void); /* * Project context (behavior.md 4), read and written in the executor