M2 batch B: <input type=file> works, plus the M1 client gaps (audio, fullscreen, keyboard, token refresh) #109

Merged
triform-admin merged 28 commits from capture/batch-b-file-chooser into main 2026-09-09 11:56:16 +00:00

<input type=file> works end to end, verified live on a real guesttests/interactive --only uploads is 9/9 on cr7727-f9deaaa7efc6: the page receives a real File, with the original filename, the right size, and contents matching byte for byte.

Full suite on that image plus a gateway rebuilt from this branch: 82 PASS, 1 FAIL, and the one failure is the pre-existing audio-after-re-arm defect — established, not assumed: audio passed with 62 KB on a fresh worker, the pod had served 17 sessions, and restarting it made audio pass again (8361 bytes).

Lane: 639 steps, 0 failed, 0 undefined symbols.

The feature

Both halves were missing, which is presumably why neither had been done:

  1. No RunFileChooser override — chromium's default auto-cancels, so the page was told "no file selected" before anything could ask the viewer for a byte.
  2. The files channel was bound to a relay whose WebSocket backend was never built (url="off", permanently disabled(), dropping every frame).

CbFileUploadReceiver terminates the v1 protocol in the browser process: chunks decoded onto a blocking pool task into <profile>/Uploads, the finished file read back off disk to verify the client's sha256, the result resolving the parked content::FileSelectListener. The dead relay's 35 KB is deleted.

What it cost: five image rolls, and why

Every one of the three real defects was a silent path, not a wrong answer:

defect how it presented
base::AppendToFile opens O_WRONLY|O_APPEND with no O_CREAT — it cannot create the file it appends to write_failed from a different function, four steps later
two chunk-drop guards: a bare return, and a VLOG(1) nothing at all
a use-after-moveresult.first moved into argument 3 and read in argument 5, and C++ leaves argument evaluation order unspecified every SUCCESSFUL upload reported failure, with the file correct on disk

The last one was masked by an error message naming a cause that never happened: "could not read the file back", about a read the logs eventually proved had never failed once. That string steered the search through the filesystem, permissions, the task runner, and a genuine end/write race before the line itself.

Two theories were refuted and are recorded as such in docs/findings/file-upload-chunk-vanishes.md — the task-runner swap fixed nothing, and the end/write race was real (its fix stands) but was not this. "Refuted" is the half of a diagnosis that vanishes if only the winning answer gets written down.

Four lints, each from a defect that got past every local check

lint catches
lint-cxx-use-after-move a move and a read of one name in a single argument list
lint-cxx-orphan a method declared in a .h and defined in no .cc — strictly stronger than the linker, which needs a call to notice
lint-yaml-dupe-keys a duplicate mapping key. build-job-x264-t7.yaml argued for 64Gi in twenty lines of comment while every build got the 72Gi on the next line, for three weeks
per-frame + per-write logging on the files channel which of write / read / neither actually failed

Each checked against the whole tree before landing, with negative controls run both ways. The orphan lint found two false positives first (RTC_GUARDED_BY, ABSL_GUARDED_BY); the use-after-move lint's first version produced 14, all false, because it ignored statement boundaries.

Also: the kaniko push deadline was 300s against a 210 MB layer that now takes longer — two pushes died at 82% with the registry logging client disconnected during blob PATCH. Raised to 900s with the measurement replacing the old "~30-60s" belief, and a DeadlineExceeded failure now says so instead of looking like a credential error.

M1 client gaps, same branch

  • Audio you can hear — the guest has sent a track since the first session; <video muted> with no control meant every byte was decoded and discarded.
  • Fullscreen, and the fullscreen_changed event control.ts logged and dropped since it was written.
  • Keys typed into the client no longer stream to the guest — the URL you typed went keystroke-by-keystroke into the remote page.
  • Every paste was sent twice and took effect onceclipboard_paste is in the guest's kKnownInputTypes, so it was accepted and handled nowhere.
  • TokenRefresher finally has a caller — tokens live 5-15 min, the login cookie 12 h, and the socket froze the token into its URL at construction.
  • A HUD — the stats event has fired once a second since T82 with no subscriber. Now 9 fps / 1.5 Mbps / 1280x720 on screen.
  • The browser tab shows the page title — the gateway parsed it off /json and threw it away.

Client tests 333, new ones mutation-checked.

**`<input type=file>` works end to end, verified live on a real guest** — `tests/interactive --only uploads` is **9/9** on `cr7727-f9deaaa7efc6`: the page receives a real `File`, with the original filename, the right size, and contents matching byte for byte. Full suite on that image plus a gateway rebuilt from this branch: **82 PASS, 1 FAIL**, and the one failure is the pre-existing audio-after-re-arm defect — established, not assumed: audio passed with 62 KB on a fresh worker, the pod had served 17 sessions, and restarting it made audio pass again (8361 bytes). Lane: **639 steps, 0 failed, 0 undefined symbols.** ## The feature Both halves were missing, which is presumably why neither had been done: 1. No `RunFileChooser` override — chromium's default auto-cancels, so the page was told "no file selected" before anything could ask the viewer for a byte. 2. The `files` channel was bound to a relay whose WebSocket backend was never built (`url="off"`, permanently `disabled()`, dropping every frame). `CbFileUploadReceiver` terminates the v1 protocol in the browser process: chunks decoded onto a blocking pool task into `<profile>/Uploads`, the finished file read back off disk to verify the client's sha256, the result resolving the parked `content::FileSelectListener`. The dead relay's 35 KB is deleted. ## What it cost: five image rolls, and why **Every one of the three real defects was a silent path, not a wrong answer:** | defect | how it presented | | --- | --- | | `base::AppendToFile` opens `O_WRONLY\|O_APPEND` with no `O_CREAT` — it cannot create the file it appends to | `write_failed` from a different function, four steps later | | two chunk-drop guards: a bare `return`, and a `VLOG(1)` | nothing at all | | a **use-after-move** — `result.first` moved into argument 3 and read in argument 5, and C++ leaves argument evaluation order unspecified | every SUCCESSFUL upload reported failure, with the file correct on disk | The last one was masked by an error message naming a cause that never happened: `"could not read the file back"`, about a read the logs eventually proved had never failed once. That string steered the search through the filesystem, permissions, the task runner, and a genuine `end`/write race before the line itself. **Two theories were refuted and are recorded as such** in `docs/findings/file-upload-chunk-vanishes.md` — the task-runner swap fixed nothing, and the `end`/write race was real (its fix stands) but was not this. "Refuted" is the half of a diagnosis that vanishes if only the winning answer gets written down. ## Four lints, each from a defect that got past every local check | lint | catches | | --- | --- | | `lint-cxx-use-after-move` | a move and a read of one name in a single argument list | | `lint-cxx-orphan` | a method declared in a `.h` and defined in no `.cc` — strictly stronger than the linker, which needs a call to notice | | `lint-yaml-dupe-keys` | a duplicate mapping key. `build-job-x264-t7.yaml` argued for 64Gi in twenty lines of comment while every build got the 72Gi on the next line, for three weeks | | per-frame + per-write logging on the files channel | which of write / read / neither actually failed | Each checked against the whole tree before landing, with negative controls run both ways. The orphan lint found two false positives first (`RTC_GUARDED_BY`, `ABSL_GUARDED_BY`); the use-after-move lint's first version produced 14, all false, because it ignored statement boundaries. Also: the kaniko push deadline was 300s against a 210 MB layer that now takes longer — two pushes died at 82% with the registry logging `client disconnected during blob PATCH`. Raised to 900s with the measurement replacing the old "~30-60s" belief, and a `DeadlineExceeded` failure now says so instead of looking like a credential error. ## M1 client gaps, same branch - **Audio you can hear** — the guest has sent a track since the first session; `<video muted>` with no control meant every byte was decoded and discarded. - **Fullscreen**, and the `fullscreen_changed` event `control.ts` logged and dropped since it was written. - **Keys typed into the client no longer stream to the guest** — the URL you typed went keystroke-by-keystroke into the remote page. - **Every paste was sent twice and took effect once** — `clipboard_paste` is in the guest's `kKnownInputTypes`, so it was accepted and handled nowhere. - **`TokenRefresher` finally has a caller** — tokens live 5-15 min, the login cookie 12 h, and the socket froze the token into its URL at construction. - **A HUD** — the `stats` event has fired once a second since T82 with no subscriber. Now `9 fps / 1.5 Mbps / 1280x720` on screen. - **The browser tab shows the page title** — the gateway parsed it off `/json` and threw it away. Client tests **333**, new ones mutation-checked.
A file input on a streamed page did nothing at all, and it took two
independent fixes to explain why:

  1. CbWebContentsDelegate had no RunFileChooser override. Chromium's
     default auto-cancels, so the page was told "no file selected"
     before anything could ask the viewer for a byte.
  2. The "files" DataChannel was bound to CbFileUploadRelay, which
     forwarded frames to a WebSocket bridge (capture/file-bridge/)
     over a client constructed with url="off" — permanently
     disabled(), dropping every frame. Same shape as the clipboard
     relay's four-month outage, and the log line even said so.

