Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 30 additions & 17 deletions hyperdb-api/tests/process_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,16 @@ fn callback_connection_shutdowns_hyperd_after_parent_kill() {
let pid = hyper
.pid()
.expect("child must report HyperProcess public PID");
fs::write(pid_file, pid.to_string()).expect("child must report Hyper PID to parent");
// Publish the PID atomically — stage a sibling temp file, then rename
// it into place. A plain `fs::write` is `File::create` (truncate) then
// `write_all`, so a poll can observe not just an empty file but a
// truncated-yet-parseable prefix (`"123"` of `"12345"`), which would
// hand the parent a plausible but wrong PID with nothing to catch it.
let pid_file = std::path::PathBuf::from(pid_file);
let staged_pid_file = pid_file.with_extension("tmp");
fs::write(&staged_pid_file, pid.to_string()).expect("child must stage its Hyper PID");
fs::rename(&staged_pid_file, &pid_file)
.expect("child must publish its Hyper PID to parent");

loop {
thread::park();
Expand Down Expand Up @@ -78,12 +87,13 @@ fn wait_for_reported_pid(pid_file: &std::path::Path, timeout: Duration) -> Resul
Ok(contents) => match contents.trim().parse::<u32>() {
Ok(pid) => return Ok(pid),
Err(_) => {
// The child reports its PID with `fs::write`, which is
// `File::create` (truncates/creates) then `write_all` —
// not atomic. A poll can land in the window where the
// file exists but is still empty or partially written;
// treat that the same as "not created yet" rather than a
// fatal parse error, and keep polling to the deadline.
// The child publishes its PID with a rename, so a partial
// read should not be observable at all. Tolerate one
// anyway instead of panicking the whole test on it: this
// retry is what keeps a writer that ever drops back to a
// plain, non-atomic `fs::write` from resurrecting the
// flake. Treat it as "not created yet" and keep polling to
// the deadline.
last_unparseable = Some(contents);
}
},
Expand All @@ -103,23 +113,26 @@ fn wait_for_reported_pid(pid_file: &std::path::Path, timeout: Duration) -> Resul
}
}

