Skip to content

fix(sync): say which condition failed, and which field an exit came from - #793

Merged
fujibee merged 3 commits into
integration/remotefrom
fix/781-782-name-the-condition-that-failed
Aug 14, 2026
Merged

fix(sync): say which condition failed, and which field an exit came from#793
fujibee merged 3 commits into
integration/remotefrom
fix/781-782-name-the-condition-that-failed

Conversation

@fujibee

@fujibee fujibee commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Declared reviewers: 1

Refs #781 / Refs #782, not Closes — a closing keyword does not fire off
the default branch, and this lands on integration/remote. Both issues need
closing by hand afterwards.

Head: 33f2287554a1b9c039d235ea06f16a28c4e235af, rebased onto integration/remote
at 5d3cc9eba4bbaf22ef94e2d652722ff552c2fe13. Every number below is from that
head.

This body carried a false sentence about the old behaviour, and a correction
is below rather than a quiet edit.
See "What the first commit got wrong".


Two reports, one shape: a message that states something the run never
established.
One names a permission problem on a platform where permissions
were not consulted; the other prints a number without saying which field it came
from, for a team it does not name.

#781 — the set is five throws, in three files

The report named two. Deriving the set instead — every
process.platform !== "win32" under scripts/ — gives nine guards on the
base, of which five throw a message about a condition that guard has already
excluded. The whole set is listed, including what is deliberately untouched,
because a count with no list is the shape that lost the three extra sites in the
first place:

file what it said
remote-sync.mjs bounded authority read must be a private / non-writable regular file fixed
remote-sync.mjs retained checkpoint must be a private regular file fixed
remote-sync.mjs AGMSG_SYNC_TRUST_DIR must be a private directory fixed — not in the report
rename-sync-config.mjs must be a private regular file fixed — not in the report
sync-cipher.mjs age identity identity file is not private fixed — not in the report
remote-sync.mjs post-open re-check changed while it was being opened unchanged — true on every platform
remote-sync.mjs age identity, end of file (guarded in full) unchanged — on win32 it throws nothing, so it says nothing untrue
remote-sync.mjs directory fsync guard (no message) unchanged
rename-sync-config.mjs directory fsync guard (no message) unchanged

The fix is an ordering, not a wording. authorityFileFault returns the
condition that actually failed, and the mode test is the last thing it
consults — so no message above it can be about permissions, on any platform.
That is what makes the win32 case safe rather than merely reworded. Callers add
the path, which the old messages omitted: they named a property without naming
what had it, on machines that hold several teams.

readNativeAgeIdentity needed more than a reworded throw. Its catch replaces
a plain Error with age identity is not securely readable, so the stat
and read failures lost their reason. Each of them names itself now — with its
code and the path — including EACCES and EPERM, which an earlier version
of this branch left on the generic sentence while claiming to have covered
"every failure".

#782 — decomposed, not decoded

signal ?? code printed one number and hid which field produced it.

Both fields are named now, and a code outside the 0–255 an exit status can
take is additionally shown decomposed. Not decoded, and the report's
reading is corrected
: under the POSIX encoding 3840 is WIFEXITED with
status 15, not "terminated by signal 15" — a signal death puts the number in the
low bits, and a bash in the middle reports 143 rather than passing a
wait status along at all. Nobody reproducing this has the platform to settle
which layer produced the number, so both components are printed and neither is
asserted.

The fallback said "inspect its team storage and binding" and named neither.
runDriver is handed the team and the binding path rather than reading them
back out of args — where they sit behind a positional index the report itself
miscounted, placing the team at args[1] where the operation is. Both
corrections are recorded on the issues.

What the first commit got wrong

4a804f4's message, and the source comment it added, said the old
readNativeAgeIdentity collapsed an unparseable identity into the privacy
sentence. That is false. The parser fails through malformed(), which is a
CipherStateError, and that catch has always begun
if (error instanceof CipherStateError) throw error — so a malformed identity
kept its own reason on the base and keeps it now. The risk this change carried
was breaking that pass-through, not restoring it
, which is the opposite of
what the message claimed. A test pins it.

The correction is in e7941d1's message rather than in a rewritten 4a804f4.
(Both were 2a6a9ec and a5b1ce2 before this branch was rebased onto the
landed base; the rebase renamed them, and this body names the SHAs that exist.)
git commit --amend is refused here — "fix it in the next commit; only ask for
help when the message itself must change"
— and rebuilding the branch would be
the same operation wearing a different name. So the wrong sentence stays in the
history with its correction beside it, touching the same lines, where
git log -p and git blame both show them together.

What was claimed and not carried

The first head asserted "five sites fixed" while rename-sync-config.mjs and
sync-cipher.mjs could be reverted whole with every test still green.
Review
found that; it was not measured before the request.

Both are bound at their real entry points now, not beside them:

  • readNativeAgeIdentity is called for a missing file, a directory, a loose
    mode, and a malformed key — the last pinning what must not change.
  • rename-sync-config.mjs is run, as a script, for a symlink, a directory
    and a loose mode.

Each case asserts its own condition, the path, and the absence of the other
conditions' words
.

