Skip to content

Tell an empty If-Match apart from an absent one, and cover the lane narrowing - #6190

Open
habdelra wants to merge 6 commits into
mainfrom
cs-12796-empty-if-match-and-lane-scope-coverage
Open

habdelra wants to merge 6 commits into
mainfrom
cs-12796-empty-if-match-and-lane-scope-coverage

Conversation

@habdelra

@habdelra habdelra commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Review fixes for the conditional-write work that landed in #6168. They were pushed to that branch a few minutes after it merged, so none of this is on main.

An empty If-Match was read as an absent one

headers.get returns null for an absent header and '' for a present empty one, and the guard tested !ifMatch, so the two collapsed. A client interpolating an ETag it never read sent If-Match: , got an unconditional write, and was told its conditional write had succeeded — while the overwrite it asked to be protected from is exactly what happened.

The comparison was already correct: ifNoneMatchMatches('', etag) is false for every etag, so the value would have been refused had it arrived. The guard is what kept it from getting there.

Three details in the fix:

  • It refuses ahead of the index-lane query. That check can fail closed with a 503, and an unsatisfiable precondition reaching it would come back as "retry this" for a request no retry can fix. This refusal is decided by the request alone, so it belongs before anything that can fail for an unrelated reason. It is still inside the precondition closure, so the request does take the batch's file locks and wait out the drain before being told 412 — latency rather than a wrong answer, and the alternative puts the throw outside commitBatch, which both call sites reach without a try.
  • 412, not 400. The recovery a 412 asks for is the one that works here — re-read, retry with the validator that read hands back, which is exactly what the caller failed to send. RFC 9110 §13.1.1 agrees on the code: zero listed validators means none matches, so the condition is false.
  • Whitespace and bare commas land on the same answer, via a namesAValidator helper that splits the way ifNoneMatchMatches does. RFC 9110 §5.6.1.2 has a recipient ignore empty elements in a comma list, so those are well-formed lists of nothing rather than junk. The two functions splitting identically is the point — "does this name a validator" and "does this match" must not read one header two ways.

Covered on both verbs. removeCard builds the precondition on its own path, and it is the verb where reading an empty If-Match as absent is least recoverable: a patch against a card that moved can be redone, a removal cannot.

The same collapse in the lane gate, failing the other way

awaitRealmIndexSettled gated its filter on jobTypes?.length, so an empty list meant no filter at all — turning "wait for nothing" into "wait for every job in the realm's lane". Not reachable today, since the only caller passes a non-empty constant, but it is the precise starvation failure this work already had to correct once in review. Absent and empty are now separate answers.

Two claims that had no test

The lane narrowing. The scoping argument behind the whole gate is the argument at the call sitejobTypes: CONTENT_MOVING_INDEX_JOB_TYPES. Deleting it would have left every test green while restoring fleet-wide 503s for the length of any reindex, because one from-scratch job lands in every realm's lane after any deploy that moves the UI checksum. Helper tests look like coverage of a narrowing; they cover the helper.

There is now a pair at the endpoint: the same wedge, the same request, differing only in which job type holds the lane. incremental-index must still be 503, from-scratch-index must be 200. If the two ever agree, one of them is wrong.

Both halves send x-boxel-skip-index-wait. That is not incidental: a wedge occupies the realm's index concurrency group, so while it is held no index job for that realm can be claimed — including the one the write under test enqueues. A write the gate allows therefore cannot finish indexing until the wedge lifts, and a response that waited for its own indexing hangs rather than answers. The refusing half never feels this, because being refused is exactly what keeps it away from indexing.

Where the validator-less refusal runs. Every case exercising it ran against a settled lane, so each one fell through to the ordinary comparison and got its 412 from there whatever the order — the tests passed for a reason unrelated to what they were named after. Only a wedged lane separates the two placements, so there is now a case holding one, and it deliberately does not send x-boxel-skip-index-wait, since the claim is that this answer does not depend on indexing.

Verification

Both files run together against a live stack, and every claim above that a test can carry has been shown to fail when the code is wrong:

  • Green: 38 pass, 0 fail.
  • Control A: 34 pass, 2 fail — collapsing empty jobTypes back into absent, and dropping the call-site jobTypes argument. One red each, nothing else. The endpoint one failed actual: 503, expected: 200, so it discriminates on the gate's answer rather than on a side effect.
  • Control B: 37 pass, 1 fail — moving the validator-less refusal below the lane query. actual: 503, expected: 412. The PATCH spelling loop and the DELETE case both stayed green through it, which is exactly why neither could have caught this.

A note on the spellings, since it is an easy thing to "simplify" back: ['', ',', ',,', ', ,'] are four values that differ on the wire. Leading and trailing whitespace is stripped by the HTTP parser and again by the Headers constructor, so a whitespace-only value arrives as '' and ' , ' arrives as ',' — writing those would pin two inputs while appearing to pin four.

An earlier green pass caught a defect in one of these tests rather than in the product: the wedge fixture blocked the write's own indexing, so a write the gate allowed hung instead of answering. That is why the fixture sends the skip header.