Either fix alone changes nothing, which is presumably why neither
happened. Both land here.

CbFileUploadReceiver terminates docs/protocols/file-upload.md v1 in
the browser process: chunks are base64-decoded onto a MayBlock
sequence into <profile>/Uploads, the finished file is read BACK off
disk to verify the client's sha256 (what is verified is what the page
will get), and the result resolves the content::FileSelectListener
that RunFileChooser parked. The sidecar is deployed by nothing today,
so nothing regresses; its 35 KB of unreferenced source is deleted.

Read from the pinned tree rather than remembered, and two drafts were
wrong until it was:

  * NativeFileInfo::display_name is what the page reads as File.name.
    Left empty, blink falls back to the base of file_path — our
    sanitised "<id>__<name>" — so a site echoing the filename or
    checking its extension would see the wrong thing. The raw name is
    now carried alongside the on-disk one.
  * FileSelected's base_dir is empty except for kUploadFolder
    (file_select_listener.h:24). We reject that mode, so it is always
    empty; passing uploads_dir_ advertised an enumeration root that
    described nothing.

Two more defects found by re-reading, not by the compiler:

  * Upload::received was never incremented, so progress always
    reported 0 and the cumulative size guard tested 0 > declared_size
    — i.e. never fired. It now counts DECODED bytes; an earlier draft
    passed the base64 length through, which overstates by a third.
  * Every abandon path left partial bytes on disk. Since the session
    quota only credits uploads that SUCCEED, a client retrying a bad
    hash could fill the guest's disk without ever tripping it. All
    six paths now go through AbandonUpload (erase + delete + reply).

The listener contract (resolve exactly once, or chromium CHECKs on
release) is held from three directions, because no one of them is
enough: the receiver runs its own 5-minute deadline (the control
request's is cancelled the moment the viewer answers, so a viewer who
says "sending" and then vanishes would strand it); re-arm and teardown
cancel before destroying; and the client answers every branch,
including when its own picker throws.

Client: control.ts learns kind=file_chooser and main.ts shows a picker
bar, since a file dialog needs a user gesture and this request arrives
from the network. Five new tests, two mutation-checked.

Docs: the protocol spec's server half was describing the sidecar. Its
MIME allowlist and virus scan do not exist here and are now marked NOT
IMPLEMENTED rather than left as claims; target_selector is ignored;
attached_via can no longer be domSetFileInputFiles; the error-code
table is replaced with the codes actually emitted.

UNVERIFIED: capture/ does not compile locally. The build lane has not
run on this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
build-job-x264-t7.yaml carried this, and had since 2026-08-21:

    memory: "64Gi"
    memory: "72Gi"

Twenty lines of comment above the first argue — with measurements from
two OOM evictions — for 64Gi. YAML's last-key-wins gave every build
72Gi. The 64Gi edit had been inserted ABOVE the existing line instead
of replacing it.

Nothing surfaced it in three weeks. kubectl apply accepts a duplicate
mapping key silently, PyYAML accepts it silently, both values schedule
on triform-7, so every build worked and the manifest simply did not
mean what it said. No other manifest in the repo has one — the other
five build jobs have exactly two `memory:` keys, which is requests +
limits.

The class matters more than the instance. This repo's manifests are
heavily commented precisely BECAUSE the numbers are load-bearing and
hard-won (CLAUDE.md's OutOfcpu/OutOfmemory traps are about these exact
lines), and a silently discarded value makes every one of those
comments untrustworthy. Same shape as "a guard whose skipped path is
silent is not a guard": the discarded value reports identically to the
applied one.