A fourth silent failure, found here and fixed here

While checking this branch against a pattern being collected elsewhere, the
helper it added for #782 turned out to be an instance of it:

function bindingPathIfKnown(team) {
  try { return teamConfigPath(team); }
  catch { return undefined; }        // the reason goes nowhere
}

teamConfigPath throws without a connection root, and a caller that has none
still has to run — swallowing is right here and stays. What was wrong was
dropping the reason, so a diagnostic that could not name the binding said "and
its binding"
and stopped. Being non-fatal was never the defect. Being silent
was.

The fallback carries it now — { path } or { unavailable } — and the
diagnostic says which it got. Filed as #802 with the other three instances,
including the line between them: two || true left in a diagnostic dump are
correct and are deliberately not changed.

Driven at the production entry, not beside it: the new case calls
driver(), which resolves the path itself — once with a connection root (the
message names the file) and once without (the message names the reason). Both
halves also assert the run still reached the driver and still reported its
exit, because continuing is the property that must not change.

Tests, on this head

tests/remote_sync_engine.test.mjs: 87 pass, 0 fail (local, this head).

For bats, CI on this head is the stronger statement — it runs every shard on
three platforms. Every one is green except bats (ubuntu-latest 4/4) and the
bats aggregate that depends on it:

ubuntu   1/4 2/4 3/4 success        4/4 FAILURE
macos    1/4 2/4 3/4 4/4 success
windows  install helpers, windows runtime (#567)  success

The test covering the first site accepted one sentence for all three of its
cases — it was pinning the defect. It now requires each case to name its own
condition, including the negative: a symlink must not be reported as a
permission problem.

mutation result
the mode test consulted first again 1 failed
describeChildExit back to signal ?? code 2 failed (unit and integration)
sync-cipher.mjs reverted to base 1 failed — the identity control only
rename-sync-config.mjs reverted to base 1 failed — the rename control only
the binding-path catch back to return undefined 1 failed — the production-entry case

The last two failing one each is itself the check: a control that went red
for both reverts would not be binding the file it names.

One honest limit. The integration symlink case does not discriminate on
this platform: a symlink's mode here is 0o755, which does not trip the 0o022
mask, so it would stay green with the order reversed. The unit test around
authorityFileFault is what pins the ordering, with a case built to be both a
symlink and group-writable.

Windows itself is not measured — nobody reproducing this has the platform. What
is claimed here is what the code does on the branch process.platform takes
there, read from the source and exercised where the guard allows.

check-private-names: clean, 412 files — and it fires, controlled with a
seat-shaped name injected into a tracked file (1 finding), removed again.

The one red, and why it is not this branch

not ok 265 watch: relaunch with the SAME instance id replaces the previous watcher (#66 preserved), failing at _wait_pidfile "$pf" "$w2"
(tests/test_watch.bats:442). Confirmed on this exact head, not carried over.

#595 was opened 2026-08-01T20:25:42Z and its body names this test and
this assertion; its title is "bats suite flakes across the process-lifecycle
tests: main itself fails ~half its runs, a different test each time"
. This
branch's first commit is 2026-08-14T11:18:42Z.
The signature is older than
the branch, and it is recorded against main itself.

Reachability, re-measured on this head because the head moved:

  • the four files this branch changes are remote-sync.mjs,
    rename-sync-config.mjs, sync-cipher.mjs, and the engine test
  • scripts/watch.sh and tests/test_watch.bats reference none of them
  • watch.sh does reach sqlite-sync.shlib/storage.sh
    agmsg_storage_loadstorage.sh:382sqlite.sh:510 — and that file
    names sync-cipher.mjs. But the assignment sits inside
    storage_sync_prepare_push, and the spawn is inside the same function
    ;
    the failing test never calls it, and this branch changes no line of
    sqlite-sync.sh

That is "reached, but the failing test does not execute the changed hunk"
not "no direct reference".

Two reports, one file, one shape: a message that states something the run
never established. Kept as one commit because both live in
`remote-sync.mjs` and cannot be staged apart.

== #781, the condition that was never checked ==

A reader on Windows was told a file was not private, went looking for
permissions, and there were none to look at: the mode test is guarded by
`process.platform !== "win32"`, so on that platform the only conditions left
were a missing file, a symlink, or an oversized one. The same message on Linux
had been a real `0664` left by `umask 0002`, which is what made the wrong
reading credible.

Five throws said it, in three files, and the report named two. The set was
derived rather than listed -- every `process.platform !== "win32"` under
`scripts/`:

  remote-sync.mjs        the bounded authority read, the retained checkpoint,
                         and AGMSG_SYNC_TRUST_DIR
  rename-sync-config.mjs the config it is about to rename
  sync-cipher.mjs        the age identity

Two sites in the same sweep are NOT changed, and the reason is the same rule:
the post-open re-check says "changed while it was being opened", which is true
on every platform, and the identity check at the end of remote-sync.mjs is
guarded in full, so on win32 it throws nothing rather than something wrong.

The fix is an ordering, not a wording. `authorityFileFault` returns the
condition that actually failed, and the mode test is the LAST thing it
consults -- so no message above it can be about permissions, on any platform.
Callers name their own subject and add the path, which the old messages
omitted: they named a property without naming what had it, on machines that
hold several teams.

`readNativeAgeIdentity` needed more than a reworded throw. Its own `catch`
replaced every failure with "age identity is not securely readable", so the
specific reason never reached anyone -- a missing file and an unparseable one
both arrived as a claim about security. The reasons are raised as
`CipherStateError` now, which that `catch` passes through.

The test that covered the first site accepted one sentence for all three of its
cases. It is replaced by one that requires each case to name its own condition,
including the negative: a symlink must not be reported as a permission problem.
The unit test around `authorityFileFault` pins the ordering directly, because
the integration case cannot -- a symlink's mode on this platform does not trip
the mask being tested, so it would stay green with the order reversed.

== #782, the field the number came from ==

    storage sync prepare failed (3840)

Three things missing from one line. Which field produced 3840, which team it
was about, and where to look.

`signal ?? code` prints one number and hides which of the two it was. On
Windows/Git Bash a driver that ends to a signal can arrive through `code` as a
raw wait status, so the operator gets a number that reads like an exit status
and is not one -- with nothing on the line to tell them apart.

Both fields are named now, and a `code` outside the 0-255 an exit status can
take is additionally shown DECOMPOSED. Not decoded: under the POSIX encoding
3840 is `WIFEXITED` with status 15, while the report that raised it read it as
signal 15 -- and a death by signal puts the number in the LOW bits, with a
`bash` in the middle reporting 143 rather than passing a wait status along at
all. Nobody reproducing this has the platform to settle which layer produced
the number, so both components are printed and neither is asserted. Choosing
one would put a second wrong sentence exactly where the first one was.

The fallback diagnostic said "inspect its team storage and binding" and named
neither, on a machine that may hold several teams. `runDriver` is handed the
team and the binding path rather than reading them back out of `args`, where
they sit behind a positional index -- the report's own description miscounted
it, placing the team at `args[1]` where the operation is.

The path is resolved defensively: `teamConfigPath` throws without a connection
root, and a caller that has none still has to run. A value that exists for a
sentence in a failure message must not become a new way to fail -- which it
briefly was here, breaking three tests that had nothing to do with the change.

Refs #781
Refs #782
…y catch

The previous commit's message and the comment it added both said the old
`readNativeAgeIdentity` collapsed an UNPARSEABLE identity into "age identity is
not securely readable". That is false, and review caught it.

The parser fails through `malformed()`, which raises a `CipherStateError`, and
that catch has always begun `if (error instanceof CipherStateError) throw
error`. So a malformed identity kept its own reason on the base and keeps it
now. The risk this change carried was BREAKING that pass-through, not restoring
it -- the opposite of what the message claimed -- and a test now pins it.

What the catch actually collapsed is a plain `Error`: the stat and read
failures. A missing file, a directory, an unreadable one, and a mode win32
never checks all arrived as a claim about security.

The first version of this fix named three of those and left the rest -- EACCES
and EPERM still reached the generic sentence, so the comment's "every failure"
was wider than the code. Every stat and read failure names itself now, with its
code and the path, so the sentence and the behaviour say the same thing.

AND THE TWO FILES OUTSIDE THE REPORT ARE NOW BOUND AT THEIR ENTRY POINTS.
Reverting either `sync-cipher.mjs` or `rename-sync-config.mjs` whole left every
test green, so "five sites fixed" was a claim the suite did not carry. The
identity reader is now driven for a missing file, a directory, a loose mode and
a malformed key; the rename script is RUN, as a script, for a symlink, a
directory and a loose mode -- each asserting its own condition, the path, and
the absence of the other conditions' words. Reverting either file turns its own
case red and leaves the other green; both were measured.

Refs #781
`bindingPathIfKnown` caught a failure and returned `undefined`, so a diagnostic
that could not name the binding said "and its binding" and stopped -- with
nothing anywhere saying why. Found while checking this branch against a pattern
being collected elsewhere; the fourth instance of it, and the second to appear
inside work whose subject is messages that say what actually happened.

Swallowing is right here and stays. `teamConfigPath` throws without a
connection root, and a caller that has none still has to run: a value that
exists for a sentence in a failure message must not become a new way to fail.
Being non-fatal was never the defect. Being silent was.

So the reason travels with the fallback -- `{ path }` or `{ unavailable }` --
and the diagnostic says which it got.

Driven at the production entry rather than beside it: the new case calls
`driver()`, which resolves the path itself, once with a connection root (the
message names the file) and once without (the message names the reason). Both
halves assert that the run still reached the driver and still reported its
exit, because continuing is the property that must not change. Reverting the
catch to `undefined` turns the second half red.

Refs #781
Refs #802
@fujibee
fujibee force-pushed the fix/781-782-name-the-condition-that-failed branch from a5b1ce2 to 33f2287 Compare August 14, 2026 18:55
@fujibee
fujibee merged commit 59cc42c into integration/remote Aug 14, 2026
19 of 21 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