Skip to content

release: promote the macOS runtime split and the sidecar-path fix to main - #230

Merged
dvcdsys merged 14 commits into
mainfrom
develop
Aug 10, 2026
Merged

release: promote the macOS runtime split and the sidecar-path fix to main#230
dvcdsys merged 14 commits into
mainfrom
develop

Conversation

@dvcdsys

@dvcdsys dvcdsys commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Promotion for server/v0.12.9.

What ships

fix(server): re-derive the llama sidecar paths on every boot. A real bug,
independent of macOS. cix-server persists its embedding provider config on
first boot and treats the stored blob as authoritative afterwards — including
bin_dir (from os.Executable()) and socket_path (from os.Getpid()), which
describe the running process rather than anything a person chose. Frozen at
first boot, bin_dir keeps a moved or upgraded install launching the old
llama-server until that directory is deleted, at which point embeddings stop
with no configuration having changed; a frozen socket_path lets a new server
adopt an orphaned llama-server already bound to that name instead of spawning
its own. Both fields are already documented as deployment-level and are absent
from the dashboard's edit schema — this makes that true on the second boot too.
Everything a person can choose still comes from the database untouched.

build(server): stamp the binary version. make bundle passed no ldflags,
so every non-Docker build reported the zero version. It also fixes an unfiltered
git describe that labelled server images with CLI versions.

A new release asset. release-server.yml grows a macos-runtime job on
macos-latest that packages cix-server, the cix CLI and the Metal
llama-server as cix-runtime-<version>-darwin-arm64.tar.gz, and attaches it
with a checksums.txt to the GitHub release. It is the same server as the
Docker images, from the same tag and the same run.

The macOS app, which ships nothing yet — no mac/v* tag is being cut here.
Its code lands on main so the launcher can be tagged separately once this
release is confirmed working.

Ordering

Deliberately staged: cut server/v0.12.9 first, verify the runtime tarball
installs and runs, then tag mac/v* for the app.

Note for the release run

release now depends on macos-runtime. If the macOS job fails the Docker
images still push — those jobs are independent — but the GitHub release is not
created until it is re-run. That is intentional: a release without the runtime
attached is one no Mac can install from.

🤖 Generated with Claude Code

dvcdsys and others added 13 commits August 10, 2026 12:05
Two prerequisites for packaging a macOS .app, both of which make a release
build meaningfully different from a developer build.

Version stamping. `make build` passed no ldflags, so every non-Docker build
(make bundle, install-server.sh --mode native, and now the .app) shipped a
cix-server that could not report what it was — only server/Dockerfile stamped
-X main.version. Mirror the Dockerfile's flags exactly: -trimpath, -w, and no
-s, since govulncheck -mode=binary needs the Go symbol table.

The version source needed fixing too. `git describe --tags` has no --match
filter, and this repo has three tag streams; on develop it resolved to a *CLI*
tag, so a locally built *server* image was labelled with a CLI version. CI
never hit this because release-server.yml passes VERSION= explicitly, which is
why it went unnoticed. Filter to server/v* and strip the prefix.

fetch-llama strict mode. The script records a missing checksum and continues,
which is right when a contributor is bootstrapping a LLAMA_VERSION bump and
wrong in a build that ships: the recorded value is computed from the download
it is meant to verify, so it verifies nothing. LLAMA_STRICT=1 fails instead,
and does so before spending the ~50 MB download. Default stays 0. The header
comment claimed downstream CI already failed hard on this; nothing did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a third, independent tag stream. `mac/vX.Y.Z` builds a single Apple
Silicon .app carrying cix-server, the cix CLI and a Metal-accelerated
llama-server, published as a drag-to-Applications DMG. The versions of the two
bundled binaries are stamped separately and recorded in Info.plist, because the
app is versioned by what it does, not by what it happens to contain.

This release is the pipeline, not the product. The menu-bar interface —
start/stop, provider status, dashboard link, password reset, autostart and
self-update — lands in later mac/v* releases. What ships now is a bundle that
assembles, signs and verifies, plus enough of a front end (`cix-launcher
-report`) to prove each component is the one the build intended.

Layout is constrained, not chosen. Every executable sits in Contents/MacOS,
including llama/: codesign --verify --strict rejects executable code under
Resources/, and cix-server resolves llama-server at
filepath.Dir(os.Executable())/llama, so keeping them siblings means
CIX_LLAMA_BIN_DIR never has to be set.