🤖 Generated with Claude Code

habdelra and others added 4 commits September 17, 2026 18:34
A present but empty If-Match took the same branch as a missing header, so a
client interpolating an ETag it never read was given an unconditional write
and told its conditional write had succeeded -- while the overwrite it asked
to be protected from is what happened. Zero listed validators means none can
match, so the condition is false and the write is refused.

The refusal is decided by the request alone, so it runs ahead of the index
lane query: that check can fail closed, and an unsatisfiable precondition
reaching it would come back as a 503, which asks for a retry that cannot
help. A 412 asks for the retry that can -- re-read, and send the validator
that read hands back.

Whitespace and bare commas land on the same answer through a helper that
splits the way the matcher does, so that "does this name a validator" and
"does this match" cannot read one header two ways.

The same collapse was in the lane gate itself, failing the other way: an
empty jobTypes list was treated as no filter at all, which turns "wait for
nothing" into "wait for every job in the realm's lane". Absent and empty are
now separate answers.

Tests: the four spellings of a validator-less If-Match, each asserting the
stored bytes are untouched; the empty jobTypes case, with a real unfulfilled
job in the lane so it cannot pass vacuously; and the narrowing itself, which
until now nothing exercised -- a from-scratch job must not refuse a
conditional write, where the incremental job beside it must.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test for a write the gate should let through hung for 60s instead of
answering. A wedge occupies the realm's index concurrency group, so while it
is held no index job for that realm can be claimed -- including the one the
write under test enqueues. The gate allowed the write, as intended; the
fixture then made that unobservable, because a response that waits for its
own indexing cannot come back until the wedge lifts.

The refusing half never felt this: it is refused before it commits, so it
never reaches indexing at all. That asymmetry is what left the defect to the
one test that exercised the allowed path.

Both halves now send x-boxel-skip-index-wait, so the write answers from its
serialized echo. That fixes the hang and makes the pair differ in exactly one
value -- which job type holds the lane -- so the two outcomes are a property
of the gate rather than of anything else about the requests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous change described this header and did not add it to the request
it was describing, so the test went on hanging for the reason the comment
already explained.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…match-412-on-writes-and-metaversion-on-cardjson
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 17, 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-09-17T23:15:20.964318Z 09319ec 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.

@habdelra habdelra left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Code review finding verdict, reviewed at 09319ec, static analysis only.

The product change is sound. All six claims in the description hold up under tracing, including the likeliest bug site — namesAValidator and ifNoneMatchMatches tokenize identically, checked by differential over 1,835 constructed values with zero disagreements. No correctness defect.

Four notes, all coverage or placement rather than behaviour, posted inline on the lines they concern with the author's responses threaded under each. The one worth reading is the first: the placement of the validator-less refusal had no test, and moving it reddened nothing.

Also checked and found sound: every caller of awaitRealmIndexSettled (exactly two in production, neither able to answer settled early); that the gate tests discriminate under the old spelling; and that x-boxel-skip-index-wait does not weaken the 503 case, since the precondition runs outside the guard that header sets.

Review found that nothing pinned where the validator-less refusal runs. It
sits ahead of the lane query on purpose -- the answer is decided by the
request alone, so reaching a check that can fail closed would let an
unsatisfiable precondition come back as a 503, asking for a retry that cannot
help. But every case exercising it ran against a settled lane, so each one
reached the ordinary comparison and was refused there whatever the order.
Moving the block below the query reddened nothing.

Only a wedged lane separates the two placements, so there is now a case that
holds one: 412 where the guard is, 503 with it moved. It deliberately does
not send skip-index-wait, since the claim is that this answer does not depend
on indexing.

Two more from the same review. DELETE builds its precondition on its own path
and nothing covered it, which is the verb where reading an empty If-Match as
an absent one is least recoverable -- a patch against a moved card can be
redone, a removal cannot. And the spelling loop pinned four values that are
two: leading and trailing whitespace is stripped by the HTTP parser and again
by the Headers constructor, so a whitespace-only value arrives as empty and
' , ' arrives as ','. Replaced with four that differ on the wire, keeping the
interior-whitespace case the RFC reading is actually about.

Also recorded what the placement does not buy: the check still runs inside
the precondition closure, so a request answerable from the header alone takes
the batch's file locks and waits out the drain before being told 412.
Hoisting it would put the throw outside commitBatch, which both call sites
reach without a try.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@habdelra

habdelra commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] Responses to the review are threaded under each finding inline, rather than collected here — all four are fixed in 34cc748.

Verification, since two of the fixes are themselves tests: 38 pass, 0 fail green, and three control passes each reddening exactly its intended case — including the mutation this review identified as catching nothing, which now fails actual: 503, expected: 412.

// first read would put the throw outside `commitBatch`, which both call
// sites reach without a `try`, so their refusal handling would have to
// grow a second entry point to catch it.
if (!namesAValidator(ifMatch)) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Code review findingLOW (test gap), CONFIRMED — the placement of this refusal has no test.

