plans: row 87 round 6 gives the store one real-path spelling, starts each core fork on its first line and states the name-conflict refusal (#1581)

Answers Darkwing's round 5 review (28880) and Filbert's doc-only
follow-ups (28881).

- connected follows the channel from the wrapper's creation, as a forked
  child's does; the entry starts the broker on boot and the notifier on
  start, so host.mjs:217 and :244 never wait on a pid line.
- Bus start makes <dataRoot>/bus at 0700 and takes its real path; the
  bind source, mosaic.store label, leftover filter and hostStatus all use
  it.
- A name conflict refuses with exit 3, names the container and the
  client's exit code, gives host.lock back, and removes nothing.
- The host.pids test list names what each test does with the pid.
- I6 gains four lifecycle tests for these.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
jason.woltjeandClaude Opus 5.5 committed 2026-10-18 09:23:53 -05:00
1 parent b04014e592
commit 80ebe2afc4
2 files changed
+86 -35

No files matched your search

@@ -8,7 +8,8 @@ Filbert's review (comment 28851) and Darkwing's (comment 28852). Round 3
answers their round 2 reviews (Darkwing 28863, Filbert 28864). Round 4
answers their round 3 reviews (Darkwing 28871, Filbert 28870). Round 5
answers Darkwing's round 4 review (28877) and Filbert's doc-only
follow-up on the stop timeout (28876).
follow-up on the stop timeout (28876). Round 6 answers Darkwing's round
5 review (28880) and Filbert's doc-only follow-ups (28881).
This doc turns decisions 105 to 108 into interfaces. It doesn't reopen
them. Every claim about container behavior cites a spike in
@@ -150,18 +151,39 @@ fork's exit code (`host.mjs:341-347`), and the start failure path kills
the notifier alone (`host.mjs:252-257`). Containerized mode keeps all of
that by putting each fork's events on the channel.
- **The store path.** Before anything else touches the engine, bus
start makes `<dataRoot>/bus` if it is missing, mode 0700, the way the
broker's `Store` does today (`packages/bus/src/store.mjs:126`), which
only the broker does now. The host makes only `bus-host/`
(`host.mjs:80`, `:120`), so on a fresh install nothing else would. Bus
start then takes the real path of `<dataRoot>/bus`. That one string,
the store path, is the store's only spelling in containerized mode. It
is the source of the store's bind mount, the `mosaic.store` label, the
leftover rule's filter and `hostStatus`'s filter. `Store`'s `safe()`
checks only the leaf, so a data root under a symlinked parent has two
spellings. With the real path everywhere, both spellings reach the
same name and the same label.
- **Name and labels.** The host runs the core container with
`--name mosaic-core-<store hash>`, `--label mosaic.business=<business>`,
`--label mosaic.kind=core`, `--label mosaic.store=<dataRoot>/bus` and
`--label mosaic.kind=core`, `--label mosaic.store=<store path>` and
`--rm`. `<store hash>` is the first 16 hex digits of the SHA-256 of
the real path of `<dataRoot>/bus`. The name is keyed on the store, as
`writer.lock` is, not on the business: two data roots that run the
same business get two names. The `packages/cli` suite runs its test
files in parallel, and many of them use the business `acme`, each with
its own data root. The fixed name means the engine refuses a second
core container for the store while one exists in any state. The host
reads the forks' exit codes from the channel, not from the container,
so `--rm` loses nothing it reads.
the store path. The name is keyed on the store, as `writer.lock` is,
not on the business: two data roots that run the same business get two
names. The `packages/cli` suite runs its test files in parallel, and
many of them use the business `acme`, each with its own data root. The
fixed name means the engine refuses a second core container for the
store while one exists in any state. The host reads the forks' exit
codes from the channel, not from the container, so `--rm` loses
nothing it reads.
- **A name conflict.** The leftover rule below runs first, so a conflict
means a container with the name appeared after the list, or one the
engine is still removing. The `docker run` client then exits before
any line, and the engine's error goes to the host log through the
client's inherited stderr. The host doesn't parse that text. Bus start
refuses with exit 3, `core container mosaic-core-<store hash> did not
start (docker run exited <code>); see the host log`, gives `host.lock`
back and starts nothing else. It never removes a container it didn't
list under the store's labels.
- **The close timeout.** `close()` waits `CLOSE_TIMEOUT_MS` (20 s,
`host.mjs:30`) for the notifier, then again for the broker
(`host.mjs:343`, `:347`). So stopping both forks the way `close()`
@@ -175,11 +197,16 @@ that by putting each fork's events on the channel.
or `notifier` and `msg` is the IPC message today's host sends, or
`{to, signal}`, which the entry delivers as `kill(signal)` on that
fork. A line to the host is `{from, msg}` for an IPC reply, or
`{from, exit: {code, signal}}` once that fork has exited. Before
either, the entry writes `{from, pid}` once the fork has started,
where `pid` is in the container's pid namespace. The host wraps each
fork in an object with `send`, `kill`, `connected`, `exitCode`,
`signalCode` and an `exit` event driven by those lines. `firstReply`
`{from, exit: {code, signal}}` once that fork has exited. The entry
starts a fork when the first line for it arrives: the broker on
`boot`, the notifier on `start`. It passes that line to the fork as its
first IPC message, so a line never waits for a fork that isn't there.
No notifier line means no notifier fork, as today when the business
has no notifier. Once a fork has started, the entry writes `{from,
pid}` for it, before any other line from that fork, where `pid` is in
the container's pid namespace. The host wraps each fork in an object
with `send`, `kill`, `connected`, `exitCode`, `signalCode` and an
`exit` event driven by those lines. `firstReply`
(`host.mjs:129`), `ended` (`:148`), `watchChildren` (`:159-165`) and
`close()` keep their logic:
- `close()` sends `{op: 'stop'}` to the notifier and `{op: 'close'}`
@@ -187,31 +214,40 @@ that by putting each fork's events on the channel.
line;
- the start failure path's `notify.kill('SIGTERM')` becomes
`{to: 'notifier', signal: 'SIGTERM'}`.
- **`connected`.** A wrapper's `connected` turns true on the fork's
`{from, pid}` line. It turns false, and never back, on the first of
three events: the fork's exit line, the client's stdout closing or the
client exiting, or a write to the client's stdin failing. Once it is
false, `send` and `kill` write nothing and return false. The host's
`connected` checks before a send (`host.mjs:256`, `:342`, `:346`)
therefore skip a fork whose channel is gone, as they do today when a
child has disconnected.
- **`connected`.** A wrapper's `connected` follows the channel, as a
forked child's does today: it is true from the moment the host makes
the wrapper. The broker's wrapper is made when the `docker run` client
starts, and the notifier's where `host.mjs:242` forks today. The host
sends `boot` and `start` right after (`host.mjs:217`, `:244`), and
those writes go through, because the entry starts the fork on that
line. The `{from, pid}` line changes nothing in `connected`; it gives
the log its pid. `connected` turns false, and never back, on the
first of three events: the fork's exit line, the client's stdout
closing or the client exiting, or a write to the client's stdin
failing. Once it is false, `send` and `kill` write nothing and return
false. The host's `connected` checks before a send (`host.mjs:256`,
`:342`, `:346`) therefore skip a fork whose channel is gone, as they
do today when a child has disconnected.
- **`host.pids`.** In containerized mode both entries are null, and the
object adds `container`, the core container's name. The host doesn't
know a fork's pid in its own namespace. A container pid passed to
`process.kill` would signal an unrelated host process. The tests that
signal a fork by pid (`host.test.mjs:227`, `:546-563`, `:612`) stay in
local mode, where the host logic they test is unchanged. The host logs
`process.kill` would signal an unrelated host process. Four tests use
`host.pids` as host pids: `host.test.mjs:202-205` reads each pid's
`/proc` cmdline and environ, `:227` sends the notifier SIGKILL,
`:546-563` sends the broker SIGSTOP and SIGCONT, and `:612` finds the
broker's child process by pid to send it IPC. They stay in local mode,
where the host logic they test is unchanged. The host logs
each fork's container pid from its `{from, pid}` line. I6's lifecycle
tests reach a fork with
`docker exec <container> kill -<signal> <pid>`, using the pid from
that log line.
- **One fork exits alone.** The entry writes that fork's exit line, then
stops the other the way `close()` does, waits up to the host's close
timeout, sends SIGKILL if it hasn't exited by then, writes its exit
line and exits 1. The host gets the first exit line, logs
stops the other, if it started, the way `close()` does, waits up to the
host's close timeout, sends SIGKILL if it hasn't exited by then, writes
its exit line and exits 1. The host gets the first exit line, logs
"`<name>` exited", and stops with exit 1, as `host.mjs:355-364` does.
When the host stops both forks, the entry exits 0 once both have
exited 0, and 1 otherwise.
When the host stops the forks, the entry exits 0 once each fork it
started has exited 0, and 1 otherwise.
- **The client ends first.** If the `docker run` client exits, or its
stdout closes, before a fork's exit line arrives, the host treats that
fork as exited with signal `SIGKILL` and stops with exit 1.
@@ -229,7 +265,7 @@ that by putting each fork's events on the channel.
design doesn't rely on them, because of the next rule.
- **Leftovers at bus start.** After `host.lock` is taken and before the
core container starts, bus start lists containers labelled
`mosaic.kind=core` and `mosaic.store=<dataRoot>/bus` through the
`mosaic.kind=core` and `mosaic.store=<store path>` through the
endpoint helper. It runs `docker stop -t <core stop timeout>` on each,
45 s at today's value, which covers both of the entry's close waits,
then `docker rm -f` on any that `--rm` hasn't removed, and logs each to
@@ -247,7 +283,7 @@ that by putting each fork's events on the channel.
broker would show as "a broker died hard". In containerized mode
`hostStatus` ignores the lock's pid and asks the engine instead. The
lock is live when a container labelled
`mosaic.store=<dataRoot>/bus` and `mosaic.kind=core` is running, and
`mosaic.store=<store path>` and `mosaic.kind=core` is running, and
dead when none is. If the endpoint doesn't answer, it reports the
lock as unknown, never dead. The `socket:` line checks `sock/agent`
and `sock/human` (section 5) instead of `bus/broker.sock`. I6 owns
@@ -403,8 +403,9 @@ and no document states the limits in design section 10.
and `packages/cli/src/notifier-process.mjs` don't change.
- `packages/cli/src/host.mjs` for the channel's fork wrapper, the
exported close timeout and the core stop timeout, `host.pids` in
containerized mode, the leftover removal at bus start and
`hostStatus`, and the `bus status`
containerized mode, the store path (making `<dataRoot>/bus` and
resolving it), the name-conflict refusal, the leftover removal at bus
start and `hostStatus`, and the `bus status`
lines in `packages/cli/src/cli.mjs` (design section 3, "The core
container's lifecycle").
- `compose.yaml`, `scripts/run-task.sh` and the worker adapter files it
@@ -475,6 +476,20 @@ and no document states the limits in design section 10.
- the entry refuses to start without `MOSAIC_CLOSE_TIMEOUT_MS`;
- a wrapper's `connected` turns false after the fork's exit line
and after the client exits, and a later `send` writes nothing;
- `boot`, sent right after the client starts and before any `{from,
pid}` line, reaches the broker and gets its reply well inside
`BOOT_TIMEOUT_MS`; a business without a notifier gets no notifier
fork, and close still returns 0;
- a data root under a symlinked parent: the core container's
`mosaic.store` label is the real path, and a leftover started
under the other spelling is found, stopped and logged at the next
bus start;
- on a fresh data root, bus start makes `<dataRoot>/bus` at 0700
before it resolves the store path;
- a container that already holds the core name but not the store's
labels makes bus start refuse with exit 3 and the message naming
the name and the client's exit code; `host.lock` is given back,
and that container is left as it was;
- `mosaic bus status` shows a running broker's lock as live, a lock
left after `docker rm -f` as dead, and the lock as unknown when
the endpoint doesn't answer;