Skip to content

CRW-944: runtime-upgrade runs only verified bytes, always restarts, and checks the install target - #873

Merged
thisisjun786 merged 10 commits into
devfrom
codex/crw-944-upgrade-review
Oct 8, 2026
Merged

thisisjun786 merged 10 commits into
devfrom
codex/crw-944-upgrade-review

Conversation

@thisisjun786

@thisisjun786 thisisjun786 commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Closes CRW-944.

crw manage runtime-upgrade replaces the relay runtime from a release archive. The post-merge
review of the earlier upgrade change found five defects in it, plus a decision to settle. This
change closes them, and a later correction round closes four more the pre-merge evaluation found.
The final head is 6b36c52.

What changed

Only the bytes that were verified are executed. The command hashed the release directory's own
archive and then reopened that same original to copy it, so replacing the original between the two
reads changed what was unpacked and run - with the upgrade runner's privileges, in dry runs too.
The archive and its SHA256SUMS are now copied into the run directory (0700) first, the copy's
digest is compared with the digest the copied SHA256SUMS names, and the copy is what is unpacked,
--versioned and passed to install update --from. The originals are never reopened.

The service always comes back. An update that broke the pointer left the service down: start
read only the new pointer and gave up when it did not resolve. The runtime the pointer names is now
recorded before the stop, and start is called after it whatever the update did - from the pointer
when it still resolves, otherwise from the recorded runtime - and the record's start_from says
which one it was. A start that answers that the service is already running is not a failed launch:
the service holds the lock, so no other runtime can start either, and that answer names the runtime
the service is on rather than a reason to fall back.

A configuration change outranks an update failure. update_failed (exit 1) used to mask the
post-check's configuration finding (exit 4). The final exit code now prefers the configuration
change, and the record's reasons list keeps every reason that applied, so a run that both failed
to update and changed the configuration names both. A configuration file the snapshot read and that
is now gone is a change the run can state exactly; a file that still exists but cannot be read is
not a change, because the run cannot tell whether it differs.

The post-check is compared with the install target. It used to accept any non-empty --version.
It now remembers the version the unpacked crw --version printed and the runtime directory the
update's own answer reported, and refuses with exit 4 runtime_mismatch when the pointer does not
name that directory, when the update names none, or when the pointer's crw --version differs. An
update that put no runtime in service is a rollback only while the pointer still names the runtime
it replaced; a pointer left on any other runtime is a mismatch. The comparison runs before and
again after the service wait, because the wait can run for a minute and another process on the host
can move the pointer while this run waits. A restart that fell back to the previous runtime while
the pointer names the installed one is that same disagreement, so it is a mismatch rather than a
success.

The service status is waited for. It was read once, so a worker that finished its policy write
a moment later was treated as a failure. After start the status is re-read every 2 seconds for up
to 60 seconds, waiting for running true and matchesRunning same; the last answer is recorded
otherwise.

Open attempts come from the relay doctor. The step used to run its own SQL against the store.
It now asks the running runtime's own codex-session-relay doctor and reads
contents.openAttempts: 0 proceeds, more than 0 is exit 3, and contents that are unavailable or
carry no count are refused rather than read as an absence. The direct store SQL and the store import
are gone.

One test is no longer decided by the wall clock. TestUpgradeDryRunCallsNoServiceCommand
required the second of two dry runs in the same wall-clock second to refuse, so a second boundary
between them made it fail. The harness now injects Env.Now, both runs fall in the second the test
chooses, and a control advances the injected clock by one second and shows the second run taking its
own run directory. The product's run-directory rule is unchanged.

Acceptance criteria and evidence