The ordering here is load-bearing: it sits ahead of awaitRealmIndexSettled so that a request the realm can answer from the header alone cannot come back as the lane's 503, which asks for a retry that nothing can fix.

Nothing pins it. Every case exercising this refusal runs against a settled lane, so each one falls through to the ordinary validator comparison further down and is refused there — with the same 412 — whatever the order. Moving this block below the lane query reddens no test.

The discriminating input is If-Match: , issued inside wedgeIndexingLane(): 412 with the guard here, 503 with it moved.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Fixed in 34cc748. This is the finding worth the review.

You're right that all four spellings passed for a reason unrelated to what they were named after — four assertions that could not fail, which is worse than one.

The wedged-lane case is now in, and it deliberately does not send x-boxel-skip-index-wait: the claim is that this answer does not depend on indexing, so the request must not be the one opting out of waiting for it.

Verified by running your mutation rather than reasoning about it — guard moved below the lane query: 37 pass, 1 fail, actual: 503, expected: 412. The PATCH spelling loop and the new DELETE case both stayed green through it, which is your point reproduced exactly.

// fail closed would let an unsatisfiable precondition come back as the
// lane's 503 — "retry this", for a request no retry can fix.
//
// "Ahead of the lane check" is as early as it gets, not as early as it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Code review findingLOW, CONFIRMED — "decided by the request alone" describes the answer, not the work.

This refusal lives in the returned closure, so a PATCH carrying If-Match: and no skip-index-wait still acquires the batch's file locks and waits out core.drainIndexing() before being told 412.

Latency and lock-holding rather than a wrong answer — I checked that the quiescence deferreds always fulfill, so there is no rejection path that could turn the 412 into a 5xx. Hoisting the check beside the ifMatch === null test would avoid it, with the caveat that the throw would then escape the un-try'd call sites.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Correct, and recorded rather than changed — the comment you're reading is the result.

Your caveat is exactly why I left it: both call sites reach commitBatch without a try, so hoisting the throw would make each of them grow a second refusal entry point to catch it. Paying a lock acquisition on a malformed request is the cheaper side of that trade.

Stating it in the code is the part that mattered, so it reads as a known cost rather than as something nobody noticed.

// interior whitespace in `', ,'` survives, which is the case the
// §5.6.1.2 reading is actually about.
let bytesBefore = readFileSync(cardFile('person-1.json'), 'utf8');
for (let ifMatch of ['', ',', ',,', ', ,']) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Code review findingINFORMATIONAL, CONFIRMED — two of these four spellings are the same input.

Verified over a real loopback round trip and against Headers/Request directly: leading and trailing OWS is stripped by the HTTP parser and again by the WHATWG Headers constructor, so ' ' arrives as '' and ' , ' arrives as ','.

The loop pins two distinct values while reading as four. The §5.6.1.2 reasoning you're invoking applies to the interior whitespace case, which is live and currently unrepresented.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Fixed in 34cc748 — now ['', ',', ',,', ', ,'], which differ on the wire, keeping the interior-whitespace case the RFC reading is actually about.

This is the one I would have gone on asserting: I wrote the loop, the PR description and a reply all claiming four spellings, and none of that is checkable by reading the test. The comment now says why these particular values, so the next person doesn't "simplify" it back to the pair that collapses.

Corrected the PR description too, which repeated it.

}
});

test('a DELETE whose If-Match names no validator is refused too', async function (assert) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Code review findingLOW (test gap), CONFIRMED — DELETE is reachable but was uncovered.

Traced: removeCard builds the same precondition on its own path, and commitBatch invokes opts.precondition?.() outside the entries.some(stagesContent) guard that skips the drain for a delete-only batch — so DELETE with If-Match: is a 412 today.

Nothing exercised it. The new coverage only PATCHes, and the three pre-existing DELETE conditional tests cover moved-past / current / *. It is the verb where an ignored empty If-Match is least recoverable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Added in 34cc748 — this test is the fix.

Your reachability trace matches mine, and the reason it deserves its own case rather than being taken on symmetry with PATCH is the one you give: a patch applied against a card that moved can be patched again, and a removal cannot be un-removed. Covering the recoverable verb and inferring the unrecoverable one is the wrong way round.

@habdelra
habdelra requested a review from a team September 17, 2026 23:40
@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Host Test Results

    1 files  ± 0      1 suites  ±0   2h 31m 0s ⏱️ - 6m 19s
4 895 tests +10  4 881 ✅ +10  14 💤 ±0  0 ❌ ±0 
4 910 runs  +10  4 896 ✅ +10  14 💤 ±0  0 ❌ ±0 

Results for commit 1773baa. ± Comparison against earlier commit 34cc748.

Realm Server Test Results

    1 files  ± 0    244 suites  +2   1h 16m 16s ⏱️ - 3m 9s
3 624 tests +77  3 624 ✅ +77  0 💤 ±0  0 ❌ ±0 
3 675 runs  +77  3 675 ✅ +77  0 💤 ±0  0 ❌ ±0 

Results for commit 1773baa. ± Comparison against earlier commit 34cc748.

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.

2 participants