So: lint-yaml-dupe-keys, wired into `make lint`. Checked against the
whole tree before landing (CLAUDE.md's rule) — zero findings across 59
files once t7 is fixed, and the 14 Go-templated Helm files are skipped
BY NAME with the skip reported, so the lint can never quietly check
nothing. Negative control run both ways: reintroducing the exact
defect fails it at the right line; removing it passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine checks in a new `uploads` suite. Its oracle is deliberately the
REMOTE PAGE's own <input>, not the guest's Uploads/ directory: bytes on
disk prove the transport ran, while the defect this exists for is that
the page's `change` event never fired at all. A suite that checked the
filesystem would have passed against a guest where <input type=file>
was still completely dead.

The preflight reports the `files` and `control` channels SEPARATELY
because the two halves failed independently — no RunFileChooser
override (chromium auto-cancels, page told "no file selected") and a
files channel bound to a relay with no backend. Either one alone
presents as "the file input does nothing", and naming the wrong half
sends the next person to the wrong file.

Also asserts File.name is what the viewer picked. An empty
NativeFileInfo::display_name makes blink fall back to the base of the
on-disk path — our sanitised "<upload_id>__<name>" — and every other
check in the suite would pass right through that. Contents are read
back by the page too, since a correct name and size with wrong bytes
is exactly what a base64 or chunk-ordering bug looks like.

Driving the client's own picker needs DOM.setFileInputFiles: a real
file dialog cannot be scripted, and the click that OPENS the chooser
still goes through the input channel as a genuine mouse event, which
is what makes chromium call RunFileChooser in the first place.

Not yet run — it needs a guest image containing the receiver, and that
build is in the lane now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guest has sent an audio track since the first session. `client/
index.html` carried `<video autoplay playsinline muted>` with no
control anywhere, so every byte arrived, decoded, and was discarded.
The product had audio in the sense that the SDP mentioned it.

`muted` on the element is NOT the bug and is kept: an unmuted autoplay
is refused by every browser's autoplay policy, and the failure is not
"no sound" but a rejected play() that stalls the entire video. What
was missing is a way OUT of that state, which the policy is explicit
about — a user gesture. So: an Unmute button, which is that gesture,
and which retries play() inside the click rather than on a later tick
where the gesture no longer counts. If the retry is still refused the
button re-mutes itself rather than claiming sound the user cannot
hear.

Mute state is read off the <video> rather than tracked in a variable,
and a `volumechange` listener keeps the label honest when the browser's
own media controls change it. The state deliberately SURVIVES a
reconnect — only the control is disabled between sessions — because a
broker blip silently re-muting someone who unmuted is its own bug.

Three checks added to the interactive stats suite, splitting two facts
that fail independently: inbound-rtp audio bytesReceived > 0 is the
transport (ADM, encoder, transceiver), and the element being unmuted
after a real click is whether a human hears anything. A stack that
gets the first right and the second wrong is silent — which is exactly
what shipped, and what no existing check could have caught. The
suite's one previous mention of "audio" asserts a SENDER kind during
camera passthrough, i.e. the opposite direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two halves of one gap.

The button: fullscreen the STAGE rather than the document, so the
stream fills the window and keeps its aspect ratio while the panel and
log get out of the way. The label follows `document.fullscreenElement`
rather than a flag of our own, because Escape and the browser's own
control both leave fullscreen without going through the button. Each
transition re-syncs the viewport, since the stage just changed size.

The event: `CbWebContentsDelegate` has emitted `fullscreen_changed` on
the control channel since it was written, and control.ts logged it and
dropped it — the one comment on that branch even said "fullscreen_
changed today; nothing to answer", which was true and beside the
point. There was nothing to ANSWER and something to DO. A streamed
page calling requestFullscreen (a video player, a game) gets it
granted by chromium, its :fullscreen CSS applies and its layout
changes, while the viewer still sees a 380px sidebar.

attachControlChannel gains an onEvent seam, guarded: a throwing
handler must not kill the channel, because every later frame arrives
through the same callback — including the ui_requests the guest
BLOCKS on. That guard is the mutation-checked test.

Following the event can be refused, since the browser wants a user
gesture and a network event is not one. The refusal is logged rather
than swallowed, and the page ends up no worse off than before.

Three new tests, 295 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keys are listened for on the WINDOW, and they have to be — a <video>
cannot hold focus in a way that delivers key events, so a
target-scoped listener would receive nothing. The consequence was that
every key in the page went to the cloud browser: the URL a user typed
into the address bar was streamed keystroke by keystroke into whatever
the remote page had focused, and Cmd/Ctrl+L, +T, +W and +R fired in
BOTH browsers at once.

`keyBelongsToClient` decides by WHERE FOCUS IS, not by which key it
is. Anything the viewer can type into or activate (input, textarea,
select, button, contenteditable) and the two client-owned overlays
(the dialog prompt, the file picker) keep their keys; everything else
goes to the guest.

Deliberately NOT an enumeration of "browser shortcuts". Intercepting
Cmd+W means preventDefault on a key the viewer may genuinely want
locally, and a stream that swallows it is worse than one that merely
does not forward it. Duplicating it was the bug.

The gate applies to keyup as well, which carries a real risk: focus
moving between a key's down and its up would leave the guest thinking
the key is held. The alternative leaks every release of every key
typed into the client's own UI, and the guest's dispatcher tracks held
modifiers from exactly these events. The policy is stable for the life
of a keypress, so the risk does not materialise — written down at the
call site rather than discovered later.

The rule lives in src/input.ts as a pure function over a duck-typed
target, because this package's tests run in node with no DOM and a
policy nobody can test is how the previous one (there wasn't one)
survived. `attach()` takes it as an option defaulting to "forward
everything", so every existing caller is unchanged.

Ten tests. Mutation-checked at all three points: the rule, the keydown
gate, the keyup gate, and the permissive default. Worth recording that
the first keydown mutation SILENTLY FAILED TO MATCH and the arm
reported 47/47 green — a clean file compared against itself, exactly
the trap CLAUDE.md describes. Re-run with the anchor count asserted, it
fails as it should.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`InputChannel.attach` wired the browser's `paste` event to a
`clipboard_paste` envelope on the INPUT channel, while `main.ts`
separately sent `clipboard_offer` on the CLIPBOARD channel. Two
envelopes per paste, one of which does nothing.

Nothing surfaced it, and the reason is worth writing down: the guest
lists `clipboard_paste` in `kKnownInputTypes`, so the dispatcher
accepts it and does NOT report it through OnInputEventUnknownType. It
is dropped silently by a lookup table that says it is known.
`cb_input_dispatch_clipboard.h` documents the gap on purpose ("we
leave the dispatch surface unclaimed here") and the M4 rank that was
to claim it never landed.

Removed the listener, not the sender: `sendClipboardPaste` stays for
anyone driving the channel directly, now carrying a comment saying
where it goes (nowhere). `clipboard_copy_request` stays wired — that
one IS consumed, arming the guest's copy window
(cb_input_dispatch_composite.cc:77), so removing the copy listener
would have broken a working feature while chasing a dead one.

The spec claimed "the server writes this into the remote clipboard and
synthesizes a paste action". It does not, and has never. Marked as
specified-but-unimplemented with a pointer to `clipboard_offer`, which
is what works. The envelope itself stays in v1 — removing a type is a
wire break, and a future implementation may still claim it.

Two tests, mutation-checked by putting the listener back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix: ReadDict needs its options argument — the lane found it, precedent would have
Some checks are pending
CI / Lint (pull_request) Waiting to run
CI / Container smoke test (pull_request) Waiting to run
CI / Docs link check (pull_request) Waiting to run
E2E / docker-compose + Playwright (pull_request) Waiting to run
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Waiting to run
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Waiting to run
Public security / ownership (pull_request) Waiting to run
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
57bdcfc65e
The build lane failed on exactly one thing across 638 compile steps:

  cb_file_upload_receiver.cc:183:74: error: too few arguments to
  function call, expected at least 2, have 1
      base::JSONReader::ReadDict(json)

`options` has no default at 7727. Every other ReadDict caller in this
tree already passes base::JSON_PARSE_RFC — cb_control_channel.cc:257,
cb_clipboard_relay.cc:109, cb_devtools_agent.cc:664 and :750. This is
the check CLAUDE.md prescribes ("grep for existing uses of it in
capture/ before using a Chromium API"), and I ran it for AppendToFile,
crypto:#️⃣:Sha256, GetDeleteFileCallback, FileSelected and the
chooser mojom, and skipped it for this one line.

Everything else in batch B compiled: cb_web_contents_delegate.o and
cloud_browser_browser_main_parts.o both built clean, so the
RunFileChooser override, the receiver wiring, the re-arm/teardown
cancellation and the new GN target are all confirmed by the compiler.
Re-verified the remaining APIs against the pinned tree afterwards
(JSONWriter::Write, ThreadPool::CreateSequencedTaskRunner,
OneShotTimer::Start, PostTaskAndReplyWithResult, base::DictValue).

Also in this commit: TokenRefresher finally has a caller.

It has existed, implemented and unit-tested, with none. Tokens carry a
5-15 minute TTL while the gateway's login cookie lasts 12 hours, and
ReconnectingWebSocket froze the signaling URL — token and all — at
construction. So any session that outlived one TTL and then had to
redial presented an EXPIRED credential; the broker rejects that at the
handshake, which is indistinguishable from "the broker is down", and
it gets more likely the longer a session runs. Precisely when
reconnecting matters.

The socket now takes a URL SUPPLIER (a plain string still works, so
every existing caller is unchanged) resolved per dial, and the session
adopts the token it already fetched rather than issuing a second one.
TokenRefresher gained adopt() + scheduleNext() for that. It is stopped
on teardown: a live refresher after disconnect keeps minting
credentials every 15 minutes for a session nobody holds.

Four tests. The redial one is mutation-checked by re-freezing the URL
at construction, which reproduces the original bug exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
chore(cv2-build): record guest release cr7727-e0e029b5c6ac (batch B) and pin the stack
Some checks are pending
CI / Lint (pull_request) Waiting to run
CI / Container smoke test (pull_request) Waiting to run
CI / Docs link check (pull_request) Waiting to run
E2E / docker-compose + Playwright (pull_request) Waiting to run
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Waiting to run
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Waiting to run
Public security / ownership (pull_request) Waiting to run
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
643c547ba1
Built from e0e029b, which is the commit the lane verified: 639 steps,
0 failed, cloud_browser_worker and all eight test targets linked.

Digest sha256:e6589bd4. Carries batch R (merged as 30178cd) plus
CbFileUploadReceiver and the RunFileChooser override, so `<input
type=file>` reaches the viewer for the first time.

Both files in one commit, per the push script's own warning: recording
only the json is what made main drift before, and `make
lint-deploy-pin` exists to catch exactly that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(upload): AppendToFile does not create the file — every upload died on chunk 0
Some checks are pending
CI / Lint (pull_request) Waiting to run
CI / Container smoke test (pull_request) Waiting to run
CI / Docs link check (pull_request) Waiting to run
E2E / docker-compose + Playwright (pull_request) Waiting to run
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Waiting to run
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Waiting to run
Public security / ownership (pull_request) Waiting to run
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
31afc97854
Measured on the live guest at cr7727-e0e029b5c6ac. The whole path
worked: the chooser was parked, the request crossed the control
channel, the viewer saw a picker, the bytes arrived on the files
channel — and then the FIRST chunk failed with `write_failed` and the
chooser was cancelled, so the page saw "no file selected".

base::AppendToFile opens with `O_WRONLY | O_APPEND` and no `O_CREAT`
(base/files/file_util_posix.cc:1269), so it cannot create the file it
appends to. Its header says only "Appends |data| to |filename|" —
true, and it reads as if a missing file is created. `CreateDirectory`
on the parent was already there; the FILE was the gap.

The first chunk now goes through base::WriteFile, which "gets created
with read/write permissions for all" if absent (file_util.h:577-579)
and truncates if present. Truncating matters: a leftover file at that
path can only be a stale partial from an abandoned upload_id, and
appending to it would silently corrupt the new upload.

This is the third API in this file whose exact contract mattered, and
the second the header alone would not have told me — the lane caught
ReadDict's required options argument, and only running it against a
real guest caught this one. A unit test would not have: it is a
property of the filesystem call, not of my logic.

Also in this commit, two harness fixes found the same way:

- The upload fixture's `<input type=file>` was a ~200x20 control, and
  the click lands at its CENTRE — on the "No file chosen" label, not
  the button. window.__clicks stayed 0 and the suite reported "no
  picker appeared", which reads as a broken RunFileChooser override
  and is not one. The download fixture's anchor is a padded block for
  exactly this reason and says so in a comment; I did not copy it.
- Added the click-landed check itself, so "the mouse missed" can never
  again be reported as "the upload is broken". Same oracle the
  download suite has carried since it learned the lesson.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs: what a green lane does not tell you, and the batch B pins
All checks were successful
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
CI / Container smoke test (pull_request) Successful in 1m32s
CI / Docs link check (pull_request) Successful in 8s
CI / Lint (pull_request) Successful in 3m31s
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Successful in 59s
Public security / ownership (pull_request) Successful in 6s
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Successful in 1m31s
E2E / docker-compose + Playwright (pull_request) Successful in 10m34s
c4703df6b5
Records the two API traps from batch B where a correct signature was
not a correct call, plus the four that were simply right.

The load-bearing one is base::AppendToFile: it compiled, linted, and
passed the lane, then failed on every upload's first chunk because its
POSIX implementation opens O_WRONLY|O_APPEND with no O_CREAT. The
header says "Appends |data| to |filename|" — true, and it reads as if
a missing file is created.

The general form, now in CLAUDE.md next to the lint-cxx caveat: a lint
clean means one mistake is absent, a green lane means it builds, and
neither means it works. When a contract is about a SIDE EFFECT — does
it create, truncate, require the parent — the declaration will not say.
Read the _posix.cc implementation or budget a live run.

Also pinned: FileSelected's base_dir is empty except for kUploadFolder,
and NativeFileInfo::display_name is what the page reads as File.name
(empty falls back to the on-disk basename, so a sanitised name leaks
into the DOM while every other check passes).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(upload): every chunk-drop path was silent — make them all say why
Some checks are pending
CI / Lint (pull_request) Waiting to run
CI / Container smoke test (pull_request) Waiting to run
CI / Docs link check (pull_request) Waiting to run
CodeQL / Analyze go (pull_request) Waiting to run
CodeQL / Analyze javascript-typescript (pull_request) Waiting to run
E2E / docker-compose + Playwright (pull_request) Waiting to run
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Waiting to run
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Waiting to run
Public security / ownership (pull_request) Waiting to run
Public security / secrets (pull_request) Waiting to run
eee0fae6f8
Chasing why uploads failed cost two rebuild cycles, and the reason is
that the receiver could lose a chunk without saying so. The visible
symptom was `file_upload_end` replying `write_failed: could not read
the file back` — a truthful description of a consequence, four steps
from any cause.

Measured, not guessed: hooking RTCDataChannel.send in the client
proved it sends start/chunk/end in order, and the guest's error lands
16ms later. So the chunk reaches the wire and vanishes inside the
receiver, in one of two early returns — a missing-field check with a
bare `return`, and an unknown-upload check behind VLOG(1), i.e.
invisible in production.

Both now LOG(WARNING) with the specific field or id. `file_upload_end`
additionally says when NOT ONE chunk was accepted, because that is the
cause of the read-back failure and it is known at that point. And
HandleEnvelope logs every inbound type with its size, which is the one
line that would have answered "did a chunk arrive?" immediately.

This is CLAUDE.md's own rule — "a guard whose skipped path is silent
is not a guard" — applied to code I wrote two days after re-reading it.
The drop reported identically to success.

Also corrects a comment I had just written asserting `!seq` would
reject seq==0. It would not: std::optional::operator bool IS
has_value(), and cb_input_dispatch_mouse.cc relies on exactly that for
x/y coordinates that are legitimately 0. `.has_value()` stays spelled
out for clarity, since the three operands in that condition convert to
bool for two different reasons, but it is not a fix and the comment no
longer claims it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(client): a HUD, because the stats event had no subscriber
Some checks are pending
CI / Lint (pull_request) Waiting to run
CI / Container smoke test (pull_request) Waiting to run
CI / Docs link check (pull_request) Waiting to run
E2E / docker-compose + Playwright (pull_request) Waiting to run
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Waiting to run
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Waiting to run
Public security / ownership (pull_request) Waiting to run
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
271ccacfd2
ChromelessSession has emitted a full StatsSample once a second since
T82. The client assembled it, shipped it to the broker over the stats
data channel, and dropped it on the floor locally — so a viewer
watching a stream degrade had no way to tell a slow network from a
broken guest. The panel said `connected` either way, which is the
least useful thing it could say at exactly the moment someone needs to
know.

Five numbers and a dot: fps, bitrate, RTT, loss, resolution, quality.

src/hud.ts is deliberately PURE — two samples in, display strings out,
no DOM and no clock — because this package's tests run in node and an
untestable policy is how the last one (there wasn't one) survived.

The deltas are the whole difficulty. Every counter in a StatsSample is
CUMULATIVE, so a HUD that renders them raw shows numbers that only
climb and mean nothing; each rate is computed against the previous
sample's timestamp. Three specific decisions, each with a test:

- No previous sample reads "—", not 0. "0 fps" and "I don't know yet"
  are different claims and the first one looks like a defect.
- Loss is over the INTERVAL, not cumulative. A session that lost 200
  packets in its first second and none since is healthy now; a
  cumulative ratio would keep accusing it for as long as it runs.
- Two samples sharing a timestamp render "—", not Infinity fps, which
  would read as a spectacular success.

Resolution comes off the <video>'s videoWidth/Height rather than
getStats: that is what is actually being PAINTED, and it is the number
a user can check against their own window.

18 tests, mutation-checked at the two places that matter (dropping the
byte delta, dropping the interval guard). Four interactive checks read
what is ON SCREEN rather than what getStats returns, since the point is
that the numbers reach a person.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs(findings): the upload chunk reaches the guest and vanishes
Some checks are pending
CI / Lint (pull_request) Waiting to run
CI / Container smoke test (pull_request) Waiting to run
CI / Docs link check (pull_request) Waiting to run
E2E / docker-compose + Playwright (pull_request) Waiting to run
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Waiting to run
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Waiting to run
Public security / ownership (pull_request) Waiting to run
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
b47f108f90
Records what is ESTABLISHED rather than what is suspected, because
this defect has already survived two theories.

Established by measurement: the client sends start/chunk/end (hooking
RTCDataChannel.send in the viewer's browser), start and end are both
processed by the guest (it logs the name, size and destination, and
the error arrives 16ms later from the end handler), and Uploads/ is
never created — watched at 200ms through a whole run. So the chunk is
dropped inside HandleChunk before the write is posted.

Seven theories are recorded as REFUTED with how, including one I
briefly believed and wrote into a commit message: `!seq` does not
reject seq==0, since std::optional::operator bool IS has_value(), and
cb_input_dispatch_mouse.cc depends on that for coordinates that are
legitimately zero.

The finding names its own next step, per this directory's contract:
eee0fae logs every inbound frame type and both silent drop paths, so
one run should answer in a line what two rebuild cycles could not
answer from outside.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(upload): the file IO ran on the only task runner shape with no precedent
Some checks are pending
CI / Lint (pull_request) Waiting to run
CI / Container smoke test (pull_request) Waiting to run
CI / Docs link check (pull_request) Waiting to run
CodeQL / Analyze go (pull_request) Waiting to run
CodeQL / Analyze javascript-typescript (pull_request) Waiting to run
E2E / docker-compose + Playwright (pull_request) Waiting to run
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Waiting to run
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Waiting to run
Public security / ownership (pull_request) Waiting to run
Public security / secrets (pull_request) Waiting to run
60e9c992ed
The chunk reached HandleChunk, passed every guard, was posted to the
receiver's own SequencedTaskRunner — and the file never appeared. Not
once, at 10 ms polling, across three image rolls.

That runner was built in the constructor with
base::ThreadPool::CreateSequencedTaskRunner, and it was the ONLY use
of that call anywhere in capture/. The download delegate — which
writes files in this same process and works — uses
base::ThreadPool::PostTask with explicit traits, and its own comment
says why: "no in-tree precedent is a cost paid hours later in the
build lane". It was, three times.

So all five file operations now use base::ThreadPool::PostTask /
PostTaskAndReplyWithResult with one shared constexpr TaskTraits,
matching cb_download_manager_delegate.cc:98 exactly. The traits form
is the one from base/task/task_traits.h's own doc comment.

Dropping the sequence is not free, and the comment now states what
actually makes it safe rather than hand-waving about O_APPEND (which
would not have been true for the first chunk — WriteFile truncates).
Two chunks cannot be in flight at once: writes are posted only from
HandleChunk on the UI sequence, and the seq check rejects any chunk
arriving before the previous one is counted. The client is serial too.
If either changes, this needs a sequence again, and the comment says
so.

Still UNVERIFIED: the lane has not run on this, and whether it fixes
the vanishing chunk is exactly what the next roll will say. The
diagnostics from eee0fae stay in — they are what turned "the upload is
broken" into "the chunk arrives, passes every guard, and the write
does not happen", which is what made this the obvious suspect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs(findings): the diagnostics answered it — the chunk arrives, the write does not
Some checks are pending
CI / Lint (pull_request) Waiting to run
CI / Container smoke test (pull_request) Waiting to run
CI / Docs link check (pull_request) Waiting to run
E2E / docker-compose + Playwright (pull_request) Waiting to run
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Waiting to run
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Waiting to run
Public security / ownership (pull_request) Waiting to run
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
546f14bfb4
Updates the open finding with what the eee0fae logs showed on the live
guest: file_upload_chunk arrives (139 bytes), no drop warning fires, so
HandleChunk reached its post — and the file still never exists at 10ms
polling, in a directory that mkdir+touch prove writable.

That narrows it to the task runner, and names the one thing about this
code that had no precedent anywhere in capture/. 60e9c99 changes it.

Deliberately NOT marked fixed. The next roll says whether it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(upload): file_upload_end hashed the file before the chunk had been written
Some checks are pending
CI / Lint (pull_request) Waiting to run
CI / Container smoke test (pull_request) Waiting to run
CI / Docs link check (pull_request) Waiting to run
E2E / docker-compose + Playwright (pull_request) Waiting to run
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Waiting to run
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Waiting to run
Public security / ownership (pull_request) Waiting to run
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
24ebd98010
THE cause. The task-runner theory was wrong — swapping the sequenced
runner for base::ThreadPool changed nothing, same failure, and that is
recorded rather than quietly dropped.

The client sends the last chunk and `end` back to back. The guest
handles both on the UI sequence within microseconds, while the chunk's
WRITE is a pool task that has not run yet. So HandleEnd hashed a file
that did not exist and replied "could not read the file back" — a
truthful description of a consequence that names no cause.

It was invisible from both ends, which is why it took three rolls:
OnChunkWritten's reply landed AFTER OnFinalised had erased the map
entry, so its `find` failed and it returned silently. The only
evidence anywhere was end's own error message.

Uploads now carry writes_in_flight. HandleEnd defers when it is
non-zero (setting end_pending and saying so in the log), and the last
write's reply finalises instead. FinaliseUpload is split out so both
paths share it.

OnChunkWritten's "entry already gone" branch now LOGS rather than
returning silently — that silence is precisely what hid this, and it
is the same rule as the chunk-drop paths in eee0fae: a guard whose
skipped path is silent is not a guard.

Also in this commit: the gateway's /api/current-url now returns the
page TITLE alongside the url. cdpTarget has parsed Title since it was
written and currentURL threw it away, so a streamed browser could show
an address and nothing else. Additive, so an older client is
unaffected; mutation-checked by returning an empty title.

UNVERIFIED: the lane has not run on this. What it fixes is measurable
though — tests/interactive --only uploads should reach 9/9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(upload): restore two function bodies a scripted edit ate, and lint for it
Some checks are pending
CI / Lint (pull_request) Waiting to run
CI / Container smoke test (pull_request) Waiting to run
CI / Docs link check (pull_request) Waiting to run
E2E / docker-compose + Playwright (pull_request) Waiting to run
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Waiting to run
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Waiting to run
Public security / ownership (pull_request) Waiting to run
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
c94f10e416
The lane caught this in ~13 minutes:

  ld.lld: error: undefined symbol:
    cloud_browser::CbFileUploadReceiver::OnFinalised(...)

My HandleEnd rewrite replaced a range that ran from one function to
the next and took two OTHER bodies with it. OnFinalised and
ResolveChooserWith were still declared, still called, defined nowhere.
Both restored verbatim from 546f14b.

`make lint-cxx` was clean — it checks includes. `make verify` was
clean — it cannot compile capture/. So a defect a script can find in
milliseconds cost a full lane cycle, on the one component where a
cycle is 20 minutes plus an image roll.

So: lint-cxx-orphan. It is strictly stronger than the linker, which
only notices when a CALL exists — this also catches a method declared,
defined nowhere, and not yet called, which links today and breaks
whoever adds the first caller.

Checked against the whole tree before landing (CLAUDE.md's rule). It
found two false positives on the first run — RTC_GUARDED_BY and
ABSL_GUARDED_BY, thread-safety macros that trail a member declaration
and parse exactly like a call — which are now excluded by suffix.
Clean across all 53 header/impl pairs after that. Negative control run
both ways: deleting OnFinalised's body fails it at the right symbol,
restoring it passes.

It deliberately under-reports (skipping inline bodies, pure virtuals,
= default/delete, templates) because a false positive costs more trust
than a missed defect costs time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(client): the browser tab says what page you are looking at
Some checks are pending
CI / Lint (pull_request) Waiting to run
CI / Container smoke test (pull_request) Waiting to run
CI / Docs link check (pull_request) Waiting to run
E2E / docker-compose + Playwright (pull_request) Waiting to run
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Waiting to run
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Waiting to run
Public security / ownership (pull_request) Waiting to run
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
CodeQL / Analyze go (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
cf9b09c565
The gateway parsed the page title off DevTools /json into
cdpTarget.Title and threw it away, so the client had an address and
nothing else to show — a URL bar with no page behind it, and a viewer
tab reading "chromeless — v0 client" for every session.

/api/current-url now returns it (additive, so an older client reading
only `url` is unaffected) and the client sets document.title from it.
currentPage() reads the field defensively and currentUrl() is kept as
a one-line wrapper, because this stack rolls in two pieces — the
bundle is baked into the gateway image, the worker is separate — so a
client newer than its gateway is the normal case, not an edge one.
That skew is a test, not an assumption.

A non-string title is dropped rather than rendered: the body comes
over the network and "[object Object]" in the tab is worse than no
title. Mutation-checked.

Four client tests, one Go test, 333 client tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs(findings): root cause — end hashed the file before the write ran
Some checks are pending
CI / Lint (pull_request) Waiting to run
CI / Container smoke test (pull_request) Waiting to run
CI / Docs link check (pull_request) Waiting to run
E2E / docker-compose + Playwright (pull_request) Waiting to run
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Waiting to run
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Waiting to run
Public security / ownership (pull_request) Waiting to run
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
0c85511235
Records the answer and, deliberately, the theory that was wrong: the
task-runner swap produced an identical failure and fixed nothing.
"The previous theory was refuted" is the useful half of a diagnosis
and it disappears if only the eventual fix gets written down.

The cause is a race. The client sends the last chunk and `end` back to
back; the guest handles both on the UI sequence in microseconds while
the write is a pool task, and HandleEnd never waited for it. Two
silent paths hid it from each other — OnChunkWritten's "entry already
gone" branch returned with no log, so the write's outcome was never
reported at all, and end's "could not read the file back" was the only
evidence anywhere.

Also records the cost honestly: five build-and-roll cycles, and all
three real defects in this feature were SILENT PATHS rather than wrong
computations. On a component whose feedback loop is 20 minutes plus an
image roll, a silent branch costs a full cycle every time it is hit.
The per-frame log should have been the first change, not the fourth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs: on a lane-bound component, log the wire before theorising about it
Some checks are pending
CI / Lint (pull_request) Waiting to run
CI / Container smoke test (pull_request) Waiting to run
CI / Docs link check (pull_request) Waiting to run
E2E / docker-compose + Playwright (pull_request) Waiting to run
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Waiting to run
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Waiting to run
Public security / ownership (pull_request) Waiting to run
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
CodeQL / Analyze go (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
fc84b0413f
The silent-guard rule already in this file cost five build-and-roll
cycles today, so it gets the concrete instance and the multiplier.

All three real defects in the file-upload path were silent paths, not
wrong computations, and two of them hid each other: the write's own
outcome was never logged, so every theory was about why the write
failed when it had simply not run yet.

One LOG(INFO) per inbound frame answered in a single run what four
cycles of static reasoning could not. It was the fourth change made;
it should have been the first, and that ordering is the point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs(interactive): "65 of 65" is stale, and a bare total is the wrong criterion
Some checks are pending
CI / Lint (pull_request) Waiting to run
CI / Container smoke test (pull_request) Waiting to run
CI / Docs link check (pull_request) Waiting to run
E2E / docker-compose + Playwright (pull_request) Waiting to run
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Waiting to run
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Waiting to run
Public security / ownership (pull_request) Waiting to run
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
9f7e8e7719
The suite grew to 91 check() sites today — uploads is new (12) and stats
went 6 → 13 (audio reception, the unmute control, the HUD) — so the
README's headline number was about to send the next reader looking for
a regression that is just arithmetic.

Replaced with the criterion that actually holds: ZERO FAILED lines.
Suites skip whole blocks when a preflight says the guest binary or the
served gateway bundle predates a feature, and those skips are reported
rather than silently counted, so the printed total legitimately varies
between runs on different images.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
diag(upload): the write SUCCEEDS and the read of the same path fails
All checks were successful
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
CodeQL / Analyze go (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
CI / Container smoke test (pull_request) Successful in 1m9s
CI / Docs link check (pull_request) Successful in 16s
CI / Lint (pull_request) Successful in 5m44s
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Successful in 1m10s
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Successful in 12s
E2E / docker-compose + Playwright (pull_request) Successful in 5m8s
Public security / ownership (pull_request) Successful in 5s
b1cf3697ae
The race fix worked and the upload still fails, which is progress: the
log now proves what the previous four cycles could not.

  <- file_upload_chunk
  <- file_upload_end
  end ... arrived with 1 write(s) still in flight; finalising when they land
  failed (write_failed): could not read the file back

The deferral branch was taken, so FinaliseUpload's only possible caller
was OnChunkWritten — which reaches it only on ok==true (ok==false goes
to AbandonUpload with a different message). Therefore AppendChunk
returned TRUE and HashFile's ReadFileToString then returned FALSE, on
the same path, about a millisecond apart.

That is a new and much narrower fact, and three explanations remain:
the file is written and removed in between, the write returns true
without creating a file, or the two paths are not the same string.

So this commit distinguishes all three in ONE run rather than three:

- AppendChunk logs which call it made, whether it returned true, and
  base::GetFileSize of the path immediately after — a write that
  returns true and leaves no file is exactly the shape being chased.
- HashFile logs, on failure, whether the file is ABSENT or present and
  unreadable. Those are different bugs (a delete racing the read
  versus permissions) and "could not read the file back" covers both.
- FinaliseUpload logs the path it is about to hash, so it can be
  compared byte-for-byte with the one AppendChunk wrote.

This is the lesson from the last four cycles applied deliberately:
instrument every branch that could explain the observation, rather
than pick the most likely one and spend a roll on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(upload): a use-after-move reported every successful upload as a failure
All checks were successful
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
CI / Lint (pull_request) Successful in 3m28s
CI / Docs link check (pull_request) Successful in 7s
CI / Container smoke test (pull_request) Successful in 1m59s
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Successful in 14s
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Successful in 13s
Public security / ownership (pull_request) Successful in 6s
E2E / docker-compose + Playwright (pull_request) Successful in 2m28s
f9deaaa7ef
THE cause, found by instrumenting rather than theorising:

  CV2-UPLOAD: WriteFile -> ok, 27 bytes; file is now 27 at .../Uploads/...
  CV2-UPLOAD: finalising ...; hashing .../Uploads/...
  CV2-UPLOAD: upload ... failed: could not read the file back

The write succeeded. The file was 27 bytes on disk. And the ERROR I had
added to HashFile's read-failure path never printed once — so the read
succeeded too. Both halves worked and the caller still saw failure.

    self->OnFinalised(std::move(id), std::move(p),
                      std::move(result.first), result.second,
                      !result.first.empty());        // argument 5

`result.first` is moved into argument 3 and read in argument 5. C++
leaves argument evaluation order UNSPECIFIED; on this toolchain
argument 3 won, so `ok` was computed from a moved-from empty string.
Every upload reported failure while the file sat correct on disk.

Compute the flag into a named local first. Never read a value in the
same call that moves it.

Five image rolls went into this, and the reason is that the error
message named a cause that had not happened: "could not read the file
back" sent me through the filesystem, the task runner, and a genuine
end/write race (that one was real, and its fix stands) before the line
itself. So the message is now `hash_failed` and says only what it
knows, with the read logging its own failure separately. The spec's
code table records the old lie.

Two things landed to make the class cheap next time:

- `lint-cxx-use-after-move`. Only the UNSEQUENCED case — a move and a
  read of one name inside a single argument list. A first version that
  ignored statement boundaries produced 14 findings, ALL false
  (`auto x = std::move(m_); m_.clear();` is legal and deliberate), and
  a second matched `chunk_bytes` as a read of `bytes`. Both fixed;
  clean across 76 files, negative control reintroduces the bug and it
  fails at the right line.
- The AppendChunk / HashFile / FinaliseUpload logging that produced
  the three facts above in ONE run, after four cycles of reasoning
  from the outside had produced none.

UNVERIFIED: the lane has not run on this. The uploads suite should
reach 9/9; that is the next roll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(upload): report the re-arm wipe racing an in-flight chunk write
Some checks are pending
CI / Lint (pull_request) Waiting to run
CI / Container smoke test (pull_request) Waiting to run
CI / Docs link check (pull_request) Waiting to run
CodeQL / Analyze go (pull_request) Waiting to run
CodeQL / Analyze javascript-typescript (pull_request) Waiting to run
E2E / docker-compose + Playwright (pull_request) Waiting to run
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Waiting to run
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Waiting to run
Public security / ownership (pull_request) Waiting to run
Public security / secrets (pull_request) Waiting to run
a3469929de
A regression I introduced, so it should not ship silently.

ResetForNewSession posts DeletePathRecursively to the pool while a
chunk write may still be queued there. Both are base::ThreadPool tasks
with no shared sequence, so the delete can run FIRST and the late
write then re-creates the directory and leaves an orphan the next
session never wipes.

The sequenced runner ordered these implicitly. I removed it chasing
the theory that it was why uploads failed — it was not (the cause was
a use-after-move, f9deaaa), so the removal fixed nothing and cost this
ordering. The runner stays removed because it was also the only
CreateSequencedTaskRunner in capture/ and the download delegate's
shape is the one that demonstrably works here.

This LOGS rather than blocks, deliberately: the wipe is best-effort
housekeeping, an orphan is bounded by the per-session byte quota, and
delaying re-arm on file IO would trade a rare stale file for a slower
handover on every viewer change. The count comes from the map, which
is the only record of what is outstanding, so it is read before the
clear. If that warning ever appears in a real log, that is the signal
to make the delete actually wait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs(findings): the cause was a use-after-move, and two theories were wrong
All checks were successful
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
CI / Container smoke test (pull_request) Successful in 30s
CI / Docs link check (pull_request) Successful in 7s
E2E / docker-compose + Playwright (pull_request) Successful in 1m24s
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Successful in 11s
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Successful in 10s
Public security / ownership (pull_request) Successful in 5s
CI / Lint (pull_request) Successful in 4m56s
f54903e3ed
Records the answer with the evidence that produced it — the write
logged 27 bytes on disk, and the read-failure ERROR printed zero times
while its two sibling lines printed, so the probe worked and the read
genuinely never failed.

Both earlier theories stay written down. The task-runner swap fixed
nothing; the end/write race was real and its fix stands but was not
this. "Refuted" is the half of a diagnosis that disappears if only the
winning answer is recorded, and this defect burned three rolls on
theories that each fit the evidence available at the time.

Also names why it cost five rolls: OnFinalised reported "could not
read the file back" for a read that had never failed, and that one
string steered the search through the filesystem, permissions and the
scheduler before the line itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
build: the kaniko push deadline was 300s and the layer takes longer than that
All checks were successful
CodeQL / Analyze go (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
CI / Docs link check (pull_request) Successful in 7s
CI / Container smoke test (pull_request) Successful in 25s
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Successful in 11s
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Successful in 26s
Public security / ownership (pull_request) Successful in 6s
CI / Lint (pull_request) Successful in 3m31s
E2E / docker-compose + Playwright (pull_request) Successful in 2m59s
a4ad29cb75
Two pushes of the same image failed in a row today, and neither was a
push error. The Job event says DeadlineExceeded and the registry says:

  error msg="client disconnected during blob PATCH"
    contentLength=210758335 copied=172730490 error="unexpected EOF"

82% of a 210 MB layer transferred, then activeDeadlineSeconds severed
it. The comment above that setting asserted "cold layer push is
~30-60s"; the eight successful pushes earlier the same day took
109s-4m21s, so the headroom was already thin and something — registry
load, node egress — pushed it over.

300s -> 900s, which is ~4x the slowest observed success. The deadline
still stops a genuinely wedged job from holding node capacity; it just
no longer kills one that is merely slow.

Two things that made this read as a code problem, both fixed:

- The script's `wait --for=condition=complete` was 600s, UNDER the
  Job's own deadline, so a slow push returned "indeterminate" before
  the Job had decided anything.
- A DeadlineExceeded failure printed the same "✗ kaniko-push FAILED"
  as a credential or Dockerfile error. It now names the reason and
  points at the registry log line above. The tell is that the last log
  line is "Pushing image to ..." with no error after it.

Found while chasing a deploy of my own that I should also record: I
read a failed push as still-running from tail output, then piped an
empty digest capture into `kubectl set image`, which the API accepted
and left the worker in InvalidImageName for ~2 minutes. Restored from
the previous digest. The script's verdict line was in the file I was
already reading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
chore(cv2-build): pin cr7727-f9deaaa7efc6 — uploads verified 9/9 live
All checks were successful
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
CI / Lint (pull_request) Successful in 4m49s
CI / Docs link check (pull_request) Successful in 28s
CI / Container smoke test (pull_request) Successful in 3m12s
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Successful in 13s
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Successful in 13s
Public security / ownership (pull_request) Successful in 6s
E2E / docker-compose + Playwright (pull_request) Successful in 5m16s
01fe711803
`<input type=file>` works end to end on a real guest, not merely
compiled:

  PASS  the file reached the page's own <input>
  PASS  the page got a File, not an empty selection
  PASS  the page sees the ORIGINAL filename   chromeless-upload-probe.txt
  PASS  the file is the right size   27 bytes
  PASS  the page can read the file's contents   contents match byte for byte

and the guest log ends "complete: 27 bytes, sha ok, attached to the
page's file chooser". The race-deferral line appears in that same log,
so both fixes are exercised by the passing run.

Full suite on this image + a gateway rebuilt from this branch: 82
PASS, 1 FAIL. The failure is the KNOWN audio-after-re-arm defect, and
that is established rather than assumed — audio passed with 62 KB
earlier today on a fresh worker, this pod had served 17 sessions, and
restarting it made audio pass again (8361 bytes). It is
docs/findings/audio-dies-after-first-rearm.md, not a regression from
this branch.

Both files in one commit, per the push script's warning: recording
only the json is what made main drift before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
triform-admin force-pushed capture/batch-b-file-chooser from 01fe711803
All checks were successful
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
CI / Lint (pull_request) Successful in 4m49s
CI / Docs link check (pull_request) Successful in 28s
CI / Container smoke test (pull_request) Successful in 3m12s
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Successful in 13s
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Successful in 13s
Public security / ownership (pull_request) Successful in 6s
E2E / docker-compose + Playwright (pull_request) Successful in 5m16s
to 88dea1eb51
All checks were successful
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
Public security / secrets (pull_request) Has been skipped
CI / Docs link check (pull_request) Successful in 19s
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Successful in 3m50s
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Successful in 12s
CI / Container smoke test (pull_request) Successful in 4m36s
Public security / ownership (pull_request) Successful in 4s
CI / Lint (pull_request) Successful in 4m46s
E2E / docker-compose + Playwright (pull_request) Successful in 9m11s
2026-09-09 11:45:10 +00:00
Compare
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
triform/chromeless!109
No description provided.