Signing is ad-hoc and bottom-up. There is no paid Apple Developer membership,
so there is nothing to notarize — but ad-hoc signing is still mandatory, since
Apple Silicon refuses to run unsigned code at all. `xattr -cr` runs on every
build, not once: server/Makefile already records that macOS 26 amfid SIGKILLs
an ad-hoc-signed binary whose dylibs carry a stale signature or a
com.apple.provenance xattr, with empty stderr, and every cp into the staging
tree recreates those conditions. --deep is avoided (deprecated, and unreliable
for a bundle with four executables and ~35 dylibs directly in MacOS/).

Consequences that are documented rather than worked around: Gatekeeper blocks
the first launch and macOS 15+ removed the right-click→Open shortcut, so the
System Settings route ships in the DMG, the release body and doc/MACOS_APP.md;
Homebrew Cask is not an option, as support for casks failing Gatekeeper ends
2026-09-01; and there is no Intel build, because upstream llama.cpp publishes
no macOS x86_64 asset — build-app.sh refuses rather than producing a bundle
that dies at the first embedding.

The launcher lives at cli/launcher/ inside the cli module so it can import
cli/internal/{client,config} directly — Go's internal rule is directory-scoped,
and a separate module would force duplicating both packages. A non-darwin stub
keeps `go build ./...` green on ubuntu in ci-cli.yml.

Icons are placeholders, replaceable by dropping in three files with no code
change. make-placeholder-icons.py regenerates them so they are reproducible
rather than mystery binaries; it is stdlib-only and no build ever runs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the placeholder artwork with the delivered CIX icon set and puts the
DMG window together the way the design assumes.

The set is split by role. Cream magnifier = the app, red magnifier with a "+" =
the installer; that inversion is the only thing telling the two files apart in
a Downloads folder, so the red one becomes the DMG's volume icon and never the
app's. The menu-bar glyph is a pure black-plus-alpha template image, which is a
requirement rather than a style: macOS recolours template images for dark mode
and for the pressed state and reads nothing but the alpha channel.

The .icns files are built from the iconsets at package time rather than
committed, so the PNGs stay the single source of truth. The export pipeline
could not write "@" into filenames, so the retina files arrived as -2x.png and
were renamed to @2x.png, which is what iconutil requires.

make-dmg.sh now builds a read-write image, applies the designed window layout
through Finder, then compresses — the layout lives in the volume's .DS_Store
and cannot be written into a read-only image after the fact. Finder needs a GUI
session that a CI runner may not have, and an automation-consent prompt would
hang the build rather than fail it, so the step runs under a watchdog and
DMG_LAYOUT decides what failure means: auto (warn, ship unstyled), require
(abort), off (skip). Default is auto until a real release proves the runner can
do it.

The image ships two visible items and no third. A READ ME FIRST.txt with the
Gatekeeper instructions was tried and dropped: the block happens after the user
has dragged the app to Applications and ejected the image, so the file is on
screen exactly when it is not needed and gone when it is. Those instructions
stay in the release body, next to the download button, and in doc/MACOS_APP.md.

Two behaviours cost real debugging and are recorded where they bite:

- The volume icon has to be installed AFTER the Finder pass. Staging it up
  front looks like it works — hdiutil copies the file, SetFile sets the flag —
  and then Finder removes both while laying out the window, shipping a generic
  disk icon. Found by bisecting the steps.
- Finder's item `position` is the top-left of the cell, not its centre, and a y
  below roughly 68 is not clamped for that one item: Finder translates *every*
  item down by the difference, sliding the app and the symlink off the arrow.

Verified by reading the finished image back: content 640x420, icon size 104,
cix.app at (175,250), Applications at (465,250), volume icon flag set, and the
bundle still passing `codesign --verify --strict` after the round trip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`display alert` is drawn with the icon of the process that ran the script,
which is osascript — so every dialog the app showed came up wearing a generic
folder icon, immediately after packaging a real icon set.

`display dialog` takes an explicit icon, so point it at the bundle's cix.icns.
The trade is that the title becomes a window title rather than bold body text,
which is worth it. It is also the primitive the later phases need regardless:
only `display dialog` supports `default answer`, required for the reset-password
prompt.

