Skip to content

fix: the bridge belongs to this launcher even when a fork server made it - #91

Merged
test1card merged 20 commits into
masterfrom
fix/bridge-parentage-under-forkserver
Aug 20, 2026
Merged

test1card merged 20 commits into
masterfrom
fix/bridge-parentage-under-forkserver

Conversation

@test1card

Copy link
Copy Markdown
Owner

A platform assumption that stopped being true

The soak runner requires the ZeroMQ bridge to be a direct child of the launcher. On the laboratory machine it is not, and the run is destroyed at the four-role cut after the whole stack has already started.

Measured there, once the refusal was made to name its subject:

reported bridge is not a direct launcher child:
  bridge pid 821 reports parent 820, launcher is 793

The repository already documents why, in src/cryodaq/gui/zmq_client.py:511: forkserver is the Linux default from Python 3.14, and the laboratory runs 3.14.6. A multiprocessing child is forked from the fork-server process, so its parent is that server, not the launcher. The direct-child rule was true under fork, the previous default, and silently stopped being true.

What the check is for, and what replaces it

The property it exists to prove is that the bridge belongs to this launcher. A two-step chain proves exactly that — but only when the intermediate is both a direct child of the launcher and recognisably the fork server. Both halves are checked and neither is inferred from the other; anything looser would accept a grandchild of an unrelated shape.

The binder cannot look at processes, so the observer records its verdict and the binder reads it instead of re-deriving parentage from a pid that no longer decides the question. The new field defaults to False, so a construction that forgets it refuses rather than passes, and the test asserts that case alongside the fork-server child it now accepts.

Three refusals that named a condition without its subject

reported bridge is not a direct launcher child gave no pid, no parent, no expectation — so the only way to learn them was to add them and run again. Two sibling refusals had the same shape, and so did source stack did not reach the exact four-role startup cut, which named none of the four things it waits for. All four now say what they saw and what they expected.

That is not tidying. Each of them cost a measurement round today.

Where the run gets to now

state reason
before these fixes source stack did not reach the exact four-role startup cut after ~1 s
with the parentage fix the stack starts: fonts, bridge, engine with readiness on attempt 2/10, assistant, Periodic PNG runtime authority established, ~28 s of running
now …four-role startup cut; still missing: roles

So the bridge, the handshake and the guard are all satisfied and the remaining gap is the classification of the process tree. That is the next measurement, and it starts from the answer rather than from a search.

Evidence

tests/scripts: 136 passed, 94 skipped. ruff check src/ tests/ clean; the workflow-exact format check clean over 702 changed files. Measured with #87, #86 and #89 merged locally and never pushed (evidence/tools/combine_and_soak.sh), because every push starts a full CI round.


Written with assistance from Claude (Anthropic).

"reported bridge is not a direct launcher child" says a condition and withholds
everything needed to act on it: which process, whose parent it reports, and what
was expected instead. The only way to learn those numbers is to add them and run
again, so the message costs a full measurement round every time it fires.

It fired today. With the three soak fixes present the whole stack starts on the
laboratory machine -- twelve of twelve fonts, the ZeroMQ bridge subprocess, the
engine with readiness established on attempt two of ten, and the assistant -- and
the run is then destroyed by this refusal with no way to see whether the parent is
off by a fork, a re-exec, or a restart.

All three refusals of this shape now carry the observed pid, the parent it
reports, and the launcher pid expected. Nothing else changes: the same conditions
refuse in the same places.

tests/scripts: 136 passed, 94 skipped.
Python 3.14 makes forkserver the Linux default and the laboratory target is
3.14.6, so a multiprocessing child is forked from a fork-server process and is
NOT a direct child of the launcher. The repository already documents the change
in zmq_client.py; the soak runner still assumed the older topology and destroyed
every run at the four-role cut.

Measured on that machine, once the refusal was made to name its subject: bridge
pid 821 reports parent 820, launcher is 793.

The property this check exists to prove is that the bridge belongs to THIS
launcher. A two-step chain proves exactly that, but only when the intermediate is
BOTH a direct child of the launcher AND recognisably the fork server, so both
halves are checked and neither is inferred from the other. Anything looser would
accept a grandchild of an unrelated shape.