The issue's criteria, each with the check that pins it:

  • C1 verified bytes only: TestUpgradeReview702SwappedArchiveIsNotRun replaces the release
    directory's archive once the copied archive's digest has been compared, asserts the replacement
    really took, and shows the run still unpacks the archive it verified while the swapped archive's
    crw never runs. On the baseline the same replacement is what the run unpacks and runs.
  • C2 always restart: TestUpgradeReview702StartRunsWhenUpdateBreaksThePointer has the fake update
    remove the current link and exit 1; service start is still called, from the runtime the
    pointer named before the stop, and start_from records it.
    TestUpgradeReview702ServiceAlreadyRunningIsNotAFailure pins the other side: a start that answers
    already_running on the installed runtime is the service being up there, not a failure.
  • C3 exit-code precedence: TestUpgradeReview702ConfigChangeWinsOverUpdateFailure - update exit 1
    plus a configuration change gives exit 4 and a record naming both update_failed and
    config_changed; TestUpgradeReview702ConfigDeletionWinsOverUpdateFailure pins the deletion case
    and TestUpgradeReview702UnreadableConfigIsNotAChange the unreadable one.
  • C4 install target: TestUpgradeReview702StalePointerIsAMismatch - three sub-cases: the pointer
    left on the previous runtime, an update that names no runtime, and an installed runtime reporting
    another version. Each is exit 4 runtime_mismatch. TestUpgradeReview702IncompletePromotionIsChecked
    covers the installer's exit-3 promotion, TestUpgradeReview702PointerMovedToAThirdRuntimeIsAMismatch
    a failed update whose pointer names neither the previous nor the installed runtime, and
    TestUpgradeReview702PointerBrokenDuringTheWaitIsAMismatch a pointer removed while the run waits
    for the service. TestUpgradeReview702FailedUpdateIsNotAVersionMismatch guards the correct
    rollback so it is not turned into a failure.
  • C5 service wait: TestUpgradeReview702SlowWorkerIsWaitedFor - the status answers unknown,
    unknown, same; the run reads it three times and exits 0.
    TestUpgradeReview702ServiceWaitBudgetIsRecorded pins the other half: a wait that ends without the
    service ever reporting itself up records the failure rather than passing.
  • C6 doctor open attempts: TestUpgradeReview702OpenAttemptsFromDoctor - contents.openAttempts 2
    is exit 3 and the doctor was asked at the runtime the pointer names; available false is refused
    (exit 2), and readable contents with no count are refused too.
  • C7/C9 the repository checks, run fresh in the delivery worktree (see below).
  • C8 deterministic dry-run refusal: the two subtests of TestUpgradeDryRunCallsNoServiceCommand.

Red before, green after

The new tests were run against the baseline implementation before each fix. All failed there, and
each failure was the defect:

  • the swapped archive's crw ran (extract reported v0.4.0-9999-gdeadbeef);
  • no service start call at all;
  • exit 1 update_failed where the configuration change had to win;
  • exit 0 for all three mismatch sub-cases;
  • exit 4 postcheck_failed after a single status read;
  • exit 0, with the open-attempt step running select count(*) from attempts where internal_state != 'settled';
  • exit 0 where a pointer removed during the wait had to be a mismatch;
  • exit 4 runtime_mismatch where a service already up on the installed runtime had to pass;
  • exit 1 update_failed where a pointer on a third runtime had to be a mismatch.

The C8 flake was reproduced deterministically with a probe that waits for the wall-clock second
boundary before the second run and then applies the old assertion; it fails. A tight 80-run loop of
the baseline test passes, which is why the flake was intermittent rather than constant. The fixed
test is deterministic because the harness, not the wall clock, decides the second.

Commands and results

In the delivery worktree, on the final head:

gofmt -l cmd internal                                (empty)
go vet ./internal/manage ./cmd/crw                   exit 0
GOOS=darwin go vet ./internal/manage ./cmd/crw       exit 0
CGO_ENABLED=0 go build ./...                         exit 0
go test -count=1 ./internal/manage                   ok
go test -count=1 ./internal/testsupport ./internal/contracttest   ok
go test -count=1 -tags dev ./internal/dev/ci         ok
go run -tags dev ./cmd/crw-dev ci validate           exit 0
go run -tags dev ./cmd/crw-dev ci plugin             exit 0 (no change)
git diff --check origin/dev...HEAD                   exit 0

Every Go command ran through the host's heavy-check slot, under a memory-limited scope, with the
shared build cache and a short temporary directory. The hosted checks on this head are the twelve
jobs the pull request reports.

Size

1378 insertions and 291 deletions across five files, generated data excluded (none of these files is
generated). internal/manage/upgrade_review702_test.go is new; the other four are the two product
files and the two existing test files the changed behaviour required. The size grew with the
correction rounds because each round had to pin the behaviour it changed; no part of it is
separately verifiable, so it stays in this one change.

Out of scope

internal/relay is untouched. No installation, service activation, configuration surface or live
relay state is changed, and the runtime directory naming rule is as it was.


Devin Review

runtime-upgrade had five defects the post-merge review of PR #702 found:

- it hashed the release directory's own archive and then copied that same
  original, so a swap between the two reads let unverified bytes run. The
  archive and its SHA256SUMS are now copied into the run directory first and
  the copy is what is verified, unpacked and executed.
- it skipped the service restart when the update broke the pointer. The
  runtime the pointer names is recorded before the stop, and start is called
  after it whatever the update did, from the pointer when it still resolves
  and otherwise from the recorded runtime.
- an update failure masked a configuration change. A configuration change now
  outranks an update failure in the final exit code, and the record keeps both
  reasons.
- the post-check never compared the pointer or the version with what the update
  produced. It now compares the pointer with the runtime the update reported
  and that runtime's --version with the version the archive carried.
- it read the service status once. It is now re-read every 2 seconds for up to
  60 seconds, waiting for running true and matchesRunning same.

