Fix background start falsely reporting success when the server fails to start - #255
Merged
Merged
Conversation
…to start waitForReady treated any HTTP 200 on /_/api/status as ready, even when the response was not from a mo server, and never checked whether the spawned child was the process actually serving the port. As a result: - When two mo processes raced for the same port, the loser received the winner's status, reported success with a dead pid, and its files were silently lost. - When a non-mo server occupied the port, mo exited 0 while the child had died with EADDRINUSE. - The child's fatal error was written only to stderr (/dev/null for background children), leaving no trace in the log file, and the failed child hung for 5s in donegroup cleanup because watchLoop never exited. waitForReady now requires a decodable status with a non-empty version and returns errServerConflict when the responding server's PID differs from the spawned child; startBackground then falls back to POSTing the files to the winning server, erroring out if none could be added. When the child dies, waitForReady fails fast after a short grace period that lets a race winner start serving — child death is detected with a non-blocking reap on Unix since the unwaited child stays a zombie. The restore file is only removed once the child is confirmed dead. Fatal RunE errors are logged before the log file closes (only when logging to a file, to avoid duplicate stderr output in foreground mode), and a listen failure closes the watcher so cleanup does not time out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Merged
kiakiraki
pushed a commit
to kiakiraki/mo
that referenced
this pull request
Jul 31, 2026
…g server `addToRunningServer` and the direct add-to-running-server path both counted `len(patterns)` unconditionally as "added" items. `postPatterns` may fail silently (logs warn and continues) on any HTTP error, so a pattern-only invocation whose POSTs were all rejected by the winning server would still be reported as "added N item(s)" and exit 0, defeating the `attempted > 0 && added == 0` guard introduced in k1LoW#255. `len(entries)` alone cannot substitute for a success count because a valid pattern may legitimately match zero files. Have `postPatterns` return `(entries, added int)` and use that count at both callsites. The direct path also switches from `len(files)` to `len(fileEntries)` so a partial file-POST failure no longer overstates the total either.
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.
Problem
When starting a server in the background,
waitForReadytreats any HTTP 200 on/_/api/statusas "ready" — a JSON decode failure is even short-circuited to success (return nil, nil //nolint:nilerr) — and it never verifies that the spawned child is the process actually serving the port. Combined with the probe→listen TOCTOU in the CLI, this produces false success reports in three related ways (all reproduced on Linux):moinvocations on the same port both pass the ~500ms probe and both spawn a server. The loser's child dies withEADDRINUSE, but the loser's parent receives the winner's status, printsmo: serving at ... (pid <dead pid>)with the winner's deeplinks, and exits 0. The loser's files are opened nowhere. Realistic triggers:git ls-files '*.md' | xargs -n1 mo, editor integrations, or a loaded machine where the existing server's probe response exceeds 500ms./_/api/status, mo reports success although the child died and the port owner is unchanged. If it returns non-200, the user gets a misleading "check log file" timeout after 10s./dev/nullfor background children, socannot listen on ...: address already in useappears nowhere — the log only shows a secondaryshutdown error: context deadline exceeded, caused bywatchLoopnever exiting on the listen-failure path (its exit depends onwatcher.Close(), whose cleanup hook is registered only after a successfulnet.Listen).Fix
waitForReadyonly treats a response as ready when the status decodes and carries a non-emptyversion(same validation asprobeServer). It also takes the spawned child's PID:errServerConflictwith the winner's status.startBackgroundthen falls back to POSTing the requested files/patterns/uploads to the winning server (server-sideAddFileis idempotent for duplicate paths), and errors out if nothing could be added instead of claiming success.wait4on Unix, because the unwaited child remains a zombie for whichkill(pid, 0)still succeeds. On Windows,os.FindProcessis used best-effort; PID reuse degrades gracefully to the old full-timeout behavior.the port may be in use by another (non-mo) server.slog.Error'd before the log file closes, so the root cause lands inmo-<port>.log. This is only registered when logging to a file, avoiding duplicate stderr output in--foregroundmode.startServercloses the watcher sowatchLoopexits and donegroup cleanup no longer times out after 5s.openBrowseronisNewGroup || open, matching the existing add-to-running-server path.Known limitations (accepted): a server self-restart during the 10s polling window would be classified as a conflict (benign — the fallback POST is idempotent and the files still end up served), and Windows PID reuse can defeat the fail-fast (falls back to the previous timeout behavior).
Behavior before / after
Concurrent race (
mo -p 17700 race-a.mdandmo -p 17700 race-b.mdsimultaneously):Non-mo server on the port (
python3 -m http.servervariant returning 200):Tests
TestWaitForReady_Success/TestWaitForReady_PIDMismatch/TestWaitForReady_NonMoServer/TestWaitForReady_ChildExited— ready validation, conflict detection, non-mo rejection, and fail-fast on child death (zombie-based, deterministic).TestProcessAlive_ZombieChild— regression for the zombie case:kill(pid, 0)succeeds for an unwaited dead child, soprocessAlivemust reap.TestAddToRunningServer/TestAddToRunningServer_AllPostsFail— fallback POSTs and the all-failed error path.Verification
go build ./...,go test ./... -race,golangci-lint run/ gostyle: clean.XDG_STATE_HOME, ports 17xxx), including log contents and exit codes.Reported in kiakiraki#2 (with full reproduction steps).
🤖 Generated with Claude Code