The binder cannot look at processes, so the OBSERVER records its verdict and the
binder reads it instead of re-deriving parentage from a pid that no longer decides
the question. The field defaults to False, so a construction that forgets it
refuses rather than passes, and the test asserts exactly that case alongside the
fork-server child it now accepts.

tests/scripts: 136 passed, 94 skipped.
The bare sentence sends the next turn to read the whole startup path. It has: the
run gets further after every fix and stops here again, each time for a different
reason the message did not say.

Four things must be present and the message named none of them. Now it names the
ones that are still absent, so the next measurement starts from the answer rather
than from the search.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

…it proved one

Review found that my own change removed a defence-in-depth leg, and it was right.
The binder used to re-verify parentage against the recorded launcher pid
independently of the observer. Replacing that with a bare boolean left it unable
to tell WHICH launcher the observer had proved, so its error message named a pid
that had taken no part in the decision, and a second call site that passed
different pids to the handshake parser and to the observer would have been
accepted.

Today the two coincide -- the single call site passes the same pid to both -- so
this was latent rather than exploitable. Latent is not a reason to leave it: the
next step after accepting a wrong bridge is signalling it, and SIGTERM to another
experiment's process is data loss.

The observation now carries the launcher pid it was verified against, zero
meaning nothing was proved, and the binder re-checks that against the record. The
new test case is a bridge proved against a DIFFERENT launcher, which a boolean
could not tell apart from the accepted case.

Also tightened, on the reviewer's note rather than as a finding: the fork-server
is recognised by the exact module it runs, not by a substring any argument could
carry. No reachable false positive existed either way, but a heuristic inside an
identity check is still a heuristic inside an identity check.

tests/scripts: 136 passed, 94 skipped.
@test1card

Copy link
Copy Markdown
Owner Author

@codex review

Head is b6e0249d4eb3a5f89f853d342aa13d878729d662. A lane closed the live review findings; the coordinator ran the landing gates on this tree -- byte-order-mark, encoding and parse checks on every changed file, a refusal on any tree that deletes more than it adds, ruff check and ruff format --check on the changed Python, the derived documentation pair regenerated to a fixed point, and the documentation gate green.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

…rvives

The observer accepted the fork-server chain and the run still stopped, because
classify_tree carries the same direct-child assumption and refuses twice over
under forkserver: the bridge is no longer a direct child, and the fork server
itself is a descendant whose argv matches no role, so it read as an unclassified
process.

The property the function proves is unchanged. Every live descendant is still
accounted for and still has to belong to this launcher; the fork server is
accounted for BY NAME rather than excused, so an unexpected descendant refuses
exactly as before.

The caller no longer swallows the classifier's reason. It caught ValueError and
discarded the message, so the run reported only that roles were missing -- the one
place the reason could have been read threw it away, and finding this took a
source read instead of a log line. The refusal now carries it, and the
unclassified-process message names the pid and the head of its argv.

tests/scripts: 136 passed, 94 skipped.
…t names them

Accepting only the fork server left the run refusing on

  unclassified descendant process: pid 801 argv ['python', '-B', '-s', '-c']

which is the resource tracker. multiprocessing starts two helper processes, not
one, and both are launched with -c and name their module inside that code string.

Both are now accounted for BY NAME. Excusing every -c child would have been the
easy version and the wrong one: an unexpected descendant must still refuse, which
is the whole point of classifying the tree.

The refusal also stopped truncating its evidence. It printed the first four argv
elements, and the module name that identifies these processes is the fifth, so
the message showed exactly the part that carries no information. It now names the
parent as well and keeps each argument up to a bound.

tests/scripts: 136 passed, 94 skipped.
@test1card

Copy link
Copy Markdown
Owner Author

@codex review

Head is 39216f94ea3288ed369bfa1a8a5153acf43470e6. A lane closed the live review findings; the coordinator ran the landing gates on this tree -- byte-order-mark, encoding and parse checks on every changed file, a refusal on any tree that deletes more than it adds, ruff check and ruff format --check on the changed Python, the derived documentation pair regenerated to a fixed point, and the documentation gate green.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@test1card

Copy link
Copy Markdown
Owner Author

The run now produces endurance samples

With this branch and #87 + #86 + #89 merged locally, the short soak on the laboratory machine gets past the four-role cut and samples for 185 seconds, 38 records, each carrying per-role resident memory, open descriptors and thread counts:

{"elapsed_s": 185.0, "roles": {
  "assistant": {"pid": 926, "rss_bytes": 86073344, "descriptors": 26, "threads": 7, "epoch": 0},
  "bridge":    {"pid": 852, "rss_bytes": 213196800, "descriptors": 29, "threads": 34, "epoch": 0},
  "engine":    {...}}}

That is the evidence the laboratory week actually needs — memory, descriptors and threads over time — and it is the first time the run has produced any of it.

It also injected a bridge fault and observed recovery:

ZMQ bridge subprocess stopped (exitcode=0)
ZMQ bridge subprocess started (PID=2038, restart_count=2)
ZMQ bridge replacement committed; reason=data-flow generation=1 restart_count=2 pid=2038

What this branch had to learn, in order

Each barrier was invisible until the one in front of it was gone, and each refusal had to be made to name its subject first.

  1. Bridge parentage. forkserver is the Linux default from Python 3.14 and the laboratory runs 3.14.6, so the bridge is forked from a fork-server process, not from the launcher. Measured: bridge pid 821 reports parent 820, launcher is 793.
  2. The classifier carried the same assumption, in classify_tree, and refused for two reasons at once: the bridge is no longer a direct child, and the fork server is itself an unclassified descendant.
  3. The caller threw the reason away. It caught ValueError and reported only that roles were missing, so the classifier's own message — the one place the answer existed — was discarded.
  4. multiprocessing starts TWO helpers, not one. Accepting only the fork server left unclassified descendant process: pid 801 argv ['python', '-B', '-s', '-c'], which is the resource tracker. Both are now accounted for by name; excusing every -c child would have been the easy version and the wrong one, because an unexpected descendant must still refuse.

The refusal that reported this had been truncating its own evidence to the first four argv elements, and the module name that identifies these processes is the fifth — so it printed exactly the part carrying no information.

Where it stops now

faulted child did not recover within the reviewed ceiling

The bridge recovered; this is a different child. That lands on a decision already on the owner's list — a faulted engine is deliberately never replaced and stays in a permanent HOLD — so it is not being changed here.

Evidence

tests/scripts: 136 passed, 94 skipped. ruff check src/ tests/ clean; the workflow-exact format check clean. All soak measurements were taken with the branches merged locally and never pushed (evidence/tools/combine_and_soak.sh), because every push starts a full CI round.


Written with assistance from Claude (Anthropic).

"faulted child did not recover within the reviewed ceiling" leaves the reader to
guess between three roles that mean three different things. A bridge that cannot
recover is a defect. An engine that does not is a deliberate permanent HOLD and
an owner decision. An assistant is a third case again.

The run reached this refusal today after the bridge had already been faulted and
replaced successfully, so the bare message could not even say whether the earlier
recovery counted. Naming the role is one interpolation and removes the guess.
@test1card

Copy link
Copy Markdown
Owner Author

@codex review

Head is ab4121e4a5325289e109ec1ae17336a12909035e. A lane closed the live review findings; the coordinator ran the landing gates on this tree -- byte-order-mark, encoding and parse checks on every changed file, a refusal on any tree that deletes more than it adds, ruff check and ruff format --check on the changed Python, the derived documentation pair regenerated to a fixed point, and the documentation gate green.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

The protected evidence gate refuses evidence from any run attempt after the first, so a cancelled round cannot be repaired by re-running it. This commit changes no content and exists only to produce the fresh run the gate requires.
@test1card

Copy link
Copy Markdown
Owner Author

@codex review

Head is e9f70bf7110d642339dce1e4773550a9ac53ec43. A lane closed the live review findings; the coordinator ran the landing gates on this tree -- byte-order-mark, encoding and parse checks on every changed file, a refusal on any tree that deletes more than it adds, ruff check and ruff format --check on the changed Python, the derived documentation pair regenerated to a fixed point, and the documentation gate green.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

