Add a start/stop controller for the S2 storage sink - #401
Merged
Merged
Conversation
The writer is opened at boot today, which leaves no way to decide per session whether events are persisted at all. Wrap it in a controller that resolves the stream lazily and opens at most one writer, so a later change can start it from the telemetry handler instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
archandatta
marked this pull request as ready for review
September 18, 2026 14:25
Sayan-
requested changes
Sep 18, 2026
Sayan-
left a comment
Contributor
There was a problem hiding this comment.
- p1:
S2StorageController.Stopclears its writer even when shutdown exits before draining or closing storage.everStartedthen prevents reopening, and later stops cannot reach the live writer. Reproduced 20/20 under race with a blocked append. - p2: the controller holds
muacrossstreamFn, writer startup, and the full stop. A blocked callback or stop makesStop(ctx)exceed its deadline while waiting for the lock and blocks both state accessors. Reproduced 20/20 under race. - p2:
Startlogs “S2 storage enabled” before startup succeeds. A canceled parent returns an error with both state flags false while retaining the enabled log. Reproduced 20/20 with captured logging.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 4e921bb. Configure here.
| c.log.Info("S2 storage enabled", "basin", c.basin, "stream", stream) | ||
| c.mu.Lock() | ||
| c.writer, c.cancel, c.everStarted = w, cancel, true | ||
| c.mu.Unlock() |
There was a problem hiding this comment.
Writer can start after Stop returns
Medium Severity
If Stop times out while Start is still opening the append session, Start still publishes the writer afterward. Running is false when Stop returns, so a caller can treat the sink as down and later see events forwarded anyway.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 4e921bb. Configure here.
Sayan-
reviewed
Sep 21, 2026
Sayan-
left a comment
Contributor
There was a problem hiding this comment.
- p2: concurrent
Startcalls report success before the in-flight start finishes. If the leader later fails, the follower has already returnednilwhileRunning()andEverStarted()remain false. This reproduces under-raceand is not covered by the current successful-concurrency test.
archandatta
added this pull request to stack #406
September 21, 2026 19:07
archandatta
added a commit
that referenced
this pull request
Sep 22, 2026
Stacked on #401, which adds `events.S2StorageController`. This layer replaces the ad-hoc `atomic.Pointer[events.S2StorageWriter]`, `sync.WaitGroup`, and `startS2Writer` lifecycle in `cmd/api/main.go` with that controller, leaving `main.go` net **−6 lines**. The sink still starts at boot when the instance is not waiting for a fork identity or has already applied one, and otherwise from the fork-identity hook, off the handoff critical path per the contract documented on `forkIdentityHandler`. `api.New` also takes the controller behind a new `S2Storage` interface for the later telemetry-side integration; nothing reads it yet. Stream selection keeps both existing paths, and keeps the hook's payload authoritative by construction rather than by disk state. Boot and API-process restarts resolve the persisted applied payload through `appliedS2Stream`. A successful fork hook stores its own payload's `S2_STREAM` as an in-process override before starting the controller, so resolution no longer depends on `appliedS2Stream` re-reading `ReadyFile`, the applied marker, and the payload file. That matters because the controller binds one stream for the life of the instance and never retries: had that re-read fallen through to the boot `config.S2Stream`, a fork would have written its telemetry into the stream of the instance it forked from, permanently and silently. The wrapper writes `ReadyFile` before starting the API whenever fork-identity wait is armed, so this closes a latent failure mode rather than an observed production failure. The override is atomic because resolution and the asynchronous start run on different goroutines. A missing `S2_STREAM` still falls back to the boot configuration. Shutdown still drains the HTTP servers first, then S2, then OTLP. The controller waits for an in-flight start and bounds the whole stop with the existing 10-second context. The old 2-second abandonment guard is gone: in `s2-sdk-go` v0.22.1, `AppendSession` (`s2/append_session.go:54`) only starts a pump goroutine and returns, and the transport session is created from `processInflightQueue` after the first record submit, so `S2StorageWriter.Start` never holds `w.mu` across a network dial. That guard was skipping the drain — losing the shutdown window's events — to avoid a block that cannot occur. Existing behavior is otherwise unchanged: missing credentials or a missing stream keep the sink closed, concurrent starts open at most one writer, a failed start stays retryable, a successful start emits exactly one `S2 storage enabled` line, and a boot failure in the optional sink does not crashloop the browser. `S2StorageWriter`, `StorageWriter`, the telemetry handlers, and storage configuration are untouched. Deferring startup to `PUT /telemetry` remains a later change. Tests cover boot resolution, applied-identity restarts, the missing-ready-file hook path, and the surrounding API packages. `go build ./...` and `go vet ./...` are clean at `0fe7b21`. `go test $(go list ./... | grep -v /e2e$) -count=1 -race` passes all **38** non-e2e packages; an earlier run hit `TestUpstreamManagerDetectsChromiumAndRestart` in `lib/devtoolsproxy` on its own `t.TempDir()` teardown racing a Chromium profile directory — a package this PR does not touch — and it has not recurred at `-count=3`. `TestS2StreamResolverUsesHookPayloadWithoutReadyFile` fails under the pre-fix mutation, returning `seed-stream` instead of `fork-stream`. A headless image built from `0fe7b21` was booted in three modes, reading the API log at `/var/log/supervisord/kernel-images-api` rather than `docker logs`: with S2 unset, **0** `S2 storage enabled` lines; with fake credentials, exactly **1**; and with `KERNEL_FORK_IDENTITY_WAIT=true` plus a seed `S2_STREAM=a2-seed-stream`, **0** at boot and exactly **1** after `POST /internal/fork-identity` returned 204, bound to the payload's `a2-fork-stream` rather than the seed. In each mode `GET /spec.yaml` and `GET /telemetry` returned 200, `PUT /telemetry` 201, `POST /telemetry/events` 200 with the envelope arriving on `GET /telemetry/stream`, and `docker stop -t 30` exited 0 with no drain warning. The full e2e suite passes against locally built headless and headful images: `go test ./e2e/ -count=1 -timeout 110m` with `E2E_CHROMIUM_HEADLESS_IMAGE` and `E2E_CHROMIUM_HEADFUL_IMAGE` set gives **52 passed, 0 failed, 2 skipped** (34 subtests, all passing). `TestOTLPExportForkIdentityRefresh` is the closest analogue to this change: it drives the real `/internal/fork-identity` endpoint and confirms the sibling sink retargets after the handoff. Of the two skips, `TestReplayRecordingZombocomArchiveAudio` needs a network fixture, and `TestS2StorageWriter` (`server/e2e/e2e_s2_storage_test.go`) self-skips unless `S2_BASIN`, `S2_ACCESS_TOKEN`, and `S2_STREAM` are set — no S2 credentials are available here, so nothing has verified a record landing in a *real* S2 stream. That last gap was closed against a local mock instead. The SDK's append session is a bidirectional protobuf stream, so a stand-in basin serving h2c was pointed at via `S2_BASIN_ENDPOINT`, which required a throwaway local patch (`newS2Storage` passes `nil` where `s2.LoadConfigFromEnv()` would be needed) that was **not committed**. Against it: three published events arrived as one batched append; three more published immediately before `SIGTERM` all arrived during the drain, with the API log showing `shutdown signal received` and no `stop failed`, `drain incomplete`, or `ack failed`; and a fork container seeded with `S2_STREAM=seed-stream` opened its session on `/v1/streams/fork-stream/records` after applying a payload naming `fork-stream`. The mock also timestamps the dial: `S2 storage enabled` logged at `14:18:35` and the append session opened only on first record submit, confirming the lazy-dial premise for removing the 2-second guard. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes telemetry persistence lifecycle and fork-time S2 stream selection; a wrong bind is permanent, though behavior is covered by new tests and matches prior boot/defer semantics. > > **Overview** > **Replaces the ad-hoc S2 writer lifecycle in `cmd/api/main.go`** (`atomic.Pointer`, `WaitGroup`, `startS2Writer`) with `events.S2StorageController`, wired through boot, fork-identity hook, and shutdown (HTTP drain first, then bounded `Stop`). > > **Adds `s2StreamResolver`** so stream name comes from persisted `appliedS2Stream` on boot/restart, and from an **in-process atomic override** when the fork hook runs—so the controller binds the payload’s `S2_STREAM` even before `ReadyFile`/disk state would make `appliedS2Stream` trustworthy (avoids silently appending to the parent’s stream). > > **Plumbs the controller into `api.New`** via a new `S2Storage` interface on `ApiService` (parallel to OTLP); handlers do not use it yet. Tests gain a hook-path case (`TestS2StreamResolverUsesHookPayloadWithoutReadyFile`) and updated `New(..., nil, nil)` call sites. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 68dabd8. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
archandatta
added a commit
that referenced
this pull request
Sep 23, 2026
**An instance whose telemetry sessions ask for `storage.enabled=false`
never opens its S2 append session, and S2 never receives an event that
was captured with storage off.**
`BrowserTelemetryConfig` gains `storage: { enabled }`, a sibling of
`export`. An omitted block means **true**, the opposite of `export`, so
callers that predate the field keep storing. In a PUT an omitted block
means on; in a PATCH an omitted field leaves the current value
unchanged. `TelemetryState.config` echoes it on GET, PUT and PATCH,
including on the cleared config a stop request returns, which reflects
the toggle that request carried. Callers can compare the echo to what
they sent. That matters because the strict handler decodes with plain
`encoding/json`: only `POST /repl` rejects unknown fields, so an image
that predates this field accepts `storage` and returns 201 without
honoring it.
## When the sink opens
`main` no longer starts the controller at boot or from the fork-identity
hook. `reconcileStorage` is deferred from PUT and PATCH the same way as
`reconcileExport`. It opens the sink once a capture session is active
with storage on, and it never closes it. The writer is single-use and
binds its stream for the life of the instance, so it still stops only at
shutdown. Because it can't be closed, a PUT or PATCH whose resulting
config has storage off gets a **409** once the sink has ever opened, and
the 409 is returned before anything is committed. A stop PUT carrying
`storage.enabled=false` on an instance that has stored is refused the
same way, since it asks for a state the instance can no longer honor. A
stop PUT that omits storage still clears. A failed start is logged and
retried by the next request, matching export.
Every PUT and PATCH takes `storageMu` before the storage check and holds
it until the config is committed or rolled back. `reconcileStorage`
holds the same lock across its read of the session and `Start`, so it
only ever reads a settled config. That closes two races:
- **Guard vs start.** A storage-off config committed while a start was
in flight would leave storage off configured with the sink open.
- **Provisional commit vs rollback.** A storage-on update commits before
capture is applied. A `reconcileStorage` deferred from an earlier
request could read that provisional config and open the sink. If capture
then failed, the request would roll back to storage off with the sink
open.
The lock order is always `monitorMu` then `storageMu`, and
`reconcileStorage` never takes `monitorMu`. Unlike `exportMu`,
`storageMu` is taken under `monitorMu`, so a PUT or PATCH can hold
`monitorMu` while it waits for a `Start` that is running. `Start` does
not dial, because `s2-sdk-go` opens the transport on first submit, so
that wait is short.
## Why the writer now starts after a seq
`S2StorageWriter` read the ring from its oldest event, which is correct
when the sink opens at boot. With the start deferred to the handler,
that behavior would copy events from a storage-off session into S2 when
a later storage-on config opened the sink. This covers a storage-off
session followed by storage-on in the same session, and a cleared
storage-off session followed by a storing one. `TelemetrySession` now
records the last seq published before storage was turned on for the
current session. It reads that seq under the session mutex that
`Publish` holds, so the boundary is exact. `S2StorageController.Start`
and `NewS2StorageWriter` take it as `afterSeq`, and the writer reads the
ring after it. The first storing session on a fresh process still stores
from seq 0, since nothing reaches the ring without a session:
`TelemetrySession` is its only publisher and drops everything while
inactive. Deferring the start therefore loses nothing. A start that
failed and is retried keeps the session's original floor, so events
captured with storage on but not yet stored are still sent.
This changes the controller's `Start` signature, and
`S2StorageWriter.Start` is split so the forwarding half can run against
a mock backend. The controller tests from #401 now pass `0`. New tests
assert that the writer forwards exactly from `afterSeq+1`, and that a
controller started after the ring head reads nothing.
## Forks
The fork hook now only records the applied payload's `S2_STREAM`
in-process, and the handoff no longer starts anything. While the
fork-identity wait is armed and no identity has been applied, the
resolver returns no stream. A storing config applied before the handoff
therefore leaves the sink closed and logs a warning, where the old code
would have bound the parent's stream. The next PUT or PATCH after the
handoff opens the sink on the fork's stream. If no config follows the
handoff, the sink stays closed until one does. A fork's boot `S2_STREAM`
belongs to the parent, so an applied payload without `s2_stream` now
leaves storage closed. #404 fell back to the boot value in that case.
The platform always sends `s2_stream` as the fork's own instance name,
so the fallback could only ever bind the parent's stream.
Unchanged: shutdown order and the bounded `Stop`, missing credentials or
stream keeping the sink closed, export semantics, and the resolver's
restart path through the persisted applied payload.
## Testing
- `go build ./...`, `go vet ./...` — clean
- `go test $(go list ./... | grep -v /e2e$) -count=1 -race` — every
package passes at head. An earlier run hit
`TestUpstreamManagerDetectsChromiumAndRestart` in `lib/devtoolsproxy`,
which this PR does not touch, on the same `t.TempDir` teardown race #404
reported. It passed `-count=3` on rerun.
- New tests cover the never-start path, a single start across repeated
PUT and PATCH, the 409 on PUT, PATCH and a clearing PUT with config,
`seq` and `applied_at` unchanged, echoes on every response, the start
floor after storage-off capture, the retry floor, and the writer's exact
first seq. They also cover a storage-off request waiting on an in-flight
start, and a deferred reconcile waiting out a storage-on PUT or PATCH
that rolls back. Finally, they cover no ring publish without a session,
and the resolver withholding the parent stream while a fork is pending
or its payload has no stream. Each guarded line was mutated and its test
failed: the lock scope, an early lock release, each 409, the floor off
by one in both directions, and both fork fallbacks. One check turned out
redundant and was removed.
- Headless image built from this branch, API log read at
`/var/log/supervisord/kernel-images-api`:
- S2 unset: 0 `S2 storage enabled` at boot; `PUT` network 201 and still
0; `GET` echoes `storage.enabled=true`
- fake S2: 0 at boot; `PUT` with storage omitted 201 and 1; `POST
/telemetry/events` 200 and the event arrives on `/telemetry/stream`;
storage-off `PUT`, `PATCH`, and clearing `PUT` all 409; `GET` afterward
still `true`
- fresh fake S2: storage-off `PUT` 201 and 0; two events published (seqs
1–2) reach the stream with still 0 lines; `PUT` storage omitted 200 and
1; the next event is seq 3, and S2 received **only seq 3**, as shown by
the SDK's per-record ack errors against the fake basin
- fork wait with seed `S2_STREAM=seed-s`: 0 at boot; `POST
/internal/fork-identity` 204 and still 0; `PUT` 201 and 1, bound to
`stream=fork-s`
- fork wait, storing `PUT` before the handoff: 201, 0 lines, and one
pending warning; after the handoff a `PATCH` opens it on `stream=fork-s`
- fork wait, payload without `s2_stream`: handoff 204, then `PUT` 201
with 0 lines
- `docker stop -t 30` in both fake-S2 modes: exit 0, no drain warning.
One `s2 storage writer stop failed` line appears because the fake
basin's host does not resolve. The main image logs the same line under
the same steps, and this image logs none when no event was sent.
- `go test ./e2e/ -count=1 -timeout 110m` against headless and headful
images built from this branch: **52 passed, 0 failed, 2 skipped** (34
subtests pass), the same as #404 reported for main. The skips are
`TestReplayRecordingZombocomArchiveAudio`, which needs a network
fixture, and `TestS2StorageWriter`, which needs real S2 credentials.
`TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1` was set because this machine
could not resolve `localhost`.
Not run: real S2 credentials, which were not available. Records reaching
a real stream were checked only through the fake basin's per-record ack
errors.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Medium Risk**
> Changes when and what telemetry is persisted to S2 and fork stream
binding, which affects data isolation on forks and irreversible
storage-on instances; extensive tests mitigate regressions.
>
> **Overview**
> Adds a **`storage.enabled`** toggle to telemetry (default **on** when
omitted, unlike export) and defers opening the S2 append sink until the
first capture session that wants storage, via **`reconcileStorage`** on
PUT/PATCH instead of at API boot or on fork handoff.
>
> Once the sink has opened it cannot be turned off: disabling storage
returns **409** with the config unchanged. **`storageMu`** keeps the
disable check and deferred reconcile aligned with committed (or
rolled-back) config so an in-flight start or a failed capture apply
cannot leave storage off with the sink open.
>
> The writer now starts with an **`afterSeq`** floor from the session so
ring events captured while storage was off are not persisted when
storage is enabled later. Fork stream resolution no longer binds the
parent’s boot **`S2_STREAM`** while identity is pending or when the
applied payload has no stream.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
829b0f7. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Summary
S2StorageControllerinserver/lib/events/s2storage.go, a start/stop wrapper aroundS2StorageWriterthat resolves the stream name throughstreamFnatStartRunning()andEverStarted()so later callers can distinguish active forwarding from any prior S2 persistenceStart, failed-start rollback, empty config,StopwithoutStart, and post-Stopstate with unit testsWhy
An upcoming telemetry mode needs a browser instance to forward selected events without ever opening its S2 append session.
cmd/api/main.gostill opensS2StorageWriterdirectly at the existing boot and fork-identity start points; this branch only adds the lifecycle owner that a later change can wire in.Existing behavior is unchanged:
main.go,api.go,S2StorageWriter,StorageWriter, and S2 read-from-seq-0 behavior are untouched.Testing
cd server && GOCACHE=/tmp/go-build-cache GOMODCACHE=/tmp/go-mod-cache go test ./lib/events/ -run '^TestS2StorageController_' -count=20 -race -shuffle=on— passed, including concurrent failed-start result propagationcd server && GOCACHE=/tmp/go-build-cache GOMODCACHE=/tmp/go-mod-cache go test ./lib/events/ -count=1 -race— ok, includes new controller lifecycle and concurrency testscd server && GOCACHE=/tmp/go-build-cache GOMODCACHE=/tmp/go-mod-cache go build ./...— passedcd server && GOCACHE=/tmp/go-build-cache GOMODCACHE=/tmp/go-mod-cache go vet ./...— no findingsDOCKER_BUILDKIT=1 docker build -f images/chromium-headless/image/Dockerfile -t kernel-headless-test .— succeeded, imagesha256:810e301828db810062764099944738bdb2e647978f0c66d68fb3f7d72141b078S2_BASIN,S2_ACCESS_TOKEN, andS2_STREAMunset —GET /spec.yaml200, zeroS2 storagelines in/var/log/supervisord/kernel-images-apiS2_BASIN,S2_ACCESS_TOKEN, andS2_STREAM—GET /spec.yaml200, oneS2 storage enabledline in/var/log/supervisord/kernel-images-apiPUT /telemetry201,GET /telemetry200,POST /telemetry/events200, andGET /telemetry/stream?replay=alldelivered the posted event framedocker stop -t 30in both S2 modes — cleanshutdown signal received, zerodrain incomplete/drain deadline exceededwarningsNot run: real S2 credentials; none were available, so the S2-enabled live check used fake values and produced the expected submit ack error after posting an event.
Note
Low Risk
Additive lifecycle wrapper and tests only; no changes to existing boot paths or S2 writer behavior until a follow-up wires the controller in.
Overview
Introduces
S2StorageController, a mutex-guarded start/stop owner aroundS2StorageWriterso S2 append sessions can be opened only when needed (e.g. telemetry mode) instead of at boot.Startresolves the stream viastreamFn(for forks that learn the stream later), skips work when basin/token or stream are empty, opens at most one writer, and serializes concurrent starts.Stopdrains the writer, honors context during in-flight start/stop, and leavesEverStarted()true after a successful start so callers can tell if anything may have been persisted—even though a laterStartafterStopdoes not reopen the sink.Adds unit tests for idempotency, concurrency, failed-start rollback, logging, and stop/context behavior. Production wiring is unchanged in this PR;
mainstill usesS2StorageWriterdirectly.Reviewed by Cursor Bugbot for commit 7081930. Bugbot is set up for automated code reviews on this repo. Configure here.