/// The parent's `fs::write` of the PID report is `File::create` (truncates or
/// creates, leaving an empty file momentarily) followed by `write_all` — not
/// atomic. A poll landing in that window used to make `"".parse::<u32>()`
/// panic the whole test; it must instead be treated as "not ready yet" and
/// retried to the deadline.
/// The *child* writes the PID report; the parent is the one polling for it.
/// The child now publishes atomically (temp file plus `fs::rename`), but a
/// plain `fs::write` is `File::create` (truncates or creates, leaving an empty
/// file momentarily) followed by `write_all` — not atomic. A poll landing in
/// that window used to make `"".parse::<u32>()` panic the whole test; it must
/// instead be treated as "not ready yet" and retried to the deadline, so a
/// writer that ever drops back to a non-atomic `fs::write` cannot resurrect
/// the flake.
#[test]
fn wait_for_reported_pid_retries_past_a_torn_write() {
let temp_dir = tempfile::tempdir().expect("create temp dir for torn-write simulation");
let pid_file = temp_dir.path().join("hyperd-pid");

let writer_pid_file = pid_file.clone();
let writer = thread::spawn(move || {
// Reproduces the exact non-atomic sequence: create (truncate) first,
// leaving the file present-but-empty for a deliberate window, then
// write the real content — same as the production `fs::write` call
// this helper polls for, just with the empty window stretched out
// long enough that a fast poller is guaranteed to observe it.
// Reproduces the non-atomic sequence a plain `fs::write` performs:
// create (truncate) first, leaving the file present-but-empty for a
// deliberate window, then write the real content — just with the empty
// window stretched out long enough that a fast poller is guaranteed to
// observe it.
fs::File::create(&writer_pid_file).expect("create pid file (torn-write simulation)");
thread::sleep(Duration::from_millis(150));
fs::write(&writer_pid_file, "4242").expect("finish torn-write simulation");
Expand Down
72 changes: 72 additions & 0 deletions hyperdb-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,78 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
than losing scratch tables. Load with `persist: true` if data must survive a
panicking tool call. Fixes
[issue #266](https://github.com/tableau/hyper-api-rust/issues/266).
- **`discover()` rejected a live daemon over an unrecognized `identity`
block, defeating forward compatibility.** The client fast path deserialized
the whole `DaemonRecord` (added in 0.7.3) — including the nested `identity`
object — and discarded the enrichment immediately after
(`record.info().clone()`), so the added strictness bought nothing while
costing compatibility across daemon/client version skew: a retyped
`identity`, a reshaped `executable_path`, or an `identity` missing
`executable_path` each made a well-formed, live daemon's `daemon.json` fail
to parse, and `discover()` returned `None` for a healthy process.
`wait_for_daemon` (`spawn.rs`) then timed out and silently fell back to a
private `hyperd`, `status_degraded` (`server.rs`) reported no daemon, and
`Engine::is_running` returned false. `discover()` now reads the file once
and parses those bytes twice: first as the strict `DaemonRecord`, which
remains the primary path, and — only when that fails — as a bare
`DaemonInfo`, which ignores unknown fields and never trips over a nested
object this fast path does not need. A record that only the tolerant
fallback could read is deliberately left on disk when its health check
fails, instead of being stale-cleaned: such a record is exactly what a
*newer* daemon looks like to an older client, the daemon rewrites
`daemon.json` only on `hyperd` restart, and deleting it over one failed
300 ms PING would hide a live daemon from every client on the machine, not
just this one. A strictly parsed record on a dead port is still cleaned up
as before. The doctor's separate raw reader is unaffected and keeps
the strict contract, since it deliberately surfaces a malformed `identity`
block as a diagnostic. Fixes
[issue #270](https://github.com/tableau/hyper-api-rust/issues/270).
- **The "atomic" discovery-file write wasn't atomic, on the strength of a
false premise about Windows.** `write_discovery_record` deleted the
existing `daemon.json` before renaming the temp file into place, with a
comment claiming "`rename` fails if target exists" on Windows — but
`std::fs::rename` replaces an existing target on both Windows
(`MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`, falling back to
`SetFileInformationByHandle`) and Unix. The unnecessary delete opened a
window where the file did not exist at all; since `try_restart_hyperd`
rewrites this file on every `hyperd` restart, a concurrent `discover()`
could observe `Missing` mid-restart. The write is now a single `rename`,
matching what the function's doc comment already promised.
- **An oversized-but-well-formed discovery record was reported as
`Malformed`.** The doctor's bounded raw reader rejected any file over 64
KiB before attempting to parse it, so a file that was perfectly valid JSON
— just unexpectedly large — sent the user to fix "invalid JSON" that
parsed fine. `RawDiscoveryRead` gained an `Oversized` variant so the doctor
reports what actually happened instead.
- **A daemon peer that closed a health-command connection without writing
anything was reported as a successful, empty response.** `daemon_stop`
printed `Daemon responded:` with nothing after it and exited 0, which a
calling script would read as a successful stop. `send_command_with_timeout`
now returns `UnexpectedEof` when the connection ends before any bytes are
read. `doctor`'s separate `send_doctor_command` read loop had the same
shape and now classifies a silent close identically. This changes one
`doctor` warning: a recorded daemon candidate that accepts the connection
and then closes without answering is now reported as
`daemon_discovery_candidate_unreachable` ("did not return fresh enriched
STATUS") rather than `daemon_status_malformed` ("returned malformed or
unenriched STATUS"), which is what actually happened — the peer sent no
response to be malformed. Fixes 3 of the 5 items in
[issue #275](https://github.com/tableau/hyper-api-rust/issues/275).
- **After restarting `hyperd`, the daemon advertised the new endpoint over the
health port before writing it to the discovery file.** `try_restart_hyperd`
copied the endpoint into the in-memory `DaemonInfo` that `STATUS` serves and
released that lock *before* rewriting `daemon.json`. Since
`discovery::discover()` — the path every client's `Engine::new` takes —
reads the file, a client discovering inside that window connected to the
`hyperd` the daemon had just dropped. The write failing was worse than the
window: the error return happened with the new endpoint already published to
`STATUS` and the replacement `HyperProcess` dropped on the way out, leaving
`STATUS` durably naming a `hyperd` the daemon itself had killed. The
discovery file is now persisted first and the `DaemonInfo` flipped second,
both under one lock, so observing the new endpoint through either channel
implies a live `hyperd` behind it and no `STATUS` reader can see an endpoint
the file has not committed. Fixes
[issue #284](https://github.com/tableau/hyper-api-rust/issues/284).

## [0.5.0] - 2026-06-07

Expand Down
Loading