Falls back to `display alert` when running outside a bundle, where there is no
icon to point at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns cix.app from a package into something you can use: a menu bar item that
shows whether the local server is running and what its embedding provider is
doing, with start/stop and a dashboard link. No Dock icon, no windows.

First-run setup is load-bearing, not polish. cix-server refuses to start
against an empty database unless both CIX_BOOTSTRAP_ADMIN_* env vars are set
(bootstrap.go, `case count == 0`) — it will not invent an admin silently — so a
drag-installed app with no configuration could never start its own server. The
wizard asks for an email, generates a password and an API key in the server's
own format, writes ~/.cix/server.env at 0600, starts the server and registers
it with the CLI. It deliberately writes ~/.cix/server.env rather than the repo
.env install-server.sh uses, so a checkout and an installed app can coexist.

Server state comes from two sources because neither is sufficient. launchd
knows whether the job is loaded and has a pid but not whether it is serving —
a cold start spends 30s to several minutes loading an embedding model, in
silence. /health knows whether it is serving but cannot tell "stopped" from
"starting", which is the distinction that stops people killing a server that
was nearly ready. Together they give three honest states.

Start is bootstrap-then-kickstart and Stop is `launchctl kill SIGTERM`, which
needs KeepAlive=false — a deliberate divergence from install-server.sh. With
KeepAlive=true, Stop would have to be a bootout, dragging launchd's drain race
onto the hot path of a menu click. The trade is that a crashed server is not
resurrected; the menu shows it stopped within five seconds.

install-server.sh owns the same launchd label and points it at a repo checkout.
Rather than clobber it, the app detects a wrapper without its own provenance
marker and goes observe-only: status, provider and dashboard still work over
HTTP, Start/Stop are disabled and labelled "managed externally".

Two display decisions that are correctness, not taste:

- The provider kind "ollama" is rendered as "llama.cpp (bundled)". In this
  codebase that kind IS the bundled llama-server supervisor — provider/ollama
  spawns llama-server and hardcodes ManagesProcess: true — and no Ollama is
  involved or possible. Printing the raw kind tells the user they are running
  software they never installed. The dashboard keeps showing the raw kind; it
  is a diagnostic surface and this is not.
- model_loaded is debounced over two consecutive polls. The server computes it
  under a 500 ms deadline it can lose under load, so one false is not evidence,
  and for HTTP providers (openai, voyage) it is skipped entirely — hence
  EmbeddingsHealthy, which never derives a red state from a provider that owns
  no local process.

Also extends Client.Status, which was dead code decoding 4 of 10 always-present
fields, and pins the whole response with twin golden fixtures on both sides of
the module boundary — the repo's convention for a contract two modules cannot
share a type for.

Verified end to end on a clean machine state: wizard writes the env at 0600 and
bootstraps the agent, server answers /health, menu renders all three rows, Stop
leaves the job loaded with no pid, Start brings it back, Open Dashboard reaches
the login page, and the CLI's existing default_server is left untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three changes to the menu bar, plus the server config the last one needs.

Width. An NSMenu is exactly as wide as its widest row, so an untruncated model
id — `ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF`, 41 characters — stretched the
whole menu to fit one line nobody reads in full. Every row is now capped at the
same width, so the menu is a predictable size rather than a function of
whichever model happens to be configured. Truncation is from the middle, since
these are qualified names where both ends carry information and tail truncation
would render every Hugging Face model as its owner.

Status. Coloured dots — green running, amber starting, red stopped, grey
unknown — instead of a "— ready" suffix, which is how macOS status apps say
this and which costs no width. Amber for starting is deliberate: a cold start
loading a model is working, and red is what makes people kill it. The dots are
generated rather than shipped as files, and are NOT template images: a template
is recoloured from its alpha channel alone, which would make all four the same
colour and erase the only thing they say. The model row gets a transparent dot
of the same size, because AppKit indents a title by its image width and without
one that row starts left of its neighbours.

Details submenu, replacing per-item tooltips. Native NSMenuItem tooltips have
two behaviours that make them unusable here, neither with an API: once any
tooltip in the app has appeared, every subsequent one shows with no delay at
all, and they are positioned against the item rather than the pointer. Fixing
either means giving every row a custom NSView with its own tracking area — that
is, writing the menu in Objective-C instead of using systray. A submenu gets
native timing and placement for free, and is where the full model id, the pid,
the port, the exposure and the reason Start/Stop is disabled now live.

Local/network toggle. This needed a server change: the HTTP server hardcoded
`fmt.Sprintf(":%d", cfg.Port)`, so it always bound every interface with no way
to say otherwise. Adds CIX_BIND_ADDR, defaulting to empty — every interface,
which is what containers need and what every existing deployment already has,
so nothing changes for them. Config.ListenAddr composes it with net.JoinHostPort
so an IPv6 literal is bracketed rather than concatenated into a different
address, and validation rejects a URL or a host:port pair, both of which bind as
a hostname on some systems and then fail to resolve — a server that is silently
unreachable instead of one that refused to start.

The app writes 127.0.0.1 at first run, deliberately unlike the server default:
exposing a code index to the local network is a choice to make on purpose. The
menu toggle asks for confirmation when widening access and not when narrowing
it, and restarts the server, because the bind address is read once at process
start and saying "saved" without restarting would be a lie discovered later.
Existing installs keep whatever they had; the app does not silently narrow a
server someone already relies on.

Verified at the socket, not the config file: toggling produced
`TCP 127.0.0.1:21847` and a LAN request refused, then `TCP *:21847` and the
same request served, with localhost working throughout and a new pid each time.
Cancelling the confirmation left both the file and the binding untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drops the last ones — the status-item tooltip and the two still passed as the
second argument to AddMenuItem. The app now shows none at all.

AppKit's tooltips have two behaviours here that no API can change: once any one
of them has appeared, every subsequent one shows with no delay whatsoever, and
they are positioned against the element rather than the pointer. Changing either
means giving every row a custom NSView with its own tracking area — writing the
menu in Objective-C instead of using systray, which is not a trade worth making
for hover text. Everything they carried already moved to the details submenu,
where the timing and placement are the system's own.

One tooltip was load-bearing and could not simply be deleted: Quit closes the
menu bar app and leaves the launchd agent running, the opposite of what Quit
means in most menu bar apps. That now reads "Quit (server keeps running)" — a
surprise is worth 22 characters of title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…keover

Phase 3 of the menu bar app.

Start at Login writes RunAtLoad and reloads the agent. The reload is not
avoidable: launchd reads a plist exactly once, when the job is bootstrapped, so
rewriting it with the job loaded changes nothing until the next login — which is
the setting being changed, so the user would find out months later. Reloading
stops a running server, so the toggle puts it back rather than pretending the
cost is zero. The checkbox reads RunAtLoad back out of the file instead of
remembering it, so it cannot drift from what launchd will actually do.

Reset Password drives `cix-server -reset-password`, which opens the database
directly and does not need the server stopped. Its stdin is /dev/null, the
branch that makes it generate a password rather than read one, and the
"Temporary password: " line is matched by prefix — the surrounding lines are
conditional.

That command answers an unknown address by listing every account in the
database. Reasonable for an operator at a terminal who already holds the DB
file; an account enumeration anyone behind the user can read once it is in a GUI
alert. The dialog gets one sentence, the full output goes to
~/.cix/logs/launcher.log at mode 0600 — which is also why the launcher now has a
log at all, being an LSUIElement app with no terminal to write to.

Takeover. install-server.sh owns the same launchd label and points it at a repo
checkout, so on a machine already running cix from a clone this app finds an
agent it did not create, holding the port it wants. It now asks once, shows the
paths it found so the installation is recognisable, and remembers the answer.
Leaving it alone is observe-only, as before. Taking it over migrates the port,
API key and database paths out of the .env the old wrapper sourced — and only
those four, since the rest of that file is the other installation's business —
backs up the plist and wrapper first, and refuses the whole operation if the
backup fails, that being the one step that overwrites something unrecoverable.

The first-run wizard is now behind that check, not beside it: with a foreign
agent present it would have set up a second server that cannot bind the port,
against a second, empty database.

Fixes a real bug found while testing the takeover prompt. Dialog dismissal was
detected by matching osascript's "User canceled" — but the message is localised,
and this project's own development Mac runs a British locale where it says
"cancelled" with two Ls. So no cancel anywhere in the app was recognised; the
network toggle only appeared to work because both its branches do the same
thing. Now matched on OSStatus -128, which is a number in every language.

Verified end to end against a simulated install-server.sh agent: the prompt
named both its .env and its binary, Leave It Alone recorded the choice and
disabled Start/Stop while leaving reset password working, a relaunch did not
ask again, and Take Over migrated the API key back into a server.env it had been
removed from, backed up both files, and left the config byte-identical to the
snapshot taken before the simulation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app replaces itself whole — bundle and all — rather than swapping
individual binaries. Two reasons, and the first is not negotiable: the bundle's
ad-hoc signature seals every executable inside it, so replacing one of them
breaks `codesign --verify --strict` and Apple Silicon then refuses to run it.
The second is that cix-server, the cix CLI and llama-server are built and tested
as one artefact, and mixing release streams would produce a combination nobody
has run. It is also what makes "update the CLI" mean anything: the cix on PATH
is a symlink into the bundle.

Checks are cheap by construction. The releases listing is fetched with the
previous ETag, so an unchanged check is a 304 — which does not count against
GitHub's 60-per-hour unauthenticated limit — and the ETag is persisted so that
holds across restarts too. On top of that the automatic check is throttled to
30 minutes and says nothing when there is nothing to offer.

The version comparison is ported from server/internal/versioncheck rather than
imported, since that package is under the server module's internal/. One rule
is deliberately inverted: versioncheck treats an unparseable current version as
"anything is newer", which is right for a dashboard banner and wrong for
something that overwrites the running application — a development build is
usually newer than the last release. Here an unidentifiable build is never
replaced, and dev builds skip the check entirely.

Every step before the swap is reversible, and the swap itself moves rather than
deletes: live → .old, staged → live, then remove .old. If the second move fails
there is still a complete application on disk to restore, which "delete then
copy" would not leave. The swap runs from an embedded script in its own session
because the application being replaced is the one running the update, and a
process cannot outlive the deletion of its own bundle to reopen it.

Not asking for admin is a decision, not an omission. If the folder holding the
app is not writable the update stops before downloading anything and says to
install by hand. An unsigned app requesting an administrator password to
overwrite itself is indistinguishable from malware, and teaching users to
approve that is worse than an update that asks them to drag an icon.

The SHA-256 check against checksums.txt proves the download arrived intact and
is explicitly not a trust anchor — the checksums travel the same channel as the
image. Without a Developer ID there is nothing stronger available; a detached
signature over the checksums is the obvious next step and is noted in the code.

Verified end to end twice against a local release API serving a real, locally
built DMG — the update path replaces the running application, so the only way to
know it works is to run it. First pass: 0.3.0 → 0.4.0, signature still valid
after the swap, no .new or .old left behind, app reopened by itself. That run
exposed a real defect, since the server happened to be down: the "was it
running" test used the health check, so a server still loading its embedding
model — pid alive, no /health yet — read as stopped, and the update would have
replaced the bundle under a live cix-server. Now it tests for a process. Second
pass with a running server: 0.4.0 → 0.5.0 and the server came back on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cix-server persists its embedding provider config on first boot and treats
the stored blob as authoritative from then on. Two of the ollama fields in
it are not choices anyone made — they are derived from the running process:

  bin_dir      filepath.Dir(os.Executable())/llama
  socket_path  <TMPDIR>/cix-llama-<pid>.sock

Frozen at first boot, bin_dir names the directory the server was installed
in when the row was written. Move or upgrade the installation and every
later boot keeps launching the *old* llama-server, until that directory is
deleted — at which point embeddings stop working with no configuration
having changed. A frozen socket_path defeats the uniqueness it exists for:
a new server can find an orphaned llama-server already bound to that name
and talk to it instead of spawning its own.

Both fields are already documented as deployment-level and are deliberately
absent from the dashboard's edit schema (the ollama factory's SchemaJSON).
This makes that true on the second boot as well as the first.

Everything a person can choose — the model, the context size, the GPU
layers — still comes from the database untouched. The refreshed blob is not
written back: the stored row stays the record of what was chosen.

Found while splitting the macOS app's runtime into versioned directories,
where it is fatal — the app would keep running the previous release's
sidecar and break outright once that version was pruned. It is not
macOS-specific: the same applies to a container whose image layout changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app carried everything: launcher, cix-server, the cix CLI and a Metal
llama-server, 102 MB in one bundle. Every server update therefore replaced
the whole application through a trampoline that had to quit the launcher,
move the bundle aside, move the new one in, and reopen. That machinery
existed only because the server lived inside the app.

The Mac now gets two things, released separately because they are separate
things:

  cix-<ver>-arm64.dmg                    mac/v*     the app     3.9 MB (was 41)
  cix-runtime-<ver>-darwin-arm64.tar.gz  server/v*  the server   35 MB

The runtime IS the server, so it carries the server's version and ships
from the server's tag — the same server/vX.Y.Z and the same workflow run
that publishes the Docker images. A Mac install on 0.12.8 and a container
on 0.12.8 are the same server, and a llama bump remains what it has always
been: a server release. release-server.yml grows a macos-runtime job on
macos-latest and attaches the tarball plus a checksums.txt to the release;
release-mac.yml now builds only the app, and no longer needs a server/v* or
cli/v* tag reachable because nothing in it is stamped from one.

The runtime installs into ~/.cix/runtime/<server version>/ with a `current`
symlink. Updating it is a download and a rename: the app stays open, the
launchd wrapper needs no rewrite because it execs the stable `current`
path, and the version just replaced is kept. If the new server exits
instead of starting, the symlink goes back and the old one is restarted,
with no download and no prompt. "Exits" is the test, not "did not answer
/health" — a cold start loads an embedding model and can take minutes.

The app and the server update independently, on their own schedules, and
neither waits for the other: a server release reaches a Mac the day it
reaches Docker Hub. The launcher watches both streams, with an ETag each.

The CLI travels with the server because it speaks a specific server's API,
so /usr/local/bin/cix becomes a symlink into ~/.cix/runtime/current and
follows updates without /usr/local being touched. llama travels with the
server for the same reason it always has: cix-server resolves its sidecar
next to its own executable, so shipping them apart would mean carrying
CIX_LLAMA_BIN_DIR forever.

Two things fell out of the split rather than being designed in:

  - An app update no longer stops the server. Nothing a running server
    touches is inside the bundle any more, so updating cix no longer
    interrupts indexing — and the prefs flag that carried "restart it
    afterwards" across the swap is gone.
  - Info.plist no longer records CIXServerVersion/CIXCLIVersion/
    CIXLlamaVersion. With the runtime outside, those would be claims about
    somebody else's files, wrong the first time either half updates alone.

build-runtime.sh round-trips its own tarball before returning: extract,
codesign --verify --strict every Mach-O, check llama-server's @rpath
dependencies, and actually exec the server. That check is in the script so
local builds get it too, and because the failure it catches is silent —
a signature the kernel rejects is SIGKILL with empty stderr. It also
refuses to ship a payload whose server reports a different version from
its label, which is what a stale server/dist produces.

Verified on an M3 Max: fresh install, launchd wiring, a live 0.5.0 → 0.6.0
→ 0.7.0 runtime update with the server restarting on the new one each time,
and a deliberate rollback from a runtime that installs cleanly and exits on
exec. Unit tests cover the tar containment checks, the execute bit, the
symlink swap, pruning, and the full download → checksum → unpack → verify
path against a served release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(mac): install the cix server outside the app, and ship it from the server release
Three tests in internal/watcher wait for BeginIndex and then assert on
FinishIndex. Those are two sequential HTTP calls from the indexer goroutine,
so the window between them is small but real, and on a loaded CI runner the
assertion lands inside it. It surfaces as "expected FinishIndex to be called"
on a test that took 0.02s — not a timeout, an assertion made too early.

Adds waitForFinish for the tests that actually care about completion, and
keeps waitForCalls for those that only assert BeginIndex.

TestDebounce_MultipleEventsOnce had a different problem with the same shape:
its deadline was ten times the 80ms debounce interval, which reads generous
and is 800ms. A cold macOS runner loses that, and because the flush goroutine
then ran after t.TempDir() cleanup, the failure printed as "cannot read a.go:
no such file or directory" rather than as the assertion that failed. The
property under test is that five events collapse into one flush, not how
quickly, so it now waits on patience rather than on the interval.

The shared deadline goes to 15s. It exits the instant the condition holds, so
a long limit costs nothing when things work.

Both failures appeared on macos-latest during the mac-runtime PRs (#229, #230)
and passed on rerun; neither is caused by those changes, but the extra parallel
load of a new test package is enough to make the races fire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test(cli): wait for FinishIndex instead of racing it
@dvcdsys
dvcdsys merged commit a574d6c into main Aug 10, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant