261 lines
16 KiB
Markdown
261 lines
16 KiB
Markdown
---
|
|
status: building
|
|
created: 2026-08-07
|
|
updated: 2026-08-19
|
|
---
|
|
|
|
# Port collision and backend ownership safety
|
|
|
|
## Why
|
|
|
|
Kandev can currently start against the wrong backend when an explicitly requested port is
|
|
already occupied. The TypeScript development launcher and the native Go launcher accept a
|
|
successful health response from any process on that port, so a second Kandev instance can be
|
|
reported as ready while the newly launched backend is still failing to bind. That can open the
|
|
wrong SQLite database and make the failure look like a successful startup.
|
|
|
|
On Windows, the agentctl instance allocator has a separate failure: the operating system reports
|
|
WSAEADDRINUSE (10048), which does not match the synthetic Go syscall.EADDRINUSE value used by the
|
|
current retry check. The allocator therefore stops instead of trying the next port.
|
|
|
|
A direct backend command can bypass launcher checks. A second backend can then open the same
|
|
Kandev home before its HTTP bind fails. This sequence can migrate the live database and reconcile
|
|
active sessions while the first backend still owns them.
|
|
|
|
This repair covers GitHub issues
|
|
[#2370](https://github.com/kdlbs/kandev/issues/2370),
|
|
[#2372](https://github.com/kdlbs/kandev/issues/2372), and
|
|
[#2371](https://github.com/kdlbs/kandev/issues/2371). PR
|
|
[#2368](https://github.com/kdlbs/kandev/pull/2368) remains the correct Makefile wiring for
|
|
passing PORT and WEB_PORT, but it does not provide the safety checks described here.
|
|
|
|
## What
|
|
|
|
### Explicit backend-port preflight
|
|
|
|
The TypeScript and native Go launchers must probe an explicitly configured backend port before
|
|
starting their backend child:
|
|
|
|
- Explicit values include the CLI port flags, KANDEV_BACKEND_PORT, KANDEV_PORT, and the Makefile
|
|
PORT value after PR #2368 translates it to a CLI flag.
|
|
- An occupied explicit port is a hard startup error. The error names the numeric port and the
|
|
configuration source, and does not silently select a different backend port.
|
|
- A free explicit port continues through the existing launch path.
|
|
- When no backend port is configured, the existing automatic preferred-port and fallback-port
|
|
selection remains unchanged.
|
|
- The preflight is a race-reduction measure, not the ownership proof: a port can still be taken
|
|
between the probe and the child bind. Readiness ownership below closes that remaining race.
|
|
|
|
### Port-availability probe detects wildcard listeners
|
|
|
|
Wherever a launcher decides whether a port is free, whether it is preflighting an explicit
|
|
backend or web port, or selecting a preferred-then-random automatic port, the probe must treat a
|
|
port as available only when both of these hold:
|
|
|
|
- Nothing answers a loopback connect on either the IPv4 (127.0.0.1) or the IPv6 (::1) loopback
|
|
address.
|
|
- A fresh loopback bind on that port succeeds.
|
|
|
|
The connect probe is required because a running Kandev backend binds the wildcard address
|
|
(0.0.0.0 and [::]), and a specific-address bind check alone reports that port as free. On
|
|
BSD-derived systems including macOS, and because the bind probe itself sets SO_REUSEADDR, a
|
|
loopback bind against an already-active wildcard listener succeeds, so a bind-only check falsely
|
|
reports the busy port as available. Probing both loopback families is required because the
|
|
existing backend may hold only the IPv6 wildcard socket. The bind probe is retained because it
|
|
catches reservations a connect probe misses, such as Windows phantom port reservations and ports
|
|
in TIME_WAIT.
|
|
|
|
Each connect probe is bounded by a short timeout so a silently dropped SYN, for example under
|
|
WSL2 mirrored networking to an unbound loopback port, cannot hang port selection. A timed-out or
|
|
refused connect means nothing is listening.
|
|
|
|
This restores the dual connect-and-bind, dual-stack behavior the TypeScript launcher had before
|
|
`make dev` moved to the native Go launcher (PR #2411), where the Go probe was bind-only on the
|
|
IPv4 loopback. The contract is not English error-text matching on socket errors.
|
|
|
|
### Backend readiness ownership
|
|
|
|
The process that owns user-visible readiness must create one fresh opaque health token for the
|
|
launch. An ordinary TypeScript or native Go launcher invocation owns readiness, creates the token,
|
|
passes it to the backend through the existing KANDEV_DESKTOP_HEALTH_TOKEN environment variable,
|
|
and retains it for supervisor-managed backend restarts.
|
|
|
|
The Tauri desktop shell is a nested-launch exception because it owns the outer readiness check and
|
|
WebView navigation. It creates the token before invoking `kandev --headless`, identifies the launch
|
|
as desktop-owned with `KANDEV_DESKTOP_NATIVE_NOTIFICATIONS=true`, and passes the token to the native
|
|
launcher. When both the desktop-owned marker and a non-empty token are present, the native launcher
|
|
must preserve that exact token for its backend child and its own health poll. It must not replace the
|
|
desktop-owned token with a second generated value. Without that marker, the native launcher must
|
|
replace any ambient KANDEV_DESKTOP_HEALTH_TOKEN with a fresh token so a stale shell environment
|
|
cannot claim readiness ownership.
|
|
|
|
The launcher health poll succeeds only when the response is a 2xx response and its
|
|
X-Kandev-Desktop-Health-Token response header exactly matches the token generated for that
|
|
invocation. A 2xx response without the header or with a different value is not readiness from the
|
|
launched backend; polling continues until the child exits or the normal timeout is reached.
|
|
|
|
The existing backend health route and desktop token names are reused. Direct backend health
|
|
requests without a launcher-supplied token remain compatible with the current route behavior.
|
|
The token is not printed in startup output or failure diagnostics. The existing owner-only
|
|
supervisor manifest is allowed to carry the launch environment so an intentional backend restart
|
|
continues to answer with the same token.
|
|
|
|
### Windows address-in-use handling
|
|
|
|
The agentctl instance allocator and websocket tunnel must use one shared cross-platform
|
|
address-in-use classifier. It must recognize wrapped address-in-use errors on Unix and both the
|
|
Go syscall value and x/sys/windows.WSAEADDRINUSE on Windows.
|
|
|
|
- The instance allocator marks an occupied candidate unavailable and retries the next candidate.
|
|
- The websocket tunnel retains its existing user-facing “port is already in use” error.
|
|
- Non-address-in-use bind errors still release the candidate and fail immediately.
|
|
- String matching on the English error text is not part of the contract.
|
|
|
|
### Exclusive runtime-state ownership
|
|
|
|
Every backend process must acquire exclusive ownership before it initializes the backend logger or
|
|
opens a persistent store. The ownership boundary covers these targets:
|
|
|
|
- The canonical Kandev home, because it owns logs, secrets, worktrees, supervisor files, and the
|
|
default SQLite database.
|
|
- A custom SQLite database outside that home, because separate homes can still reference the same
|
|
database file.
|
|
|
|
The backend holds each operating-system advisory lock until all backend cleanup is complete. A
|
|
crash releases the lock. The lock file can remain after exit because file existence does not prove
|
|
ownership.
|
|
|
|
If another process holds a required lock, startup exits non-zero. It names the conflicting home or
|
|
database path and tells the operator to use a separate `KANDEV_HOME_DIR` for an intentional second
|
|
instance. The rejected process must not initialize file logging, create a database backup, apply a
|
|
migration, reconcile a session, launch agentctl, or start an HTTP server.
|
|
|
|
That message also names the current owner when the owner is knowable. After a backend acquires a
|
|
lock it records advisory owner metadata, its process ID, executable path, and start time, into the
|
|
lock file it already holds, replacing any previous content. A process rejected for a conflict reads
|
|
that metadata and includes it in its stderr message, so an operator who has no leftover terminal can
|
|
identify the process to stop instead of being told only that the home is busy.
|
|
|
|
This metadata is diagnostics, not ownership. The operating-system lock remains the sole proof: the
|
|
metadata is never consulted to decide whether a lock is held, whether it is stale, or whether it may
|
|
be taken over, and acquisition never waits on it. Failing to write it does not fail acquisition, and
|
|
absent, empty, or unparseable metadata falls back to the message that names only the path. Because a
|
|
crashed owner leaves its last-written metadata in place, a reader that can positively determine the
|
|
recorded process is no longer running omits the owner detail rather than naming a dead process; a
|
|
platform that cannot determine liveness reports the recorded values as-is.
|
|
|
|
The lock sidecar is opened with platform no-follow semantics and must resolve to a regular file before
|
|
the backend can write metadata. A symlink or platform reparse point at the lock path is rejected
|
|
without modifying its target. The first byte of the sidecar is reserved for the Windows lock range,
|
|
so a conflicting process can read the advisory metadata through a separate handle while the lock is
|
|
held.
|
|
|
|
The backend fails closed when it cannot create, open, or acquire a required lock. Launcher port
|
|
preflight and health-token ownership remain useful readiness checks, but they are not persistent
|
|
state ownership checks.
|
|
|
|
Intentional local instances need separate Kandev homes and separate SQLite databases. A backend
|
|
that uses Postgres still locks its local Kandev home. Active-active Postgres deployment behavior is
|
|
not part of this contract.
|
|
|
|
## Scenarios
|
|
|
|
### Issue #2370: explicit port collisions
|
|
|
|
1. Given a free CLI or environment-selected backend port, when dev, start, or run launches,
|
|
then the requested port is used and the normal readiness flow follows.
|
|
2. Given another process already owns an explicitly selected port, when the launcher starts, then
|
|
it exits non-zero before starting the backend child or opening the browser, and the message
|
|
includes the port and source.
|
|
3. Given no explicit backend port and the preferred port is occupied, when the launcher starts,
|
|
then it chooses an available fallback as it does today.
|
|
|
|
### Wildcard-listener port availability
|
|
|
|
1. Given a running backend holds the preferred port through a wildcard bind (0.0.0.0 and [::]),
|
|
when the launcher selects an automatic port with no explicit port configured, then the
|
|
availability probe reports the preferred port as busy and the launcher falls back to a free
|
|
random port instead of choosing the occupied preferred port.
|
|
2. Given a wildcard listener holds an explicitly requested backend or web port, when the launcher
|
|
preflights that port, then it reports the port as busy and exits with the existing hard error
|
|
naming the port and its source.
|
|
3. Given nothing is listening on a candidate port but a loopback connect to an unbound port is
|
|
silently dropped, when the availability probe runs, then the bounded connect timeout elapses,
|
|
the fresh bind succeeds, and the port is reported available.
|
|
|
|
### Issue #2372: readiness from the wrong process
|
|
|
|
1. Given a stranger responds 2xx without the expected token, when the launcher polls health, then
|
|
it does not announce readiness or open the browser.
|
|
2. Given a stranger responds 2xx with a mismatched token, when the launcher polls health, then it
|
|
continues polling and does not treat that response as success.
|
|
3. Given the launched backend responds 2xx with the matching token, when the launcher polls
|
|
health, then it announces readiness exactly once.
|
|
4. Given the supervisor restarts the backend for the same launcher invocation, when the restarted
|
|
backend responds with the retained token, then health succeeds without accepting a stranger.
|
|
5. Given the Tauri shell marks a launch as desktop-owned and supplies a non-empty token, when the
|
|
nested native launcher starts and polls the backend, then the backend and both readiness checks
|
|
use that same token and the WebView can navigate without waiting for the startup timeout.
|
|
6. Given an ordinary CLI launch inherits a stale health token without the desktop-owned marker,
|
|
when the native launcher starts, then it replaces the stale value with a fresh token for the
|
|
backend and its own health poll.
|
|
|
|
### Issue #2371: Windows allocator retry
|
|
|
|
1. Given the first agentctl instance candidate is occupied on Windows, when an instance is
|
|
allocated, then the allocator marks that candidate unavailable and binds the next candidate.
|
|
2. Given every candidate is occupied, when an instance is allocated, then the allocator returns
|
|
its existing exhaustion error after releasing candidates correctly.
|
|
3. Given a tunnel port is occupied on Windows, when a tunnel is requested, then the caller gets
|
|
the existing clear “port is already in use” error.
|
|
|
|
### Concurrent backend startup
|
|
|
|
1. Given one backend owns a Kandev home, when another backend uses the same home, then the second
|
|
process exits before any persistent backend initialization.
|
|
2. Given two homes reference the same external SQLite database, when both backends start, then
|
|
only one process opens or changes that database.
|
|
3. Given a backend process stops or crashes, when its successor starts with the same home, then the
|
|
successor acquires ownership without manual lock-file removal.
|
|
4. Given a live backend uses the default home, when a developer runs a direct backend target
|
|
against that home, then the direct command fails without changing task or session state.
|
|
5. Given two distinct homes, databases, and ports, when two local backends start, then both can run
|
|
as independent instances.
|
|
6. Given a backend owns a Kandev home and has recorded its owner metadata, when a second backend
|
|
starts against the same home, then the rejection names the owning process ID and executable in
|
|
addition to the conflicting home path.
|
|
7. Given a lock file still holds metadata from an owner that has since died, when a second backend
|
|
is rejected by whichever process holds the lock now, then the message omits owner detail it
|
|
cannot confirm and still names the conflicting home path.
|
|
8. Given a backend cannot write its owner metadata, when it has already acquired the lock, then it
|
|
starts normally and a later conflicting process falls back to the path-only message.
|
|
9. Given a lock sidecar path is a symlink or platform reparse point, when the backend starts, then
|
|
it rejects the lock before writing and leaves the link target unchanged.
|
|
|
|
## Out of scope
|
|
|
|
- Changing default ports, fallback ranges, or automatic-port selection policy.
|
|
- Choosing a different port when the user explicitly requested one.
|
|
- PID or process-tree identity matching as an ownership, staleness, or takeover decision; the
|
|
launcher wrapper chain makes that unreliable for dev mode, and a recorded PID can be stale or
|
|
reused. Advisory owner metadata written into the lock file is diagnostic text only and is
|
|
explicitly not such a decision input.
|
|
- Changing the health response body, the backend health status semantics, or the desktop
|
|
WebView flow.
|
|
- Renaming KANDEV_DESKTOP_HEALTH_TOKEN to a neutral variable; that would be a separate contract
|
|
migration.
|
|
- Changing service-install handling of KANDEV_SERVER_PORT, which is a separate installer issue.
|
|
- Database-schema changes or new public authentication semantics.
|
|
- Automatic home selection for direct backend commands.
|
|
- Active-active backend support for one Postgres database or one event namespace.
|
|
- New UI diagnostics for ownership or task-summary fields.
|
|
|
|
## Contract notes
|
|
|
|
The existing desktop health-token contract in
|
|
docs/specs/desktop-tauri-app/spec.md is the authority for the environment variable and response
|
|
header. Backend runtime-state ownership follows
|
|
[ADR-2026-08-09-exclusive-runtime-state-ownership](../../decisions/2026-08-09-exclusive-runtime-state-ownership.md).
|
|
The repair plan is
|
|
[Backend runtime-state ownership](../../plans/backend-runtime-state-ownership/plan.md).
|