The open-attempt check reads the relay doctor's contents.openAttempts instead
of the store directly, and refuses when the contents cannot be read.

The dry-run test that required a second run in the same second to refuse now
pins Env.Now in the harness, so a wall-clock second boundary cannot decide it,
and a control advances the injected clock to show the next second takes its own
run directory.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for security reviews. Please try again later.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-07T08:18:42.846167Z 8743be7 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 5 potential issues.

Devin Review

Comment thread internal/manage/upgrade_steps.go Outdated
Comment thread internal/manage/upgrade_steps.go Outdated
Comment thread internal/manage/upgrade.go
Comment thread internal/manage/upgrade_steps.go Outdated
Comment thread internal/manage/upgrade_steps.go

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8743be7a7b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/manage/upgrade_steps.go Outdated
Comment thread internal/manage/upgrade.go
Comment thread internal/manage/upgrade_steps.go Outdated
codex and others added 9 commits October 7, 2026 17:54
The first external review of this pull request found five gaps in the new
behaviour. Each has a test that fails on the previous head:

- a pointer can resolve to a directory the service cannot be started from. The
  restart now tries the pointer's runtime and falls back to the runtime the
  pointer named before the stop when that start does not succeed, and the
  record names the runtime the service came back on.
- a configuration file the post-check could not read is not a change: it is
  recorded as a post-check finding, so it no longer takes the exit code from an
  update failure that would otherwise report it.
- every failure names its reasons, including a run that refused before the
  update, which previously had only the single reason field.
- an update that did not land leaves the pointer on the runtime it replaced,
  whose version is the previous one by design. That is the rollback, not a
  mismatch, so the version comparison runs only against a runtime the update
  put in service.
- the installer's exit 3 is a promotion whose claim did not settle: the
  runtime is in service, so the pointer and version are compared with it even
  though the run is reported as failed.
The independent review of the previous head found two defects in what this
change adds:

- the run state kept the verified archive's digest and never read it; the sums
  step already records it in the run record, so the field is gone.
- the open-attempt guard refuses both unreadable contents and readable contents
  that carry no count, and only the first half had a case. The second half now
  has one, so a regression that read a missing count as zero fails.

The duplicated installer statuses stay: the constants name the coupling in a
comment, and importing the installer's own constants would add a package
dependency this change does not need.
The pre-merge evaluation of the reviewed head found three in-scope defects.

The restart's fallback kept the relay up when the runtime the update installed
would not start, but the run still reported success: the post-check compared the
pointer with the installed runtime and then read the service status from the
runtime the restart actually used, without requiring the two to be the same. A
pointer on the new runtime with the service running the old one is now a
runtime_mismatch.

The post-check treated every read failure of the configuration file as no
change. A file the snapshot read and that is now gone is a change the run can
state exactly, so it is config_changed; a file that still exists but cannot be
read stays a post-check finding of its own.

A pointer that does not name the runtime the update promoted is a mismatch even
when the update itself failed, so the mismatch exit code is no longer hidden by
the update failure.
The pre-merge evaluation of the first correction round found two more in-scope
defects, both in the restart and post-check.

The restart treated a start answer of already_running as a failed launch and
fell back to the previous runtime, recording that runtime as the one the service
was on even when the service was up on the runtime the update installed. A
service already running holds the lock, so no other runtime can start either;
already_running now names the runtime the service is on, and the post-check
compares that runtime with the installed one.

The post-check assumed a failed update had rolled back without checking the
pointer still named the runtime the update replaced. An update that puts no
runtime in service is a rollback only while the pointer names the previous
runtime; a pointer left on any other runtime is a mismatch.
The pre-merge evaluation found that the pointer and version were compared only
before the service wait. The wait can run for a minute, and another process on
the host can move or remove the pointer while this run waits; the last status
answer alone then reported success with the pointer no longer naming the
installed runtime.

The install-target check now runs before and after the wait, and a mismatch it
finds is recorded once. A start that never leaves the service up no longer
records the last runtime it tried as the runtime the service came back on.
The doctor names the store the relay service serves in serviceStore when discovery
selected another directory; the check ignored it and judged the selected
directory's contents, so a served store with open attempts could pass as none.
The served store is now asked for its own answer, and an unreadable served store
is refused rather than read as empty.
The pre-verification copy read whatever it was given, so a release directory
holding something far larger than the archive could fill the state disk the live
relay shares before the checksum even ran. The copy now reads at most the
installer's own input limit and removes its partial file when it refuses or
fails.

The wait-budget test now shrinks the budget and drives a service that answers but
never settles, so it spends the budget and checks the last answer is recorded;
the earlier form only proved the parent context ended.
# Conflicts:
#	internal/manage/upgrade_test.go
@thisisjun786
thisisjun786 merged commit f94eff8 into dev Oct 8, 2026
12 checks passed
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