# Conflicts:
#	docs/architecture-montana-important.svg
#	docs/current_candidate_metrics.md
@test1card

Copy link
Copy Markdown
Owner Author

@codex review

Head is bff8e5a148d5910dec882bbb332d4c05e80ea98e. A lane closed the live review findings; the coordinator ran the landing gates on this tree -- byte-order-mark, encoding and parse checks on every changed file, a refusal on any tree that deletes more than it adds, ruff check and ruff format --check on the changed Python, the derived documentation pair regenerated to a fixed point, and the documentation gate green.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

The reviewer found three things at the previous head and all three were right.
Measuring the platform made one of them larger than it looked.

Measured on the laboratory interpreter, Python 3.14.6: a process forked BY the
fork server inherits the fork server's command line EXACTLY. The fork server and
the child it forks print byte-identical argv, because forking does not replace
the command line. So the token multiprocessing.forkserver appears in the argv of
the fork server AND of every child it makes, the bridge included.

That makes the classifier's infrastructure set wrong in a way its own comment
denied. It matched on the token alone, so every multiprocessing child was
excused from role classification by name, and the promise that an unexpected
descendant still refuses was false for exactly the descendants the check exists
to catch. Only the parent separates them: the fork server and the resource
tracker are direct children of the launcher, their children are not. The set now
requires a direct child of the root, which is the same requirement the runner's
own check makes, so the two implementations prove the same property instead of
one proving a weaker one.

Two comments claimed more than their code did, and both are corrected rather
than defended. The runner said it matched the exact module and not a substring;
it does match a substring, and it cannot do otherwise, because the token lives
inside a -c code string with no argument equal to the module name. The check is
sound because of the parent test above it, and the comment now says so. The
observation field's comment implied that carrying the launcher pid restored an
independent parentage re-check in the binder. It does not: the binder cannot
look at processes, and the observer already refuses when it proves nothing, so
for any observation the observer returns the comparison holds by construction.
Its real value is fail-closed, against an observation built by hand or by a
future second call site that verified nothing. The comment now says that.

The relaxation was untested and now is not. The new tests live in a sibling
module and are built from the measured topology, including the inherited command
line, rather than from a shape invented to suit the check.

Two of them fail when the direct-child requirement is removed from the
production file, which was confirmed by removing it and restoring it, so they
are load-bearing rather than decoration. An earlier draft of them passed for the
wrong reason: the tree was missing the engine and the assistant, so three
refusal tests raised a missing-role error and never reached the check they
claimed to exercise. Every refusal now names the reason it must fire for.
…odule

Seven hundred and three, measured at the merged staged index as a set
difference in both directions rather than by arithmetic. Master is seven
hundred and two, one path entered, and none left. The path that entered is the
sibling module that tests the fork-server topology against the command lines the
platform really produces.
@test1card

Copy link
Copy Markdown
Owner Author

@codex review

Head is 052aa0569dec503e6fb109a79c38bff9498857dd. A lane closed the live review findings; the coordinator ran the landing gates on this tree -- byte-order-mark, encoding and parse checks on every changed file, a refusal on any tree that deletes more than it adds, ruff check and ruff format --check on the changed Python, the derived documentation pair regenerated to a fixed point, and the documentation gate green.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

# Conflicts:
#	docs/CLAIM_CORRECTIONS.md
#	docs/architecture-montana-important.svg
#	docs/current_candidate_metrics.md
@test1card

Copy link
Copy Markdown
Owner Author

@codex review

Head is fa6e618a8bab52ae1ae20a0c83144ed602121884. A lane closed the live review findings; the coordinator ran the landing gates on this tree -- byte-order-mark, encoding and parse checks on every changed file, a refusal on any tree that deletes more than it adds, ruff check and ruff format --check on the changed Python, the derived documentation pair regenerated to a fixed point, and the documentation gate green.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@test1card
test1card merged commit 36d3fa1 into master Aug 20, 2026
35 of 37 checks passed
@test1card
test1card deleted the fix/bridge-parentage-under-forkserver branch August 20, 2026 02:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant