diff --git a/.agents/skills/project-netdata-vendoring/SKILL.md b/.agents/skills/project-netdata-vendoring/SKILL.md index 86b3f310..f2d95607 100644 --- a/.agents/skills/project-netdata-vendoring/SKILL.md +++ b/.agents/skills/project-netdata-vendoring/SKILL.md @@ -168,7 +168,50 @@ Load this skill for any task involving: If any downstream vendored-source change is not understood, do not run `vendor-to-netdata.sh`. -7. Record the CI/scanner preflight in the active SOW before vendoring. +7. Assess performance impact (AGENTS.md, Test Cadence). + + Benchmarks take hours; run them only when the vendored changes can affect + established-session steady state. Collect the SOW gate performance + assessments for every source change since the baseline (step 5), and + record in the active SOW whether any changed code runs per request on an + established session, with file:line evidence. + + - No such change (connection setup, reconnect, error mapping, idle-timeout + paths, shutdown, tests, docs, build): no benchmark run; record the + assessment. + - Such a change exists: run the affected suites once on the candidate + commit: + + ```bash + # Measure (run-*), then render the report and check floors (generate-*). + bash tests/run-posix-bench.sh benchmarks-posix.csv + bash tests/generate-benchmarks-posix.sh + # On the Windows host: + bash tests/run-windows-bench.sh benchmarks-windows.csv + bash tests/generate-benchmarks-windows.sh + ``` + + Compare against the committed CSVs (`git diff`). If rows regress beyond + noise, fix all regressions, then rerun once; bisect by SOW commit. + - Assessment uncertain: ask the user before running anything. + + A regression that is neither fixed nor explicitly accepted by the user + blocks vendoring. + + Extended suites (fuzz, stress, randomized chaos, lookup scale) are not + part of correctness runs. Run them once on the candidate commit when the + vendored changes touch what they exercise (codecs and decoders for fuzz; + concurrency, session lifecycle or transports for stress), and record the + result or the reason none was needed: + + ```bash + bash tests/run-extended-fuzz.sh + bash tests/run-extended-stress.sh + ``` + + A failure blocks vendoring until it is fixed. + +8. Record the CI/scanner preflight in the active SOW before vendoring. Include: @@ -176,9 +219,13 @@ Load this skill for any task involving: - GitHub Actions/check-run summary; - code-scanning summary by tool and severity; - Dependabot and secret-scanning summary; + - Go version alignment: the `go` directive in the three plugin-ipc + `go.mod` files equals Netdata's `src/go/go.mod`, and `GoVersionOverride` + in the three `osv-scanner.toml` files equals the patch release Netdata + pins in `packaging/check-for-go-toolchain.sh`; fix any mismatch first; - decision: proceed, block, or proceed with explicit risk acceptance. -8. Only after the CI/scanner preflight and two-way migration plan pass, update +9. Only after the CI/scanner preflight and two-way migration plan pass, update the Netdata vendored copy using the project-local vendor workflow. ```bash @@ -187,14 +234,22 @@ Load this skill for any task involving: bash ./diff-netdata-vendor.sh /path/to/netdata ``` -9. Validate in the Netdata checkout. +10. Validate in the Netdata checkout. Run the targeted Netdata build/tests required by the active SOW. At minimum, prove: - the vendor diff is expected after copying; - Netdata-only wrapper files were not overwritten; - - unrelated Netdata files were not staged or modified by the vendoring step. + - unrelated Netdata files were not staged or modified by the vendoring step; + - every vendored `.c` file is in Netdata's build lists + (`src/libnetdata/SourceLists.cmake`), and every system library the new + code calls is linked to the `netipc` target in + `src/libnetdata/CMakeLists.txt` (the script does not edit either file). + + Netdata builds `netipc` on Linux and Windows only; the Windows build is + proven by the Netdata PR's "Build Windows" check, so the SOW stays open + until that check passes. ## Blockers @@ -210,6 +265,8 @@ Stop and report if any of these are true: - either side has NetIPC changes since the last vendoring that are not understood; - the active SOW does not contain a migration plan for both directions; +- the performance assessment is missing, or a benchmark run it called for + shows a regression that is neither fixed nor explicitly accepted by the user; - source tree has uncommitted changes that are intended for vendoring; - Netdata checkout has unrelated changes that would be hard to isolate. @@ -228,5 +285,7 @@ Netdata vendoring preflight: - Upstream gap: - Downstream gap: - Migration plan: +- Performance: +- Extended suites: - Decision: proceed | blocked | proceed with user-accepted risk ``` diff --git a/.agents/sow/current/SOW-0021-20260613-netipc-at-scale.md b/.agents/sow/current/SOW-0021-20260613-netipc-at-scale.md index 75740e97..e8f2e396 100644 --- a/.agents/sow/current/SOW-0021-20260613-netipc-at-scale.md +++ b/.agents/sow/current/SOW-0021-20260613-netipc-at-scale.md @@ -1359,7 +1359,7 @@ Pending. ## Followup -None yet. +- 2026-10-31: the Netdata consumer steps of the "Downstream Vendoring Plan" (step 7: OVERSIZED_ITEM / PAYLOAD_EXCEEDED handling and status names) were never done; the statuses are already vendored in Netdata. They are now tracked in SOW-0050, including a confirmed network-viewer caching bug. This SOW closes after the pre-vendoring full POSIX and Windows benchmark run and after the remaining broader adversarial matrix gaps are handled (Execution Log, artifact gate entry). ## Regression Log diff --git a/.agents/sow/current/SOW-0050-20261031-netdata-vendor-robustness-fixes.md b/.agents/sow/current/SOW-0050-20261031-netdata-vendor-robustness-fixes.md new file mode 100644 index 00000000..14953f2d --- /dev/null +++ b/.agents/sow/current/SOW-0050-20261031-netdata-vendor-robustness-fixes.md @@ -0,0 +1,273 @@ +# SOW-0050 - Netdata Vendor Robustness Fixes + +## Status + +Status: paused + +Sub-state: paused 2026-11-07 for SOW-0070 (PR review findings); Netdata consumer fixes done locally in the worktree; re-vendor after SOW-0070. + +## Requirements + +### Purpose + +Deliver the SOW-0044 (dead peer detection) and SOW-0043 (handshake, timeouts, shutdown) fixes to Netdata, whose plugins consume NetIPC as vendored source. + +### User Request + +From SOW-0040 decisions (2026-10-29, decision 3a): the robustness fixes are vendored into Netdata together in one PR after SOW-0043, through the vendoring preflight. Pushing and opening the Netdata PR need explicit user approval. + +### Assistant Understanding + +Facts: + +- Netdata consumes NetIPC through vendored copies of `src/libnetdata/netipc`, `src/crates/netipc` and `src/go/pkg/netipc` (`vendor-to-netdata.sh`, `diff-netdata-vendor.sh`). +- The current Netdata consumers are C on Linux using SHM_FUTEX with a 5 s call timeout (survey recorded in SOW-0044, `ktsaou/netdata @ ae9113f2cc`). N5 (SHM client waits the full timeout after the server dies) affects them today. +- `.agents/skills/project-netdata-vendoring/SKILL.md` is mandatory before touching a Netdata checkout. +- Netdata consumer fixes carried from SOW-0021's downstream plan (checked 2026-10-31 against `ktsaou/netdata @ ae9113f2cc`; the statuses are already vendored there, the consumers were never adapted): + - confirmed bug: `src/collectors/network-viewer.plugin/network-viewer-apps-lookup-client.c:503-537` special-cases only `NIPC_PID_LOOKUP_UNKNOWN`. An `NIPC_PID_LOOKUP_OVERSIZED_ITEM` item carries `cgroup_status == KNOWN (0)`, `starttime == 0` and empty strings (`docs/codec-apps-lookup.md`, "Field Semantics"), so it is cached as a known PID with empty data. It must be handled as not enriched and not cached; an escaped `PAYLOAD_EXCEEDED` must be treated as a contract failure, not as data; + - `src/collectors/apps.plugin/apps-cgroups-lookup-client.c:381-403`: `NIPC_CGROUP_LOOKUP_OVERSIZED_ITEM` already ends as a final non-retry state (only `UNKNOWN_RETRY_LATER` is re-requested) but is counted as a retry miss; count it separately; + - `src/collectors/cgroups.plugin/cgroup-lookup-test/cgroup-lookup-test.c`: print names for `PAYLOAD_EXCEEDED` and `OVERSIZED_ITEM`. +- SOW-0035 (2026-10-31) moved plugin-ipc to rustfmt style edition 2024, formatting-only. Its Netdata half (the same formatting in the vendored `src/crates/netipc/`) is delivered by this vendoring; the vendored copy must pass `rustfmt --edition 2024 --check`. Netdata's workspace already uses edition 2024 and has no `rustfmt.toml` (`ktsaou/netdata @ ae9113f2cc`, `src/crates/Cargo.toml:66`). +- SOW-0045 (2026-10-31) added `src/libnetdata/netipc/src/transport/windows/netipc_win_security.c` (Windows access policy) and links it against `advapi32`. Netdata lists Windows NetIPC sources by hand: `netipc/src/transport/windows/netipc_win_security.c` must be added to `NETIPC_WINDOWS_TRANSPORT_FILES` (`ktsaou/netdata @ ae9113f2cc`, `src/libnetdata/SourceLists.cmake:472-475`). `advapi32` is already linked on Windows (`src/libnetdata/CMakeLists.txt:145`). The Windows plugins (apps.plugin server, network-viewer.plugin client) both run as LocalSystem, which the new access policy accepts. +- SOW-0058 (2026-10-31) moved plugin-ipc to `go 1.27.0` (Netdata's version). Its Codacy re-check needs the branch pushed: the preflight here records the Codacy Trivy `go.mod` standard-library findings after the push (33 open on `main` `63919ecb7d` before the bump). + +Inferences: + +- The SOW-0044 performance assessment found no established-session per-request change beyond one `Option` read in Rust, so its part of the vendored diff needs no benchmark run; SOW-0043's assessment decides the rest. SOW-0051 adds two monotonic clock reads per call (call start and remaining-time computation), so the vendored diff includes an established-session per-request change and the pre-vendoring benchmark run covers it. SOW-0062 adds one branch per client packet send and one queue-empty check per packet receive on the POSIX baseline, and a non-blocking progress send for pipelining clients; the pre-vendoring POSIX run covers its rows (`uds-pipeline-d16`, `uds-pipeline-batch`, and the single-in-flight UDS rows). + +Unknowns: + +- The last source-to-Netdata vendoring baseline must be re-established by the preflight at execution time. + +### Acceptance Criteria + +- The vendoring preflight (CI, code/secret/dependency scanning, baseline, two-way gap analysis, migration plan, performance assessment) is recorded here and passes. +- The Netdata checkout builds and its NetIPC consumers pass the targeted tests after vendoring; the post-vendor diff matches the expected normalized diff. +- The Netdata PR is opened only after user approval. + +## Analysis + +Sources checked: + +- SOW-0044 Followup and Outcome; SOW-0043 scope; `.agents/skills/project-netdata-vendoring/SKILL.md`. + +Current state: + +- Both fix SOWs are on branch `sow-0040-robustness-repro-tests` in `plugin-ipc`. The branch was pushed on 2026-11-04 with user approval (candidate commit `2eee40a`, after SOW-0064 and SOW-0056). + +Risks: + +- Downstream drift in Netdata's vendored copy since the last vendoring may conflict with these fixes; the preflight's two-way analysis covers it. + +## Pre-Implementation Gate + +Status: ready (preflight passed 2026-11-06) + +Problem / root-cause model: + +- Netdata's vendored NetIPC lacks the fixes of the SOWs completed since the last vendoring. The first vendoring (2026-11-06) used source commit `4261919` (branch `sow-0040-robustness-repro-tests`), after `2eee40a` and the later SOW-0066 and SOW-0067 fixes; the re-vendoring after SOW-0070 uses that SOW's commit. Each candidate's preflight evidence is in the Execution Log. + +Evidence reviewed: + +- The vendoring preflight in the Execution Log: CI and check runs, code-scanning, Dependabot and secret-scanning results with each alert's disposition, the reconstructed baseline and two-way gap analysis, the migration plan, and the same-host benchmark A/B (decisions of 2026-11-04 and 2026-11-06). + +Affected contracts and surfaces: + +- Netdata vendored NetIPC trees (C, Rust, Go) and the Netdata plugins that call NetIPC. + +Existing patterns to reuse: + +- `vendor-to-netdata.sh`, `diff-netdata-vendor.sh`, the `project-netdata-vendoring` preflight, SOW-0027 as the previous vendoring record. + +Risk and blast radius: + +- Every Netdata plugin using NetIPC; the new disconnected error code is a new public enum value that Netdata callers must treat as a failed call (they already treat any non-OK result as failure). + +Sensitive data handling plan: + +- Cite Netdata evidence as `owner/repo @ commit` with repo-relative paths; no workstation paths, tokens or private endpoints in this SOW, Netdata commit messages or the PR. + +Implementation plan: + +1. Run the vendoring preflight and record it here. +2. Vendor, diff, build and test in the Netdata checkout. +3. Ask the user before pushing and opening the PR. + +Validation plan: + +- Preflight evidence; post-vendor diff; Netdata build and targeted NetIPC consumer tests. + +Artifact impact plan: + +- Expected none in `plugin-ipc` beyond this SOW; revisit at close. + +Open-source reference evidence: + +- Netdata checkout commit recorded at activation. + +Open decisions: + +- Push and PR approval: resolved (2026-11-06, user; netdata/plugin-ipc#18 and netdata/netdata#24633 are open). +- Remaining close condition: Netdata PR CI passes on the re-vendored commit. + +## Implications And Decisions + +Ordering (2026-10-31, user): the full POSIX and Windows benchmark run happens after SOW-0045 and SOW-0058 land, and before this vendoring. SOW-0057 waits until after this vendoring. +- Ordering (2026-10-31, user, supersedes earlier ordering): finish all code changes first — SOW-0045, SOW-0058, SOW-0057, SOW-0049, SOW-0059, and SOW-0056 when the FreeBSD 15.1 VM arrives — then run the full benchmarks and the SOW-0050 vendoring together; benchmark regressions are fixed and re-vendored. + +- CI on `d54b22c` (SOW-0066, dispatched on the branch): Runtime Safety success (all Linux targets), CodeQL success (all five languages, Go Windows included), Codacy Local Analysis success, Static Analysis failure only in "Go Static Analysis (src/go) / Report gosec status", Supply Chain Security failure only in OpenSSF Scorecard (default branch only; Semgrep and OSV-Scanner pass), Codacy Coverage failure only in the upload (decision 5). + - Open code-scanning alerts on the branch: 7766 gosec G115 (`src/go/pkg/netipc/service/raw/client.go:71`, `uint32(ms)` after the `ms > ^uint32(0)` check and `left > 0`, so bounded: false positive); 7764 and 7765 Semgrep `chmod` (stale: the calls are gone, and the codacy-local analysis of `d54b22c` reports 0 results under a different tool name, so GitHub does not close them). Dismissal with evidence comments awaits user approval (the dismiss call was refused by the session's permission policy). +- Preflight decisions (2026-11-04, user accepted every recommendation): + 1. CI fixes land in a separate SOW (SOW-0066) with its own commit, which becomes the vendoring candidate. + 2. The macOS `chmod` findings are fixed with fd-relative calls (SOW-0066). + 3. CodeQL alerts 7759 to 7763 are dismissed with evidence comments (SOW-0066). + 4. Semgrep is pinned (SOW-0066). + 5. The Codacy coverage upload failure ("No Write permission on Project" for the `CODACY_API_TOKEN` account) is accepted for this vendoring; the user fixes the token permission later. + 6. Pushing SOW-0066's commit to the branch and rerunning the workflows is approved. + 7. The POSIX benchmarks rerun after SOW-0066, on a quiet workstation. + 8. Performance is settled with same-host full runs of `63919ec` (what Netdata runs today) and of the candidate, on Windows and on a quiet POSIX host; the candidate's runs become the committed `benchmarks-*.csv`. + 9. The Windows scratch tree `~/src/plugin-ipc-bench-base` is kept for decision 8. + The Windows power plan stays unchanged; changing it was not approved. +- Performance and vendoring decisions (2026-11-06, user: "I like your picks"): + 1. A: the possible Windows snapshot-baseline cost (about 5%, paired geometric mean 0.951) and the possible Go-client SHM batch cost on POSIX (about 2%, pinned) are accepted. Snapshot refresh runs about once per second per plugin against a capacity of about 50k/s. + 2. A: the committed `benchmarks-*.csv` (2026-06-15) are not replaced in this vendoring; the same-host A/B in this SOW is the performance evidence. The host-wide drop on both benchmark hosts and the re-baseline of CSVs and floors are tracked by SOW-0069. This supersedes the "commit the candidate's runs" part of decision 8. + 3. A: push `4261919` and rerun the workflows; it is the vendoring candidate. + 4. A: vendor into Netdata. The user asked for everything to be on GitHub for approvals (a plugin-ipc PR and a Netdata PR), and for the vendoring to use a git worktree under `~/src/PRs/`. +- Vendoring (2026-11-06): git worktree `~/src/PRs/netipc-vendor-20261106` from the user's Netdata checkout, branch `netipc-vendor-20261106` from `netdata/netdata @ 3483002881` (upstream master; its vendored trees still have only the two downstream commits analysed above). `diff-netdata-vendor.sh` before: 201 difference lines; `vendor-to-netdata.sh` from plugin-ipc `4261919`; Go import paths rewritten to `github.com/netdata/netdata/go/plugins` (113 files); after: no C, Rust or Go differences. Changes stay inside `src/libnetdata/netipc`, `src/crates/netipc` and `src/go/pkg/netipc` (180 modified, 60 added, the 2 `shm_linux*.go` removed by the `shm_posix*.go` rename) plus the two build files: `netipc_win_security.c` added to `NETIPC_WINDOWS_TRANSPORT_FILES` (the only vendored `.c` missing from the lists; headers are not listed by convention) and `target_link_libraries(netipc PUBLIC advapi32)` for Windows (libnetdata links advapi32 before netipc, which a static link cannot resolve). Rust: the crate needs only `libc`, already a workspace dependency; Go: Netdata's `go.mod` already requires `golang.org/x/sys v0.49.0`. +- Netdata validation on Linux (worktree build directory, the main checkout's `ENABLE_*` options, submodules initialized): configure and full build pass with no warning in NetIPC sources; `apps-lookup-protocol-test`, `apps-lookup-netipc-lock-test`, `apps-cgroups-lookup-client-abort-test`, `cgroup-lookup-netipc-test` pass; `go test ./pkg/netipc/...` and `go vet` pass; `cargo test -p netipc` 401 passed; `cargo fmt -p netipc --check` and `cargo doc -p netipc` (0 warnings) clean. +- Netdata commit `42313ad410` pushed to the user's fork; PRs opened at the user's request: netdata/plugin-ipc#18 (branch `sow-0040-robustness-repro-tests`) and netdata/netdata#24633. Netdata builds `netipc` on Linux and Windows only (`src/libnetdata/CMakeLists.txt`), so the library's FreeBSD and macOS support is not used by Netdata yet; this vendoring keeps that scope. +- SOW-0067 (`bbc55a2`, 2026-11-04) fixes the endpoint lock and SHM region file under a restrictive umask, found during SOW-0066 validation; it becomes the vendoring candidate. Pushing it and dismissing alerts 7764 to 7766 approved (2026-11-05, user, "1a 2a"). +- Alerts dismissed with evidence comments (2026-11-05): 7764 and 7765 as stale (the `chmod` calls were removed in `d54b22c`; last reported at `2eee40a`), 7766 as a false positive (bounded conversion). +- CI on `bbc55a2` (dispatched): Runtime Safety, CodeQL and Codacy Local Analysis succeed. Codacy Coverage failed earlier than the accepted upload failure: `llvm-profdata merge` crashed on a mode 0200 profile written by SOW-0067's Rust umask test child; repaired as a SOW-0067 regression (`292c6dc`). Static Analysis still fails in "Report gosec status": the step fails on any gosec finding, and dismissing alert 7766 does not change gosec's own result; `80a56de` adds the repository's usual `#nosec G115` justification at `client.go:71` (local gosec 2.29.0 does not flag the line; CI installs `gosec@latest`). Supply Chain Security fails only in OpenSSF Scorecard (default branch only). +- `tests/run-coverage-go.sh` reports 88.4% against its 90% threshold; `d54b22c` gives 88.3%, so the shortfall predates SOW-0066 and SOW-0067. No workflow runs this threshold. Tracked by SOW-0068 after this vendoring (user, 2026-11-05, "2a"). Push of `292c6dc` and `80a56de` and the workflow rerun approved (user, "1a"). +- CI on `80a56de` (the vendoring candidate; dispatched): Runtime Safety (all Linux targets), CodeQL, Codacy Local Analysis and Static Analysis succeed (the gosec status step passes with the annotation). Codacy Coverage fails only in "Upload C coverage to Codacy" (decision 5; report generation passes). Supply Chain Security fails only in OpenSSF Scorecard (default branch only). Open code-scanning, Dependabot and secret-scanning alerts: 0, 0, 0. +- Decision 8A runs started 2026-11-05: on the Windows test host, full `tests/run-windows-bench.sh` runs of `63919ec` and then `80a56de` from `git archive` exports, one after the other on the idle host (about 11% CPU from the resident Netdata agent, as in earlier runs). Extended fuzz and stress suites started on the Linux workstation on `80a56de` plus uncommitted SOW files only. The POSIX A/B waits for a quiet workstation (load average 15 to 33 on 112 cores from unrelated workloads on 2026-11-05). +- Extended suites on `80a56de` (Linux, 2026-11-05): `tests/run-extended-fuzz.sh` 15 passed, 0 failed (12 Go fuzz targets at 60 s each, the C harness with 1 MB random input and with 1000 x 1 KB inputs, the Rust proptest at 100000 cases). `tests/run-extended-stress.sh` 7 of 7 passed: `test_windows_bench_stability_policy`, `test_stress` (C), `test_chaos_extended`, `test_lookup_scale_interop`, `test_stress_go`, `test_stress_rust`, `test_lookup_scale_shm_interop`. +- Windows decision 8A, first attempt: the `63919ec` run finished (07:29 to 12:26 host time, 197 rows); the `80a56de` run stopped at its first run directory (`mkdir -m 0700` fails on MSYS). Repaired as a SOW-0066 regression (`4261919`, Windows bench script only); the candidate run restarted on a `git archive` export of `4261919`, which becomes the vendoring candidate (the other files equal `80a56de`). +- `63919ec` Windows rows lost: shm-ping-pong rust->c, rust->rust and rust->go and snapshot-shm rust->rust fail the stability gate (as on every earlier run on this host); snapshot-baseline rust->rust repeat 2 reports `client: 1 errors`, a failed call during measurement, and its diagnostic rerun is a raw-ratio outlier (1.357 against 1.35). To be compared with the candidate run before any conclusion. +- POSIX decision 8A started 2026-11-05 10:3x UTC on the Linux workstation (CPU pressure about 2%, no memory or IO pressure, load average about 10 on 112 cores): full `tests/run-posix-bench.sh` runs of `63919ec` and then `4261919` from `git archive` exports, both under umask 022 (the `63919ec` script creates its run directory with a plain `mkdir`); load and pressure sampled every minute alongside. +- Windows full A/B result (`63919ec` 197 rows, `4261919` 200 rows; 195 common): geometric mean of throughput ratios 0.964, with max-rate ratios from 0.58 to 1.42 in both directions, including rows whose code did not change (shm-ping-pong c->c 1.42, np-pipeline-d16 c->c 1.30). Two sequential five-hour runs cannot resolve differences of that size on this host. Consistent: np-pipeline-batch-d16 at about 0.82 to 0.95 (the cost accepted in SOW-0064 decision 8A); lookup 1.09 to 1.24. To resolve: snapshot-baseline (c->c 0.63, rust->c 0.58) and np-batch-ping-pong (0.81 to 1.00). The 100k/s Named Pipe rows are capacity-bound in both runs (no client reaches 100k/s), so they behave as unchecked max-rate rows and move in both directions. The candidate run reports no client error; `63919ec`'s `client: 1 errors` did not recur. A targeted alternating A/B (snapshot-baseline and np-batch-ping-pong, max rate, two rounds of base then candidate) is running. +- The `63919ec` POSIX run fails its floors on today's workstation (uds-ping-pong c->c 72k/s against a 120k floor; the committed CSV has 156k), so the POSIX host also runs well below its committed numbers. +- POSIX full A/B (`63919ec` and `4261919`, 345 common rows, both under similar load: median load average 13.0 and 12.4, CPU pressure at most 5%): geometric mean 1.048. Lookups flat (0.97 to 1.07); Go rows up strongly (SOW-0063: snapshot-shm rust->go 3.70, uds-ping-pong go->go 1.66). Below 0.9: 9 scattered rows (shm-ping-pong rust->c 0.63, shm-batch-ping-pong go->c 0.72, uds-batch-ping-pong go->c 0.78, ...). Floors missed after retries: `63919ec` 31 rows, `4261919` 23 rows (uds-ping-pong about 65k to 78k/s on both against a 120k floor; committed c->c 156k). +- POSIX row-interleaved A/B (each row runs base then candidate, 10 s max rate, low priority; `.tmp` runner calling the bench binaries directly, since `tests/run-posix-bench.sh` has no row filter): shm-ping-pong, shm-batch-ping-pong and uds-batch-ping-pong, 27 rows x 3 rounds, geometric mean 1.114; the scattered full-run drops do not reproduce (shm-ping-pong rust->c 1.08, shm-batch-ping-pong go->c 1.04). Four rows below 0.9 got 6 more rounds (9 in all): uds-batch-ping-pong c->c 0.98, uds-batch-ping-pong go->rust 1.07, shm-ping-pong rust->rust 1.10, shm-batch-ping-pong go->rust 0.89 (baseline bimodal: 16M or 20M to 21M). Pinned to CPUs 51 (server) and 55 (client), separate cores sharing one L3, 6 rounds: shm-batch-ping-pong go->rust 0.984 and go->c 0.980, tight spreads. The 0.89 was CPU placement; the Go-client SHM batch rows may carry a cost of about 2%, at the edge of what 6 samples resolve. Code check: the SHM dead-peer probes added by `b450b40` run only after an idle receive timeout (C client and Rust server), not per request. +- Windows alternating A/B (snapshot-baseline and np-batch-ping-pong, max rate, `63919ec` then `4261919`, two rounds; with the full runs, three measurements per row): np-batch-ping-pong geometric mean 1.111 (the full-run 0.81 to 1.00 was noise). snapshot-baseline, paired by adjacent runs: 0.820, 0.902 and 1.143 (all 26 paired rows: 0.951, 18 below 1); the candidate is steady (means 49k, 54k, 54k/s) while the baseline swings (59k, 59k, 48k/s). A cost of about 5% is possible and has a plausible source: SOW-0064 made the Named Pipe client handle overlapped and measured a higher client CPU per call; a snapshot call reads a large response. SOW-0064 recorded one-in-flight rows as equal or higher, so this cost was not part of its decision 8A. +- Report generation on the candidate runs: `tests/generate-benchmarks-posix.sh` reports 26 floor violations (uds-ping-pong about 65k to 78k/s against 120k on all 9 pairs; snapshot-baseline, snapshot-shm and two shm-ping-pong rows); `tests/generate-benchmarks-windows.sh` rejects the CSV as incomplete (199 of 201 rows: shm-ping-pong c->rust and snapshot-shm rust->rust failed the stability gate). The committed CSVs date from 2026-06-15 (`95c53ef`; uds-ping-pong c->c 156k/s, p50 4 us). Workstation today: `schedutil` governor, STIBP always on, kernel 7.0.0-42; the June state is not recorded, so the cause of the host-wide drop is unknown. + +## Plan + +See the implementation plan above. + +## Execution Log + +### 2026-11-04 + +- Source branch pushed (user approval, "1a"). The workflows trigger on `main` and PRs only, so they were started with `workflow_dispatch` on the branch at `2eee40a`: Codacy Local Analysis, Codacy Coverage, CodeQL, Runtime Safety, Static Analysis, Supply Chain Security. `macos.yml` cannot be dispatched: it does not exist on the default branch yet (GitHub 404); macOS was validated on the macOS test host (SOW-0056). `dependency-review.yml` runs on pull requests only. +- Full benchmark runs started on `2eee40a` (Test Cadence; covers SOW-0051, SOW-0062, SOW-0063, SOW-0064 and SOW-0056): `tests/run-posix-bench.sh` on the Linux workstation (low priority; load average about 25 at start) and `tests/run-windows-bench.sh` on the Windows test host, both with default durations as for the committed reports. +- Vendoring baseline and two-way drift (read-only analysis): + - Baseline: `netdata/netdata @ 22f029781de1a0d817bad31fbc3c49915094b86d` (#24049) vendors plugin-ipc `5c1e145` (PR #17 branch); its squash on main is `63919ec`, with an identical vendored tree. The normalized `diff -rq` of the Netdata tree against `63919ec` is empty across 228 files. + - Upstream since `63919ec`: 25 commits touch the vendored trees (210 files, +20161/-5980), SOW-0031 through SOW-0064 and SOW-0056. + - Downstream since the baseline: two commits. `netdata/netdata @ 95167c963a` (#24164) is rustfmt-only; plugin-ipc HEAD already matches it (rustfmt on HEAD makes zero changes), so it is obsolete drift. `netdata/netdata @ a80f0e3dc0` (#24123) changes one rustdoc line, `src/crates/netipc/src/protocol/mod.rs` (`[directory]` to `` `directory` ``), which removes the only `cargo doc` warning; it must be backported to plugin-ipc first. + - Netdata-only files (wrappers `netipc_netdata.c/h`, the workspace `Cargo.toml`, the Go module path) did not change since the baseline. + - Build lists: add `netipc/src/transport/windows/netipc_win_security.c` to `NETIPC_WINDOWS_TRANSPORT_FILES` in Netdata's `src/libnetdata/SourceLists.cmake`. The file calls advapi32; Netdata's `netipc` target links nothing on Windows, so link `advapi32` to it (or prove the Netdata Windows build links as is). Rust and Go need no list change; Go additions, removals and the `shm_linux*.go` to `shm_posix*.go` rename ride the rsync `--delete`. + - Consumers: C plugins compare only ABORTED/TIMEOUT/OVERFLOW/BAD_ITEM_COUNT; the new DISCONNECTED/TRANSPORT codes compile and fall into their generic failure branches. No Netdata code outside the vendored trees uses the Rust or Go packages. +- CI on `2eee40a`: all six dispatched workflows failed. Triage: + - Product or test defects in this branch: + 1. Go `TestPipeDACLOwnerOnly`, `TestWinShmObjectsDACLOwnerOnly` (CodeQL Go Windows build step): the runner account is the built-in Administrator (RID 500), which SDDL prints as `LA`; the tests build the expected string from the full SID. The Rust tests `test_pipe_dacl_owner_only` and `test_server_objects_dacl_owner_only_windows` compare the same way; the C helper compares SIDs and is correct. The product DACLs are right. + 2. C `test_robustness` CLOEXEC case, every Linux target: it checks every fd from 3 up, and the runner passes two inherited pipes (fds 142 and 145) that NetIPC did not create. + 3. Rust build on `armv7-gnu` and `armv7-gnu-time64`: CI's Rust 1.99.1 links `+crt-static` armv7 gnueabihf binaries as static-pie, which needs `rcrt1.o`; Ubuntu's `libc6-dev-armhf-cross` ships none (the local Rust 1.97.1 does not hit this). + 4. staticcheck U1000: `service/raw/large_helpers_unix_test.go` helpers are used only by the `netipc_extended` stress tests; the no-op `setNoSigpipe` in `uds_platform_other.go` is unused on Linux and FreeBSD (accept4 path). + 5. flawfinder level 5 and Semgrep `c_race_rule-chmod` (code-scanning 7764, 7765): the two `chmod()` calls in the macOS-only `bind_owner_only_socket` (`netipc_uds_lifecycle.c:127,132`, SOW-0049). + - CodeQL alerts on the branch (none open on main): 7761 `cpp/path-injection` (`netipc_uds_lifecycle.c:321`, the endpoint lock path built from the caller's `run_dir`), 7763 `cpp/stack-address-escape` (`:299`, the listen test hook), 7759 `cpp/toctou-race-condition` (`tests/fixtures/c/test_uds.c:1349`), 7760 `cpp/world-writable-file-creation` (`tests/fixtures/c/uds_eintr_wrap.c:139`, the umask probe), 7762 `cpp/long-switch` note (`test_uds.c:342`). + - Not code: OpenSSF Scorecard runs on the default branch only. Semgrep Secrets crashes (`semgrep-core exited with -11`) with the unpinned latest semgrep, also on main since at least 2026-10-19. Codacy Coverage upload is refused with "No Write permission on Project" for the `CODACY_API_TOKEN` account (it last succeeded on main on 2026-09-27). Codacy Local Analysis fails because of the chmod finding (item 5). + - Dependabot: 0 open. Secret scanning: 0 open. +- POSIX benchmark run 1 is invalid: `tests/run-posix-bench.sh` creates its run directory with a plain `mkdir` (lines 576, 905); under the session umask `0002` it is group-writable, and the C driver (`tests/fixtures/c/interop_path.h:94`) refuses it, so every scenario with a C server or C client failed. The script fix is `mkdir -m 0700`. A rerun with umask `022` was stopped after its first row: the workstation load average was 93 from unrelated workloads, and c->c ping-pong read 53k against the committed 156k. +- Windows benchmark run on `2eee40a` (host idle at start): 190 of 196 rows published; 6 SHM rows (shm-ping-pong rust->c, go->c, c->rust, rust->go, go->go; snapshot-shm c->go) failed the stability gate (stable ratio up to 2.6) and their diagnostic reruns did not recover. Against the committed `benchmarks-windows.csv` (`95c53ef`, 2026-06-15) almost every max-rate row is 0.3x to 0.4x, including the in-process `lookup` rows that use no transport, which points to host drift since June rather than to code. A same-host A/B of `95c53ef` against `2eee40a` (lookup, shm-ping-pong, np-pipeline-batch-d16; C clients; two alternating rounds) is running to separate them. +- Windows A/B result (same host, rounds base, head, base, head; max-rate throughput, C clients): + + | Row | 95c53ef r1 | 95c53ef r2 | 2eee40a r1 | 2eee40a r2 | head/base | + |---|---|---|---|---|---| + | lookup c | 59.2M | 53.9M | 46.3M | 44.0M | 0.80 | + | np-pipeline-batch-d16 c->c | 16.8M | 18.2M | 16.9M | 15.7M | 0.93 | + | np-pipeline-batch-d16 c->rust | 18.2M | 17.3M | 16.7M | 15.4M | 0.90 | + | np-pipeline-batch-d16 c->go | 14.8M | 12.5M | 13.0M | 12.9M | 0.95 | + | shm-ping-pong c->c | 1.29M | 2.99M | 2.48M | failed | noisy | + | shm-ping-pong c->rust | failed | 2.78M | failed | 2.59M | noisy | + | shm-ping-pong c->go | 1.78M | 1.98M | failed | 1.23M | noisy | + + - Host drift is confirmed: the June commit itself now runs at 0.35x to 0.45x of its own committed numbers (lookup 122M then, 54M to 59M now; np-pipeline-batch c->c 46.7M then, 16.8M to 18.2M now). The committed `benchmarks-windows.csv` is not a valid reference on this host. + - The lookup gap comes from the benchmark itself: SOW-0025 (`cf8eaf9`, 2026-06-29, after `95c53ef`, already vendored to Netdata through SOW-0027) changed every lookup driver to take the cache read guard per pass of 16 lookups. + - The np-pipeline-batch gap is the cost accepted in SOW-0064 decision 8A. + - SHM ping-pong fails the stability gate on both commits on this host, so it gives no signal either way. Since the last vendoring (`63919ec`), the Windows SHM C and Rust code changed only in `nipc_win_shm_server_create` (DACL, SOW-0045) and in formatting; the Go change is SOW-0063's hot-path work. + - The vendoring reference is `63919ec` (what Netdata runs today), not `95c53ef`. + +### 2026-11-06 + +- PR checks on netdata/plugin-ipc#18 (head `9568f4e`, library sources equal to `4261919`): 41 pass, 4 skipped, 1 failure, "C, Rust, and Go Coverage" in its Codacy upload step only (decision 5). CodeQL first failed on new alert 7767 (`cpp/path-injection`, `netipc_uds_lifecycle.c:352`), the same finding as the dismissed 7761 at lines moved by SOW-0067; dismissed as a false positive (user, "1a"); CodeQL then passes and open code-scanning alerts are 0. +- PR checks on netdata/netdata#24633 (head `42313ad410`): 124 pass, 20 skipped, 1 failure, the SonarCloud quality gate. "Build Windows", "Test Windows installer" and the Go `windows/amd64` builds pass, which proves the `netipc_win_security.c` list entry and the `advapi32` link. +- SonarCloud findings, triaged and posted on the Netdata PR (user, "2a"): + - BLOCKER c:S3519 (`netipc_named_pipe.c:1784`): false positive. `recv_buf` is grown to at least `payload_len` (`ensure_recv_buf`, lines 1600-1611, called at 1718); a first read longer than the message is rejected (1699); continuation chunks are checked for header size (1752), header fields (1765-1768) and payload length (1775); `assembled + chunk_data > payload_len` is rejected before the copy (1780). + - 8 MAJOR c:S2753 (`netipc_win_shm.c` lines 40-163): test-only fault injection; the file-local enum has the same enumerators and values as the public test enum under `NIPC_INTERNAL_TESTING`, which Netdata never defines; the code is unchanged since `63919ec`, only shifted. + - Duplication on new code 3.2% against a 3% limit: vendored transport and test code; no refactor in a vendoring PR. + +## Validation + +Acceptance criteria evidence: + +- Vendoring preflight recorded and passing: candidate `4261919` (CI on `80a56de` and `4261919`, same library sources), code scanning, Dependabot and secret scanning 0 open, baseline `63919ec` / `netdata/netdata @ 22f029781d`, two-way gap analysis, migration plan, performance assessment with same-host A/B and user acceptance (decisions 2026-11-06), extended fuzz 15/15 and stress 7/7 (Implications And Decisions). +- Post-vendor normalized diff: no C, Rust or Go differences; Netdata-only wrappers, workspace manifest and Go module path preserved. +- Netdata builds and its NetIPC consumers pass on Linux; the Windows build passes in the Netdata PR CI (2026-11-06 log). +- PRs opened only at the user's request (decision 4A). + +Tests or equivalent validation: + +- Netdata worktree on Linux: full build without NetIPC warnings; `apps-lookup-protocol-test`, `apps-lookup-netipc-lock-test`, `apps-cgroups-lookup-client-abort-test`, `cgroup-lookup-netipc-test`; `go test ./pkg/netipc/...`, `go vet`; `cargo test -p netipc` 401; `cargo fmt --check`, `cargo doc` 0 warnings. +- Netdata PR CI: 124 checks pass, Windows build and installer included. +- Source suites per SOW (SOW-0031 to SOW-0067) on Linux, FreeBSD 14.3 and 15.1, macOS and Windows. + +Real-use evidence: + +- Netdata's apps and cgroups lookup tests start the real plugin-side NetIPC servers and clients from the vendored tree. + +Reviewer findings: + +- SonarCloud and CodeQL findings on the PRs are triaged above, with evidence; no code change was needed. + +Same-failure scan: + +- Vendored `.c` files against Netdata's build lists: `netipc_win_security.c` was the only one missing. System libraries called by new code: only `advapi32` (security descriptors), now linked. + +Sensitive data gate: + +- This SOW cites Netdata evidence as `netdata/netdata @ commit` with repo-relative paths, test hosts by role, and no tokens, private endpoints or workstation paths; the Codacy token is named only by its secret name. The worktree path is under the user's home and recorded with `~`. + +Artifact maintenance gate: + +- AGENTS.md: no update; no workflow change. +- Runtime project skills: `project-netdata-vendoring` step 10 now requires checking Netdata's build lists and link libraries, and records that the Windows build is proven by the Netdata PR CI (lesson below). +- Specs: no update; vendoring changes no protocol or API. +- End-user/operator docs: no update; the vendor scripts and their usage did not change. +- End-user/operator skills: `docs/netipc-integrator-skill.md` unaffected; no API or workflow change in this SOW. +- SOW lifecycle: follow-ups tracked as SOW-0068 and SOW-0069. Correction (2026-11-08, SOW-0070): this SOW is `paused` in `current/`; it moves to `done/` after the re-vendoring and the Netdata PR CI. + +Performance assessment: + +- Same-host A/B against `63919ec` on both hosts (Implications And Decisions); possible costs of about 5% (Windows snapshot-baseline) and about 2% (POSIX Go-client SHM batch) accepted by the user (decision 1A). + +## Outcome + +Netdata PR netdata/netdata#24633 vendors plugin-ipc `4261919` with the Windows build-list and `advapi32` link changes; its CI passes except the SonarCloud gate, whose findings are triaged as false positives on the PR. Source PR netdata/plugin-ipc#18 carries the SOWs since the previous vendoring. Merging both is the user's and the maintainers' decision. + +## Lessons Extracted + +- `vendor-to-netdata.sh` copies sources but not build integration: a new `.c` file and the system libraries it calls must be added to Netdata's CMake by hand, and only the Netdata Windows CI proves the Windows side. Recorded in the vendoring skill. +- Two sequential five-hour benchmark runs cannot resolve differences under about 20% on these hosts; row-interleaved runs with CPU pinning can. Host-wide drift since June is tracked by SOW-0069. + +## Followup + +- SOW-0068 (pending): restore Go coverage to its 90% threshold. +- SOW-0069 (pending): benchmark host drift and floor re-baseline. +- Codacy coverage upload permission: user action, accepted in decision 5; not code. + +## Regression Log + +None yet. diff --git a/.agents/sow/current/SOW-0015-20260605-codacy-scope-and-maintainability.md b/.agents/sow/done/SOW-0015-20260605-codacy-scope-and-maintainability.md similarity index 97% rename from .agents/sow/current/SOW-0015-20260605-codacy-scope-and-maintainability.md rename to .agents/sow/done/SOW-0015-20260605-codacy-scope-and-maintainability.md index 3bc1eebd..a9cf4638 100644 --- a/.agents/sow/current/SOW-0015-20260605-codacy-scope-and-maintainability.md +++ b/.agents/sow/done/SOW-0015-20260605-codacy-scope-and-maintainability.md @@ -2,9 +2,9 @@ ## Status -Status: paused +Status: completed -Sub-state: paused by user decision on 2026-06-11 so `SOW-0016` can take current active slot for a timeout/abort liveness fix. Resume from Codacy test/bench exclusion and refreshed production-source maintainability baseline work. +Sub-state: closed 2026-10-31 after refreshing the metrics and selecting the next target; the continuing file-by-file work moves to SOW-0057 and the Go version drift to SOW-0058. ## Requirements @@ -1082,19 +1082,30 @@ Lessons: Follow-up mapping: -- Netdata re-vendor work is planned in this SOW and will be performed after the SDK commit is pushed. +- Netdata re-vendor work from this SOW shipped in later vendorings (`netdata/netdata#22936`, merged 2026-07-01, closed in SOW-0027). + +### 2026-10-31 close-out + +- Codacy Cloud, public API, `main` `63919ecb7d` (2026-09-27): grade A (99), 33 issues, 60453 LOC (tests and benchmarks excluded by `.codacy.yml` `exclude_paths`, which Cloud honors), coverage 90.6%, complex files 32%, duplicated files 32% (baseline at `dbf77a8595`: 42% / 41%, with tests counted). Goals: 10% / 10%. +- The 33 issues are all Trivy Go standard-library findings on `go.mod` (`go 1.26.0`); Netdata has since moved to `go 1.27.0`, so the version rule this SOW recorded is now out of date: tracked in SOW-0058. +- Next target, by evidence: `jscpd --min-tokens 50` over production sources (branch tip `dbfa64a`) finds 9.6% duplicated lines; the largest pair is Rust `transport/windows.rs` (745 lines) / `transport/posix.rs` (577). Tracked with the full ranking in SOW-0057. ## Outcome -Pending. +Codacy metrics now cover production code only (tests and benchmarks excluded through `.codacy.yml`, which Cloud honors). Complex files fell from 42% to 32% and duplicated files from 41% to 32%. The next production target is selected with evidence. + +Correction (2026-11-07, SOW-0070): the 42% / 41% baseline counted tests and the 32% / 32% result counts production code only, so the two do not measure the same scope; the result is a production-only rebaseline, not evidence that complexity or duplication fell. ## Lessons Extracted -Pending. +- Codacy Cloud reads path excludes from a root `.codacy.yml`, not from `.codacy/codacy.config.json`. +- Codacy's duplicated-files metric counts any file with one duplicated block, so it stays far above line-level duplication (32% of files vs 9.6% of lines here); pick targets by duplicated lines, then confirm with the Cloud metric. ## Followup -None yet. +- File-by-file duplication work: SOW-0057. +- Go 1.27 alignment and the Trivy findings: SOW-0058. +- Search of this SOW for defer/later/follow-up/future/TODO/pending: the July re-vendor plan (shipped, see above) and historical narrative. ## Regression Log diff --git a/.agents/sow/current/SOW-0027-20260629-netdata-vendor-memory-safety-update.md b/.agents/sow/done/SOW-0027-20260629-netdata-vendor-memory-safety-update.md similarity index 91% rename from .agents/sow/current/SOW-0027-20260629-netdata-vendor-memory-safety-update.md rename to .agents/sow/done/SOW-0027-20260629-netdata-vendor-memory-safety-update.md index 5fc8fb1f..212650d1 100644 --- a/.agents/sow/current/SOW-0027-20260629-netdata-vendor-memory-safety-update.md +++ b/.agents/sow/done/SOW-0027-20260629-netdata-vendor-memory-safety-update.md @@ -2,12 +2,11 @@ ## Status -Status: paused +Status: completed -Sub-state: paused on 2026-07-12 so SOW-0030 can take the active slot for the -fleet-observed SHM SIGBUS crash. The earlier L3 API decision was resolved by using the -direct L2 snapshot path; resume from final validation, artifact maintenance, review, -and lifecycle closure for the already merged Netdata vendor update. +Sub-state: closed 2026-10-31 on existing evidence: the Netdata vendor update merged on 2026-07-01 (`netdata/netdata#22936`). Paused 2026-07-12 for SOW-0030; resumed only for final validation and lifecycle closure. + +Correction (2026-11-07, SOW-0070): no `diff-netdata-vendor.sh` result was recorded at close, so this record does not prove source-to-Netdata parity for `netdata/netdata#22936`. The next vendoring (SOW-0050) reconstructed the baseline from both repositories and records its own before and after vendor diffs. ## Requirements @@ -287,7 +286,8 @@ Acceptance criteria evidence: - Source post-backport preflight completed before Netdata vendoring: CI/checks, GitHub code scanning, Dependabot, secret scanning, and Codacy Cloud were checked for `bb7a73e0e2b871352eeb3c4403afbd69649a351d`. - Two-way gap analysis completed before vendoring and recorded in the 2026-07-01 execution log. - Valid Netdata-only vendored-source drift was backported to `plugin-ipc` before any Netdata copy. -- Netdata vendoring is implemented in the target checkout and the eBPF L2 adaptation builds; the first PR commit exposed CI/scanner findings that are being fixed source-first before re-vendoring. +- Netdata vendoring is implemented in the target checkout and the eBPF L2 adaptation builds; the first PR commit exposed CI/scanner findings, fixed source-first before re-vendoring. +- Final state (checked 2026-10-31, read-only `gh`): `netdata/netdata#22936` "Update vendored NetIPC to 98ec474" merged 2026-07-01T13:51:31Z, head `e5f57a1e93`, merge commit `ca9a5426a6`. The vendored source commit `98ec474` is an ancestor of plugin-ipc `HEAD`. Tests or equivalent validation: @@ -298,7 +298,9 @@ Tests or equivalent validation: - `git diff --check`; - SOW audit. - GitHub/Codacy validation for the new source backport commit passed as recorded above. -- Source-side CI/scanner repair validation passed locally as recorded in the 2026-07-01 execution log. Remote source CI/scanner validation is pending until the repair commit is pushed. +- Source-side CI/scanner repair validation passed locally as recorded in the 2026-07-01 execution log. +- Remote source checks on the vendored commit `98ec474`: 46 check runs succeeded; `Valgrind` was skipped on the 2026-07-01 run and a later 2026-07-07 run was cancelled: the run that gated vendoring skipped it, and the cancelled run came six days after the merge. Open GitHub code-scanning alerts on plugin-ipc: 0 (2026-10-31). +- PR check snapshot (2026-10-31): 107 pass, 20 skipping, 20 fail. All 20 failures are `Build / Test Source Build (...)` jobs that completed about two hours after the merge (e.g. check run 84540364193, completed 15:52 UTC) and failed in Docker buildx with "repository does not contain ref refs/pull/22936/merge": a post-merge re-run against a deleted merge ref, not a code failure. Real-use evidence: @@ -308,7 +310,7 @@ Real-use evidence: Reviewer findings: -- Not started. +- No external review. Netdata maintainers reviewed and merged the PR. Same-failure scan: @@ -326,7 +328,7 @@ Artifact maintenance gate: - Specs: no update needed until implementation changes behavior. - End-user/operator docs: no update needed until implementation changes behavior. - End-user/operator skills: no update needed until implementation changes behavior. -- SOW lifecycle: moved from pending to current before source backport and Netdata vendoring; remains in progress until Netdata commit, post-vendor checks, and final review are complete. +- SOW lifecycle: moved to `done/` at close. The `umask()` suppressions added during the scanner repair are superseded by SOW-0032, which removed the `umask()` calls. Specs update: @@ -354,7 +356,7 @@ Follow-up mapping: ## Outcome -In progress. Source backport and pre-vendor checks are complete, Netdata vendoring is copied, and the eBPF consumer now builds against the vendored opaque NetIPC cache API by using direct L2 snapshot access. Draft PR `netdata/netdata#22936` is open; its first commit exposed Go fix, Codacy, and Sonar findings that are being repaired source-first before re-vendoring. Post-vendor CI/scanner checks and final SOW closure are not complete yet. +Completed. The NetIPC memory-safety fixes reached Netdata through the source-first vendoring path: Netdata-only drift was backported, the CI and scanner findings from the first PR commit were fixed in plugin-ipc, and `netdata/netdata#22936` (vendoring plugin-ipc `98ec474`, with the eBPF consumer switched to direct L2 snapshot access) merged on 2026-07-01. ## Lessons Extracted @@ -362,7 +364,8 @@ In progress. Source backport and pre-vendor checks are complete, Netdata vendori ## Followup -None yet. +- Later NetIPC changes reach Netdata through SOW-0050. +- Search of this SOW for defer/later/follow-up/future/TODO/pending: remaining matches are historical narrative (the July "pending" states, now resolved above) and the SOW-0050 reference. ## Regression Log diff --git a/.agents/sow/pending/SOW-0031-20260712-linux-shm-build-gating.md b/.agents/sow/done/SOW-0031-20260712-linux-shm-build-gating.md similarity index 92% rename from .agents/sow/pending/SOW-0031-20260712-linux-shm-build-gating.md rename to .agents/sow/done/SOW-0031-20260712-linux-shm-build-gating.md index 868d2b20..35bac067 100644 --- a/.agents/sow/pending/SOW-0031-20260712-linux-shm-build-gating.md +++ b/.agents/sow/done/SOW-0031-20260712-linux-shm-build-gating.md @@ -2,10 +2,11 @@ ## Status -Status: open +Status: closed -Sub-state: tracked follow-up discovered by SOW-0030; execution must wait until the -active SOW completes. +Sub-state: merged into SOW-0048 (FreeBSD support) on 2026-10-31, before any +implementation. Its requirements and acceptance criteria live there; the FreeBSD build +is where the `NOT APPLE` gate fails. ## Requirements @@ -173,6 +174,11 @@ Open decisions: - Created as the real pending follow-up for the CMake/platform-contract discrepancy discovered during SOW-0030 research. +### 2026-10-29 + +- Overlap recorded: SOW-0048 (FreeBSD support) includes this C SHM build-gating fix; + merge the two before implementation. + ## Validation Acceptance criteria evidence: @@ -225,11 +231,13 @@ Lessons: Follow-up mapping: -- This file is the tracked follow-up from SOW-0030. +- All requirements are tracked by SOW-0048. ## Outcome -Pending. +Closed without implementation: merged into +`.agents/sow/current/SOW-0048-20261029-freebsd-support.md`, as the overlap note of +2026-10-29 required (AGENTS.md "One SOW At A Time": merge before implementation). ## Lessons Extracted diff --git a/.agents/sow/done/SOW-0032-20260712-uds-permission-contract.md b/.agents/sow/done/SOW-0032-20260712-uds-permission-contract.md new file mode 100644 index 00000000..62058ce9 --- /dev/null +++ b/.agents/sow/done/SOW-0032-20260712-uds-permission-contract.md @@ -0,0 +1,239 @@ +# SOW-0032 - Align POSIX UDS permission handling across languages + +## Status + +Status: completed + +Sub-state: delivered 2026-10-31. + +## Requirements + +### Purpose + +Define and enforce one secure, portable POSIX Unix-domain-socket permission contract for +C, Rust, and Go without process-global permission side effects in embedding programs. + +### User Request + +The user delegated technical choices to the assistant with an explicit requirement to +choose the long-term-best design without shortcuts. SOW-0030 scanner triage discovered +this separate cross-language security and embedding-safety concern. + +### Assistant Understanding + +Facts: + +- C currently changes the process-global `umask()` around `bind()` under a NetIPC-local + mutex to create the socket as owner-only. +- A NetIPC-local mutex cannot serialize unrelated threads in the embedding process that + create files while the temporary mask is active. +- Rust and Go bind their pathname sockets without the same explicit owner-only creation + mechanism, so final modes depend on the embedding process mask. +- The documented transport contract expects a caller-controlled private runtime + directory. + +Inferences: + +- Directory ownership and permissions are the portable primary security boundary for + pathname sockets. Exact socket mode may remain useful as defense in depth, but it must + not silently mutate process-global state in an embeddable library. + +Unknowns: + +- The portable mechanism and compatibility policy that should replace or constrain the + current language-specific behavior require focused research and cross-platform tests. + +### Acceptance Criteria + +- One explicit UDS runtime-directory and socket-mode contract is documented and applied + consistently to C, Rust, and Go. +- The implementation has no uncontrolled process-global permission window and includes + concurrency tests that exercise unrelated file creation during listener setup. +- Existing cross-language UDS interoperability and stale-endpoint recovery remain green. + +## Analysis + +Sources checked: + +- `src/libnetdata/netipc/src/transport/posix/netipc_uds_lifecycle.c:16-44,212` +- `src/crates/netipc/src/transport/posix.rs:637-679,995-1019` +- `src/go/pkg/netipc/transport/posix/uds_listener.go:23-60` +- `docs/level1-transport.md:559-561` +- GitHub code-scanning alert 7750 and its historical/fixed commits, recorded in SOW-0030. + +Current state: + +- The historical post-bind `chmod(path)` race is fixed. The remaining issue is the C + process-global `umask()` window and inconsistent C/Rust/Go permission behavior. + +Risks: + +- An unrelated thread can create a file with unexpectedly restrictive permissions while + C temporarily changes the process mask, causing availability or operational failures. +- Changing modes or rejecting unsafe directories can break existing integrations unless + compatibility and migration behavior are designed explicitly. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- C makes the listener socket owner-only by setting the process-global `umask()` around `bind()` under a library mutex (`netipc_uds_lifecycle.c`, `bind_owner_only_socket`). Any other thread of the embedding process that creates a file during that `bind()` gets mode `& ~0177`. Rust (`transport/posix.rs`, `UdsListener::bind`) and Go (`transport/posix/uds_listener.go`, `Listen`) set no mode, so their socket is `0777 & ~umask` (0755 with the usual 022 umask). The three languages therefore do not share one contract, and the C mechanism has process-wide side effects. + +Evidence reviewed: + +- Code above, plus the lock/probe/bind/listen order shared by all three (`acquire_endpoint_lock`, stale probe, bind, test hook, listen). +- Kernel behavior, measured on this workstation (Linux 7.0) and the FreeBSD 14.3 test VM: `fchmod(fd, mode)` on an unbound AF_UNIX socket succeeds, and `bind()` then creates the file with `mode & ~umask` (probe: umask 0, `fchmod 0600` -> file 0600, `0640` -> 0640; no fchmod -> 0777). A connect before `listen()` is refused on both. +- Kernel sources: + - `torvalds/linux @ 36aad21d50d1af3f6954bb0c988257a908202477`, `net/unix/af_unix.c`, `unix_bind_bsd()`: `mode = S_IFSOCK | (SOCK_INODE(sk->sk_socket)->i_mode & ~current_umask())`. + - `freebsd/freebsd-src @ 1fb5d8265bc4bbbf1f5bc79cc1d2a7f280b446b2`, `sys/kern/uipc_usrreq.c`: `uipc_chmod()` stores the mode while the socket is unbound (`unp->unp_mode = mode`); `uipc_bindat()` uses `mode = unp->unp_mode & ~...pd_cmask`. +- macOS is not covered: no SEQPACKET there and no reachable host (SOW-0049). + +Affected contracts and surfaces: + +- POSIX UDS listener creation in C, Rust and Go (listener setup only). +- The public UDS contract (`docs/level1-posix-uds.md`) and integrator guidance (`docs/netipc-integrator-skill.md`). +- Rust and Go listener sockets become 0600 (from `0777 & ~umask`); C keeps 0600. + +Existing patterns to reuse: + +- The C test linker wraps (`tests/fixtures/c/uds_eintr_wrap.c`, `--wrap=send/recv`) for a deterministic hook inside `bind()`. +- Existing C test `test_listen_socket_owner_only`. + +Risk and blast radius: + +- A Rust or Go server whose clients run as a different user, relying on a permissive umask, stops accepting them. +- Netdata check (`netdata/netdata @ ae9113f2cc`): the cgroups-lookup server (cgroups.plugin, in the daemon) runs as `netdata`; its clients apps.plugin and ebpf.plugin run as `netdata` (file capabilities) or root (setuid fallback). The apps-lookup server is apps.plugin (`src/collectors/apps.plugin/apps-lookup-netipc.c`), running as `netdata` (`packaging/cmake/Modules/Packaging.cmake:503`, capabilities) or root (setuid fallback, `netdata-installer.sh:956`); its client network-viewer.plugin is setuid root (`netdata-installer.sh:1079`). Every pair is same-user or root-client, so owner-only sockets work; the C server already creates them. The run dir is `RuntimeDirectoryMode=0775` (`system/systemd/netdata.service.in:15`). +- A platform whose kernel rejects `fchmod` on sockets would fail `listen` with a socket error instead of creating a wider socket; this is the intended fail-closed behavior. + +Performance assessment: + +- Listener setup only (one `fchmod` before `bind`). Nothing on an established session's request path changes; no benchmark run. + +Sensitive data handling plan: + +- Synthetic paths and service names only; kernel sources cited by upstream repo and commit. Covers this SOW, specs, docs, skills and code comments. + +Implementation plan: + +1. C: replace the `umask` block and its mutex with `fchmod(fd, 0600)` before `bind()`. +2. Rust and Go: the same `fchmod` before `bind()`. +3. Tests: C wraps `bind()` and, inside it, creates an unrelated file with mode 0666 and checks it gets the ambient umask (fails on the old code); Rust and Go assert the listener socket is 0600 (fails on the old code under umask 022). +4. Docs: "Socket permissions" in `docs/level1-posix-uds.md`; integrator note. + +Validation plan: + +- Linux: ctest, `cargo test`, `go test`, UDS/service interop; mutants (old C umask code, Rust/Go without `fchmod`) fail the new tests. +- FreeBSD: the AGENTS.md procedure. +- Linux targets (C change in vendored code); Windows unaffected (POSIX-only files), checked by build. + +Artifact impact plan: + +- AGENTS.md: unaffected (no workflow change). +- Runtime project skills: unaffected. +- Specs: `docs/level1-posix-uds.md`. +- End-user/operator docs: same file; `docs/level1-transport.md` run-dir note checked. +- End-user/operator skills: `docs/netipc-integrator-skill.md`. +- SOW lifecycle: this SOW only. + +Open-source reference evidence: + +- Linux and FreeBSD kernel sources above. + +Open decisions: + +- None. The user delegated this SOW's technical choices with a long-term-best requirement; decisions below are recorded under that delegation. + +## Implications And Decisions + +- Decision: track this independently from SOW-0030 because it affects UDS security and + embedding semantics, not the Linux SHM allocation crash. +- Decision (2026-10-31, long-term-best): one contract in all languages. The listener socket is created with mode `0600 & ~umask` by `fchmod()` on the unbound socket before `bind()`. No process-global state is touched and no path-based `chmod` runs. Access control is owner-only plus the caller's private `run_dir`; clients must run as the server's user (or a privileged user). Rejected alternatives: path `chmod` between `bind` and `listen` (follows a swapped path in a writable directory and reintroduces the scanner finding); bind under a temporary name and `rename` (same path operation plus stale-name cleanup); leaving the mode to the directory alone (Rust/Go stay umask-dependent). + +## Plan + +1. Implement the gate's plan in C, Rust and Go with tests. +2. Docs. +3. Validate per the gate. + +## Execution Log + +### 2026-07-12 + +- Created from SOW-0030 scanner triage. No implementation started. + +### 2026-10-31 + +- C `netipc_uds_lifecycle.c`: `bind_owner_only_socket()` now calls `fchmod(fd, 0600)` and `bind()`; the `umask()` calls, the library mutex and the `` include are gone. +- Rust `transport/posix.rs` `UdsListener::bind` and Go `transport/posix/uds_listener.go` `Listen`: `fchmod(fd, 0600)` before `bind()`, failing with a socket error. +- Tests: C `test_listen_keeps_process_umask` (`test_uds.c`) with a `--wrap=bind` probe in `tests/fixtures/c/uds_eintr_wrap.c` that creates an unrelated 0666 file inside `bind()`; Rust `test_listen_socket_owner_only`; Go `TestListenSocketOwnerOnly`. +- Docs: "Socket permissions" in `docs/level1-posix-uds.md`; integrator note in `docs/netipc-integrator-skill.md`. + +## Validation + +Acceptance criteria evidence: + +- One contract in all three languages: listener socket `0600 & ~umask`, set by `fchmod` before `bind` (code above; documented in `docs/level1-posix-uds.md`, "Socket permissions"). The runtime-directory requirement is documented there too. +- No process-global permission window: no `umask()` call remains in the library (`grep -rn umask src/libnetdata src/crates/netipc/src src/go/pkg` finds only comments). The C test creates an unrelated file from inside `bind()`; it keeps the process umask (0644 under umask 022). +- Interop and stale recovery: Linux ctest (UDS/service/cache interop, stale-recovery tests) and FreeBSD ctest pass. + +Tests or equivalent validation: + +- Mutants: C built against the previous `netipc_uds_lifecycle.c` fails "file created during bind keeps the process umask (0644)"; Rust without `fchmod` fails "listener socket must be owner-only" (got 0o775); Go without `Fchmod` fails `TestListenSocketOwnerOnly`. Originals restored and rebuilt. +- Linux: build warning-free; ctest pass except `test_shm_full_backing_store`, which skips without root (unprivileged namespaces blocked on this host) and exercises SHM, which this SOW does not touch. `cargo test` 387 passed; `go test ./...` ok; gofmt clean; `GOOS=freebsd go vet` clean. +- FreeBSD 14.3 test VM: ctest 24/24, `cargo test` 317 passed, `go test ./...` ok, `tests/interop_codec.sh` passed; the new C test passes there. +- Windows: the changed sources are POSIX-only; `cargo clippy --target x86_64-pc-windows-gnu` gate passes. +- Clippy CI gate passes on Linux and the Windows target. +- Netdata Linux targets: all 9 pass (`x86_64-musl`, `i386-gnu`, `i386-musl`, `armv7-gnu`, `armv7-gnu-time64`, `armv7-musl`, `armv6-musl`, `aarch64-gnu`, `aarch64-musl`). + +Real-use evidence: + +- UDS service and cache interop between C, Rust and Go servers and clients on Linux and FreeBSD, through the changed listener code. + +Reviewer findings: + +- No external review run; mutants show each new test fails on the old behavior. + +Same-failure search: + +- Other process-global state changes in the library: `grep -rn "umask\|setlocale\|signal(\|sigaction\|chdir" src/libnetdata/netipc src/crates/netipc/src src/go/pkg` — no other process-global mutation in transport code. +- Other created files: the endpoint lock (`0600`, `O_NOFOLLOW`) and SHM regions (`0600`, `O_EXCL`) already pass explicit modes and need no umask change. + Correction (2026-11-07, SOW-0070): this was wrong. An explicit create mode is still narrowed by the umask, so a umask without owner read or write left lock files and SHM regions the owner could not reopen; SOW-0067 fixed that, and SOW-0070 repairs a mode-000 lock file left by a crash. + +Performance assessment: + +- Listener setup only; nothing on an established session's request path changed. No benchmark run. + +Sensitive data gate: + +- No secrets, customer data, private endpoints or host names in this SOW, docs or code comments; upstream sources cited by repo and commit. + +Artifact maintenance gate: + +- AGENTS.md: no workflow change. +- Runtime project skills: `project-netdata-vendoring` unaffected (vendored file set unchanged). +- Specs: `docs/level1-posix-uds.md` "Socket permissions". No `.agents/sow/specs/` entry: the public spec carries the rule. +- End-user/operator docs: same file; `docs/level1-transport.md` run-dir note is consistent and unchanged. +- End-user/operator skills: `docs/netipc-integrator-skill.md` same-user note. +- SOW lifecycle: this SOW moves to `done/`. + +## Outcome + +C, Rust and Go create the POSIX listener socket owner-only through `fchmod` on the unbound socket. The C library no longer changes the process umask, so other threads of an embedding program keep their file modes, and Rust and Go sockets no longer depend on the ambient umask. + +Correction (2026-11-07, SOW-0070): no implementation changes the umask any more, but on Linux and FreeBSD the socket file still gets `0600 & ~umask`, so a umask that removes owner read or write still narrows it (`docs/level1-posix-uds.md`, "Socket permissions"). An arbitrary umask is therefore not harmless. + +## Lessons Extracted + +- A probe socket path longer than `sun_path` is silently truncated by `snprintf`, so a `stat` of the intended path reports a different file. Keep probe paths short or check the bound name. +- When a kernel source claim decides a design, measure it on each target kernel too: FreeBSD added `fchmod` on unbound sockets (`uipc_chmod`), which older references do not show. + +## Followup + +- macOS: whether its kernel honors `fchmod` on an unbound unix socket is unknown; it is part of the macOS transport design in SOW-0049 (no SEQPACKET there, so the listener code changes anyway). +- Search of this SOW for defer/later/follow-up/future/TODO/pending: the original 2026-07 text described this SOW itself as pending; no other open items. + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0035-20260728-rust-2024-style-migration.md b/.agents/sow/done/SOW-0035-20260728-rust-2024-style-migration.md new file mode 100644 index 00000000..0726749b --- /dev/null +++ b/.agents/sow/done/SOW-0035-20260728-rust-2024-style-migration.md @@ -0,0 +1,214 @@ +# SOW-0035 - Coordinated Rust 2024 Style Migration + +## Status + +Status: completed + +Sub-state: delivered 2026-10-31 in plugin-ipc; the Netdata half is delivered by SOW-0050's vendoring (decision 2). + +## Requirements + +### Purpose + +Migrate plugin-ipc and Netdata's corresponding vendored NetIPC Rust source to +the Rust 2024 style edition without mixing formatting churn into the semantic +edition migration. + +### User Request + +When rustfmt 1.97.1 exposed a repository-wide style migration during SOW-0034, +the user selected a focused semantic migration first and a separately tracked, +coordinated formatting migration afterward. + +### Assistant Understanding + +Facts: + +- Rust language and style editions can be configured independently. +- With language edition 2024 and no style override, rustfmt 1.97.1 proposes 98 + formatting hunks across 40 standalone files. +- Thirty-six matching Netdata vendored source files also fail the same + rustfmt-2024 check. +- SOW-0034 preserves style edition 2021 explicitly to avoid obscuring semantic + review and creating unplanned vendor drift. + +Inferences: + +- A coordinated formatting-only change can be reviewed and validated more + reliably than a mixed semantic-and-formatting diff. +- Netdata vendoring preflight is mandatory because this follow-up intentionally + changes vendored source formatting. + +Unknowns: + +- The source and Netdata baseline commits at the time this SOW is activated. +- Whether later rustfmt releases or intervening source changes alter the exact + formatting diff. + +### Acceptance Criteria + +- Plugin-ipc selects Rust 2024 style explicitly and all Rust targets pass + formatting checks. +- The formatting diff is behavior-neutral and separated from semantic changes. +- Netdata vendoring preflight passes and its vendored NetIPC source uses the + same formatting without overwriting Netdata-only files. +- Plugin-ipc and Netdata validation pass on their supported POSIX and Windows + paths. + +## Analysis + +Sources checked: + +- `src/crates/netipc/Cargo.toml` +- Rustfmt 1.97.1 formatting diagnostics from SOW-0034 +- `.agents/skills/project-netdata-vendoring/SKILL.md` +- Netdata's vendored `src/crates/netipc/` source + +Current state: + +- SOW-0034 is the only executing SOW and explicitly preserves style edition + 2021. +- This formatting migration is intentionally queued until the semantic Rust + 2024 migration completes. + +Risks: + +- A large formatting diff can hide accidental semantic edits. +- Updating plugin-ipc alone would create avoidable downstream vendor drift. +- Netdata CI/toolchain changes between SOWs may change the expected formatting. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- `rustfmt.toml` pins style edition 2021 while the crate uses language edition 2024. The pin was a deliberate stopgap from SOW-0034; this SOW removes it. + +Evidence reviewed: + +- SOW-0034 has completed (`done/`). +- With `style_edition = "2024"`, rustfmt 1.9.0 (stable) reformats 42 of the 61 tracked `.rs` files; rustfmt 1.8.0 (Rust 1.91.0, the MSRV) produces the identical result (`cargo fmt --check` passes with both after the change). +- Netdata (`ktsaou/netdata @ ae9113f2cc`): workspace `edition = "2024"` (`src/crates/Cargo.toml:66`), no `rustfmt.toml` in its tree and no `cargo fmt` CI check, so Netdata's own Rust code formats in style 2024. + +Affected contracts and surfaces: + +- Rust source formatting only; `rustfmt.toml`; contributor notes in AGENTS.md and README.md. No runtime, API or wire change. + +Existing patterns to reuse: + +- CI `cargo fmt --all --check` (`.github/workflows/static-analysis.yml:88`). + +Risk and blast radius: + +- A large mechanical diff could hide a semantic edit; the formatting-only proof below rules that out. + +Performance assessment: + +- Formatting only; no compiled-code change. No benchmark run. + +Sensitive data handling plan: + +- Public source and command results only. Covers this SOW, docs and code comments. + +Implementation plan: + +1. Set `style_edition = "2024"` in `rustfmt.toml`; run `cargo fmt --all`. +2. Prove the diff is formatting-only. +3. Build and test; update AGENTS.md and README.md. + +Validation plan: + +- `cargo fmt --check` with stable and 1.91.0; `rustfmt --edition 2024 --check` over every tracked `.rs` file; formatting-only proof; `cargo test` on Linux; builds for Linux, FreeBSD, the Windows target and MSRV; clippy gates. + +Artifact impact plan: + +- AGENTS.md and README.md style notes; no spec, skill or end-user doc change (no behavior change). + +Open-source reference evidence: + +- Netdata paths above (`ktsaou/netdata @ ae9113f2cc`). + +Open decisions: + +- None (decisions below are assistant-applied under the user's autonomy instruction). + +## Implications And Decisions + +1. **Ordering - selected by the user: semantic migration before formatting.** + - Keep SOW-0034 focused and coordinate style changes in this SOW afterward. +2. **Netdata half moves to SOW-0050 (2026-10-31, assistant-applied, long-term-best).** SOW-0050 vendors every plugin-ipc change since the last baseline with the full preflight; a separate vendoring for formatting alone would repeat that preflight and touch the same files twice. SOW-0050 records the requirement that the vendored Rust copy passes `rustfmt --edition 2024 --check`. + +## Plan + +The gate's implementation plan. + +## Execution Log + +### 2026-07-28 + +- Created as the real tracked follow-up required by the user's SOW-0034 + formatting-scope decision. +- No implementation started. + +### 2026-10-31 + +- `rustfmt.toml`: `style_edition = "2024"`; `cargo fmt --all` reformatted 42 files (219 insertions, 239 deletions). +- AGENTS.md and README.md style notes updated; SOW-0050 records the Netdata half. + +## Validation + +Acceptance criteria evidence: + +- Style 2024 selected explicitly (`rustfmt.toml`); `cargo fmt --all --check` passes with stable (rustfmt 1.9.0) and Rust 1.91.0 (rustfmt 1.8.0); `rustfmt --edition 2024 --check` passes for all 61 tracked `.rs` files, including ones cargo does not reach. +- Formatting-only proof (scripted comparison of each changed file against a pre-change snapshot): + - outside `use` statements, all 42 files are identical after removing whitespace and trailing commas, except 4 files (`transport/posix.rs`, `shm.rs`, `win_shm.rs`, `windows.rs`) whose only difference is a `;` added after a tail `return ...` expression (a `return` diverges, so the semicolon changes nothing); the script checked that every added `;` ends a `return` statement; + - `use` statements import the same multiset of names in every file (only order changed: style 2024 version-sorting). +- Netdata half: delivered by SOW-0050 (decision 2). + +Tests or equivalent validation: + +- Linux `cargo test`: 387 passed. Windows (MSYS2 host) `cargo test`: 266 passed. +- Builds: Rust 1.91.0 `--all-targets` (Linux), FreeBSD `--all-targets`, clippy CI gate on Linux and `x86_64-pc-windows-gnu`: all clean. + +Real-use evidence: + +- No runtime path changed; the test suite and builds above are the evidence. + +Reviewer findings: + +- No external review; the scripted formatting-only proof replaces a manual read of the 42-file diff. + +Same-failure scan: + +- No other style pin: `rustfmt.toml` is the only rustfmt config in the repository (`find . -name rustfmt.toml`). + +Sensitive data gate: + +- Public source paths and command results only. + +Artifact maintenance gate: + +- AGENTS.md: Rust style note updated (style 2024, check with both toolchains). +- Runtime project skills: `project-netdata-vendoring` unchanged; SOW-0050 carries the formatting requirement. +- Specs: no product behavior change. +- End-user/operator docs: README.md "Building And Running" style note updated. +- End-user/operator skills: `docs/netipc-integrator-skill.md` exposes no formatting command; unchanged. +- SOW lifecycle: moved to `done/`; Netdata half tracked in SOW-0050. + +## Outcome + +plugin-ipc formats its Rust code with style edition 2024, matching its language edition and Netdata's workspace. The change is formatting-only, proven per file. + +## Lessons Extracted + +- A whitespace-stripped comparison plus an import-name multiset check is enough to prove a rustfmt edition migration is behavior-neutral; the only other construct style 2024 changes is the tail-`return` semicolon. + +## Followup + +- Netdata vendored copy: SOW-0050. +- Search of this SOW for defer/later/follow-up/future/TODO/pending: only the SOW-0034/SOW-0050 references above and historical text. + +## Regression Log + +None yet. diff --git a/.agents/sow/pending/SOW-0037-20260927-linux-32-bit-build-coverage.md b/.agents/sow/done/SOW-0037-20260927-linux-32-bit-build-coverage.md similarity index 77% rename from .agents/sow/pending/SOW-0037-20260927-linux-32-bit-build-coverage.md rename to .agents/sow/done/SOW-0037-20260927-linux-32-bit-build-coverage.md index 85c9928a..3a5eade1 100644 --- a/.agents/sow/pending/SOW-0037-20260927-linux-32-bit-build-coverage.md +++ b/.agents/sow/done/SOW-0037-20260927-linux-32-bit-build-coverage.md @@ -2,9 +2,9 @@ ## Status -Status: open +Status: closed -Sub-state: separately tracked build gaps discovered during SOW-0036; no implementation started. +Sub-state: merged into SOW-0046 by user decision (2026-10-29). SOW-0046 delivered the Go part and owns all remaining scope; see `.agents/sow/done/SOW-0046-20261029-go-cross-platform-build.md`. Correction (2026-11-07, SOW-0070): the link pointed at `current/`; SOW-0046 has since completed and delivered that remaining scope. ## Requirements @@ -46,7 +46,7 @@ thread closure. SOW-0036 corrects the SHM Timespec construction separately. ## Pre-Implementation Gate -Status: blocked +Status: superseded; merged into SOW-0046, whose gate covers this scope. Problem / root-cause model: architecture-width assumptions and libc-dependent pthread_t types prevent cross-target compilation. @@ -105,15 +105,25 @@ Investigate and complete the pre-implementation gate before starting edits. Recorded reproducible compile failures from SOW-0036. +### 2026-10-29 + +SOW-0046 delivered the Go part of this SOW: Go 386 `Iovlen` width, the +missing `SYS_SENDMSG` (socketcall on linux/386), the oversized test literals, +Go test execution on 386/arm/arm64 in Runtime Safety CI, and a 32-bit SHM +attach overflow fix in C, Rust and Go. Remaining scope here: the Rust ARM musl +`pthread_t` test compile failure, and executing the C and Rust test suites and +interop on 32-bit targets (the C/Rust attach fix from SOW-0046 is verified on +64-bit only, because no local 32-bit C/Rust toolchain exists). + ## Validation -This pending SOW records compiler evidence only; implementation acceptance, -real-use testing, review, same-failure scan and artifact gates remain required -when activated. Status open agrees with pending directory. +Closed without separate implementation; acceptance evidence, reviews, same-failure +scan and artifact gates are recorded in SOW-0046. Status closed agrees with the +done directory. ## Outcome -Tracked; not implemented. +Closed as merged into SOW-0046 (user decision 2026-10-29). No work remains here; acceptance criteria are validated in SOW-0046. ## Lessons Extracted diff --git a/.agents/sow/done/SOW-0040-20261029-robustness-repro-tests.md b/.agents/sow/done/SOW-0040-20261029-robustness-repro-tests.md new file mode 100644 index 00000000..b7cb9b6c --- /dev/null +++ b/.agents/sow/done/SOW-0040-20261029-robustness-repro-tests.md @@ -0,0 +1,251 @@ +# SOW-0040 - Robustness Reproduction Tests + +## Status + +Status: completed + +Sub-state: reproduction tests landed, gated behind `NETIPC_REPRO_KNOWN_BUGS=1`; fixes tracked in SOW-0041 to SOW-0045. + +## Requirements + +### Purpose + +Prove, with executable tests in C, Rust, and Go on POSIX and Windows, which externally reported robustness problems (and sibling problems found by code review) are real, so the user can decide the fix plan from evidence. + +### User Request + +An external reporter (could not open issues on the repository) reported three problems: SHM servers never free sessions after clients leave (Rust/Go on Linux; all languages on Windows for crashed clients), a client that never sends hello blocks the server and its shutdown, and client calls hang past their timeout. The user asked: "add tests to reproduce all problems identified and any sibling issues you may find by code reviewing. Once you have verified the issues with tests, we can discuss what is going to happen next." Windows validation host: `costa-win11` (Windows 11 + MSYS2). + +### Assistant Understanding + +Facts: + +- K1: Rust/Go POSIX SHM server sessions ignore client departure on SHM receive timeout (`src/crates/netipc/src/service/raw/server_session_unix.rs:54`, `src/go/pkg/netipc/service/raw/server_unix.go:208-209`); C checks the session socket (`src/libnetdata/netipc/src/service/netipc_service_posix_server_session.c:121-125`). Reproduced on Linux with ad-hoc programs. +- K2: Windows SHM server sessions detect only the clean-close flag (`netipc_service_win_server_session.c:47-48`, Rust `server_session_windows.rs:49`, Go `server_windows.go:250-251`). +- K3: the server hello read runs inline in the accept loop with no timeout, all languages and transports (`netipc_uds_lifecycle.c:244-248`, `netipc_uds_handshake.c:216`, `netipc_named_pipe.c:845-871`). Reproduced on Linux. +- K4: client connect and hello have no timeout, all languages and transports (`netipc_uds_handshake.c:164`, `netipc_named_pipe.c:585`). Reproduced on Linux with Go. +- N1: the Rust UDS/named-pipe receive caps each wait at 100 ms when an abort flag exists and returns `Timeout` on the first empty slice (`src/crates/netipc/src/transport/posix.rs:932-934`, `:956-961`; `transport/windows.rs:541-542`, `:631-637`). The client always passes an abort flag (`service/raw/client_call.rs:329`, `:349`). +- N2: server stop/destroy joins workers blocked in mid-message reads or response sends without unblocking them (C `netipc_service_posix_server.c:418-426`, Rust `server_unix.rs:115-117`, Go `server_unix.go:168`). +- N3: a stop issued before the server loop starts is lost (C `netipc_service_posix_server.c:188`, Rust `server_unix.rs:29`, Go `server_unix.go:108`, Windows equivalents). +- N5: the Windows SHM client never notices a crashed server (`netipc_win_shm.c:893-906`). +- N11: the auto-detected UDS packet size equals `SO_SNDBUF` (C `netipc_uds.c:24-34`, Rust `transport/posix.rs:792-811`, Go `uds.go:105-112`). On Linux a SOCK_SEQPACKET datagram of exactly `SO_SNDBUF` bytes fails with EMSGSIZE (verified with a socketpair: 212992 fails, 211968 succeeds), so every chunked message (larger than `SO_SNDBUF`) fails with default configuration. +- N8: the overflow-resize retry loop grants a fresh full timeout per retry (`netipc_service_common.c:404-436`, Rust `client_call.rs:34-99`, Go `client.go:171-235`), contradicting `docs/level2-typed-api.md:261-264`. + +Inferences: + +- Tests must assert correct behavior and therefore fail on current code. Assertion bounds are deliberately lenient (seconds, not milliseconds) so they do not pre-decide fix design (for example, a handshake deadline versus a concurrent handshake). + +Unknowns: + +- None blocking test authoring. Fix design is explicitly deferred to the post-verification discussion. + +### Acceptance Criteria + +- Each in-scope issue has at least one test per affected language/platform that fails on current `main` for the documented reason, verified by running it (Linux locally, Windows on `costa-win11`). +- C POSIX behavior that is already correct (K1 C, crash detection on Linux) has passing guard tests. +- No test can hang the suite: every blocking scenario runs under a watchdog and cleans up its own processes and files. +- Results recorded in this SOW with commands and outcomes. + +## Analysis + +Sources checked: + +- `docs/level1-posix-shm.md`, `docs/level1-posix-uds.md`, `docs/level1-windows-shm.md`, `docs/level1-windows-np.md`, `docs/level1-transport.md`, `docs/level2-typed-api.md`. +- C service/transport sources under `src/libnetdata/netipc/src/`, Rust under `src/crates/netipc/src/`, Go under `src/go/pkg/netipc/`. +- Existing tests: `tests/fixtures/c/test_chaos.c`, `src/crates/netipc/src/service/raw_unix_tests.rs`, `raw_windows_tests.rs`, Go `service/raw/*_test.go`, CI `.github/workflows/runtime-safety.yml`. + +Current state: + +- No existing test covers more SHM clients than workers, a crashed client, a silent client concurrent with a legitimate client, a hung-but-listening server, a slow handler beyond 100 ms in Rust, stop-before-run, or a worker blocked during shutdown. + +Risks: + +- Timing-based tests can flake on loaded CI runners; mitigated by generous bounds and watchdogs. +- Tests that fail today will break CI if committed unguarded; landing strategy is a user decision after verification. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- See Facts K1-K4, N1-N3, N5, N8. Root causes: missing peer-liveness checks on SHM wait timeouts; blocking hello I/O without deadlines on the accept thread and client; Rust receive slice treated as the overall deadline; shutdown paths that cannot unblock workers; non-sticky stop state; per-attempt timeouts in retry loops. + +Evidence reviewed: + +- Code references in Facts; ad-hoc Linux reproductions (C/Rust/Go silent client, Rust/Go SHM leak, Go hung-handshake); Windows ad-hoc reproductions on `costa-win11` (in progress at gate time). + +Affected contracts and surfaces: + +- Test sources only: new C fixtures and CMake registrations, new Rust test modules (gated by `#[cfg(test)]` in `service/raw.rs`), new Go test files. No library behavior change. + +Existing patterns to reuse: + +- C: `tests/fixtures/c/test_chaos.c` server thread helpers, `check()` reporting, CMake `add_test` with `TIMEOUT`. +- Rust: `TestServer`, `unique_service`, `shm_server_config`/`shm_client_config` in `raw_unix_tests.rs`; Windows helpers in `raw_windows_tests.rs`. +- Go: `startTestServer`, `testUnixShmServerConfig`, Windows `helpers_windows_test.go`. +- Crash simulation: re-exec of the test binary as a client helper (C `/proc/self/exe` or `CreateProcess`, Go helper-process pattern, Rust `current_exe()` with an env-gated helper test). + +Risk and blast radius: + +- Test-only. Main risk is flakiness or hung test processes; every blocking step uses a watchdog and records its own PIDs. + +Sensitive data handling plan: + +- No secrets or customer data involved. Test auth tokens are the existing constant test token. Durable artifacts cite only repository paths and the host alias `costa-win11` (a private lab VM name, no address). + +Implementation plan: + +1. Linux C fixture `tests/fixtures/c/test_robustness.c` + CMake: K1 guard (C passes), K3 block + shutdown, K4 client hang, N2, N3, N8. +2. Rust `service/raw_robustness_unix_tests.rs`: K1 (clean + crash), K3, K4, N1, N2, N3, N8. +3. Go `service/raw/robustness_unix_test.go`: K1 (clean + crash), K3, K4, N2, N3, N8. +4. Windows equivalents (C fixture, Rust `raw_robustness_windows_tests.rs`, Go `robustness_windows_test.go`): K2 (crash), K3, K4, N1 (Rust), N2, N3, N5. +5. Run all on Linux and `costa-win11`; record outcomes. + +Validation plan: + +- Run each new test on current code and confirm it fails for the stated reason (and guard tests pass). Run existing suites to confirm no interference. + +Artifact impact plan: + +- AGENTS.md: likely unaffected (no workflow change). +- Runtime project skills: likely unaffected. +- Specs: no spec change in this SOW; spec gaps (handshake timeout, Windows crash detection, stop-before-run) are recorded for the fix discussion. +- End-user/operator docs: unaffected (tests only). +- End-user/operator skills: unaffected. +- SOW lifecycle: fix work will be split into follow-up SOWs after the user's decisions. + +Open-source reference evidence: + +- None; only this repository was examined. + +Open decisions: + +- Resolved 2026-10-29; see Implications And Decisions. + +## Implications And Decisions + +1. 2026-10-29: user directed to write reproduction tests for all identified problems and code-review siblings first, then discuss next steps. Assertion bounds are kept lenient so they do not choose a fix design. +2. 2026-10-29, landing strategy: user chose option B - commit the tests now, skipped by default and enabled with `NETIPC_REPRO_KNOWN_BUGS=1`. Implementation detail: only tests reproducing open bugs are gated; guard tests for already-correct behavior (C K1 clean/crash, C and Go N1, N11 64 KiB control) always run. Each fix SOW removes the gate from its tests. +3. 2026-10-29, fix split: user chose option A - separate SOWs: SOW-0041 (N1 Rust timeout slice), SOW-0042 (N11 default packet size), SOW-0043 (handshake/timeout bounds and shutdown: K3, K4, N8, N2, N3 plus code-review siblings N4, N6, N7, N9, N10), SOW-0044 (dead-peer detection: K1, K2, N5). +4. 2026-10-29, Windows pipe DACL: user chose option B - its own security SOW, SOW-0045. + +## Plan + +See Implementation plan. + +## Execution Log + +### 2026-10-29 + +- SOW created; sibling code review produced N1-N10. N4, N6, N7, N9, N10 judged not deterministically testable without test hooks; recorded as code-review findings only. N8 needs a scripted multi-session overflow server; not covered in this pass. +- N11 discovered while writing the N2 test (a 4 MiB send failed with EMSGSIZE at the transport layer). +- Files added: `tests/fixtures/c/test_robustness.c`, `tests/fixtures/c/test_win_robustness.c`, `src/crates/netipc/src/service/raw_robustness_unix_tests.rs`, `src/crates/netipc/src/service/raw_robustness_windows_tests.rs`, `src/go/pkg/netipc/service/raw/robustness_unix_test.go`, `src/go/pkg/netipc/service/raw/robustness_windows_test.go`. +- Files modified: `CMakeLists.txt` (registers `test_robustness`, `test_win_robustness`), `raw_unix_tests.rs` / `raw_windows_tests.rs` (submodule includes). +- After decision 2: added the `NETIPC_REPRO_KNOWN_BUGS` gate to all six test files; wired `test_win_robustness` into `.github/workflows/runtime-safety.yml` (build targets and ctest filter) and `tests/run-windows-msys-validation.sh`. Linux CI has no general CTest job; Rust and Go robustness tests run inside the existing cargo/go test jobs and skip gated cases. +- Updated `AGENTS.md` project commands with the gate convention. +- Created pending SOW-0041 to SOW-0045. +- Windows binaries built with MSYS gcc must exit via `_exit`, not `ExitProcess`, or the MSYS runtime reports status 0. + +## Validation + +Acceptance criteria evidence (2026-10-29, current `main` 63919ec plus test-only changes). FAIL = bug reproduced; PASS on a guard = behavior already correct. + +| ID | Issue | C Linux | Rust Linux | Go Linux | C Win | Rust Win | Go Win | +|---|---|---|---|---|---|---|---| +| K1 | SHM session leak, clean close | PASS (guard) | FAIL | FAIL | n/a | n/a | n/a | +| K1/K2 | SHM session leak, client crash | PASS (guard) | FAIL | FAIL | FAIL | FAIL | FAIL | +| K3 | silent client blocks others | FAIL | FAIL | FAIL | FAIL | FAIL | FAIL | +| K3 | silent client blocks shutdown | FAIL | FAIL | FAIL | FAIL | FAIL | FAIL | +| K4 | client hang, server never accepts | FAIL | FAIL | FAIL | n/a | n/a | n/a | +| K4 | client hang, no HELLO_ACK | FAIL | FAIL | FAIL | FAIL | FAIL | FAIL | +| N1 | timeout > 100 ms honored | PASS (guard) | FAIL (101 ms) | PASS (guard) | n/a | FAIL (97 ms) | PASS (guard) | +| N2 | shutdown blocked by non-reading client | FAIL | FAIL | FAIL | FAIL | FAIL | FAIL | +| N3 | stop before run lost | FAIL | FAIL | FAIL | FAIL | FAIL | FAIL | +| N5 | SHM client waits full timeout after server crash | FAIL (10 s) | FAIL (10 s) | FAIL (10 s) | FAIL (10 s) | FAIL (10 s) | FAIL (10 s) | +| N11 | default packet size, 512 KiB message | FAIL | FAIL | FAIL | n/a | n/a | n/a | +| N11 | control, 64 KiB packet size | PASS | PASS | PASS | n/a | n/a | n/a | + +Tests or equivalent validation: + +- Linux: `tests/run-low-priority.sh cargo test --manifest-path src/crates/netipc/Cargo.toml --lib robustness -- --test-threads=1`; `go test ./pkg/netipc/service/raw/ -run TestRobust -count=1 -v` (from `src/go`); C `test_robustness` built in a scratch CMake build dir. +- Windows (`costa-win11`, MSYS2): CI-equivalent CMake configure, `ctest -R test_win_robustness`; `cargo test --lib robustness -- --test-threads=1`; `go test ./pkg/netipc/service/raw/ -run TestWinRobust -count=1 -v`. +- Every failure message was checked to name the intended reason (no setup failures). `cargo fmt --check` and `gofmt -l` clean. No helper processes left on either host. + +Gate behavior: + +- Default (gate off): C Linux `7 passed, 0 failed` with 8 SKIP lines; Rust Linux 13 ok; Go Linux 3 pass + 10 skip; Windows ctest `test_win_robustness` passes, Rust Windows 9 ok, Go Windows 0 failures. +- `NETIPC_REPRO_KNOWN_BUGS=1`: C Linux `13 passed, 8 failed`; Windows ctest fails, Rust Windows 8 failed, Go Windows 7 failed - matching the table above. + +Real-use evidence: + +- Ad-hoc reproductions before the tests: Linux C/Rust/Go interop binaries with a silent Python client; Rust/Go SHM leak programs; Go hung-handshake client; Windows repro programs in `~/src/plugin-ipc-verify-20261029` on `costa-win11` (session leak, silent client, shutdown, fake server, pipe DACL). + +Reviewer findings: + +- No external reviewers were run (not requested). Independent read-only sub-reviews produced the K1-K5 verification and N1-N10 sibling findings; every finding used here was confirmed by a failing test or, for untested ones, by code citation only (recorded as such in SOW-0043). + +Same-failure scan: + +- Sibling review covered all unbounded blocking I/O, peer-death detection, shutdown, and retry-accounting paths across C, Rust and Go on both platforms (N1-N10). N11 found during test authoring; the same derivation exists in all three languages. + +Sensitive data gate: + +- No secrets, credentials, customer data or personal data in durable artifacts. The Windows DACL owner SID is replaced with ``. `costa-win11` is a private lab host alias with no address. + +Artifact maintenance gate: + +- AGENTS.md: updated (project commands: `NETIPC_REPRO_KNOWN_BUGS` gate convention). +- Runtime project skills: no update; `project-netdata-vendoring` is unaffected because no library code changed. +- Specs: no update; this SOW changes tests only. Spec gaps (handshake timeouts, total call deadline, Windows crash detection, packet-size derivation, stop semantics, pipe security) are tracked in SOW-0042 to SOW-0045. +- End-user/operator docs: no update; no public API, behavior, or command changed. +- End-user/operator skills: no update; `docs/netipc-integrator-skill.md` describes public APIs and workflows, which are unchanged. +- SOW lifecycle: status `completed`, moved to `done/` in the same commit as the tests; follow-ups SOW-0041 to SOW-0045 created in `pending/`. + +Specs update: + +- None needed for test-only changes; spec work tracked in follow-up SOWs. + +Project skills update: + +- None needed; no new reusable workflow beyond the gate convention recorded in AGENTS.md. + +End-user/operator docs update: + +- None; tests and CI wiring only. + +End-user/operator skills update: + +- None; public surfaces unchanged. + +Lessons: + +- See Lessons Extracted. + +Follow-up mapping: + +- K3, K4, N2, N3, N8, N4, N6, N7, N9, N10, idle-client and over-limit-ack questions: SOW-0043. +- K1, K2, N5, misleading SHM error codes: SOW-0044. +- N1: SOW-0041. N11: SOW-0042. Windows pipe DACL: SOW-0045. +- N8 test not written here: tracked in SOW-0043 acceptance criteria. + +## Outcome + +Executable reproductions for every reported problem and for the testable code-review siblings now live in the C, Rust and Go suites on Linux and Windows, gated behind `NETIPC_REPRO_KNOWN_BUGS=1`, with always-on guards for behavior that is already correct. + +Correction (2026-11-07, SOW-0070): the exception is N8 (overflow-resize retry budget), which has no reproduction here; its test was tracked in SOW-0043's acceptance criteria (see Followup). + +## Lessons Extracted + +- Existing suites missed these bugs because SHM tests never exceeded the worker count, timeout tests used sub-100 ms timeouts or aborts, and no test held a silent or hung peer while a second client connected. +- Tests for blocking bugs must run every blocking step under a watchdog and release the blocker before joining, or a regression hangs the suite. +- Windows test binaries built with MSYS gcc must exit with `_exit`; `ExitProcess` reports status 0 to MSYS/CTest. +- Auto-detecting a SOCK_SEQPACKET packet size from `SO_SNDBUF` is not safe on Linux; the maximum datagram is smaller than the buffer. + +## Followup + +- SOW-0041, SOW-0042, SOW-0043, SOW-0044, SOW-0045 (all in `pending/`). + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0041-20261029-rust-receive-timeout-slice.md b/.agents/sow/done/SOW-0041-20261029-rust-receive-timeout-slice.md new file mode 100644 index 00000000..b339fee4 --- /dev/null +++ b/.agents/sow/done/SOW-0041-20261029-rust-receive-timeout-slice.md @@ -0,0 +1,221 @@ +# SOW-0041 - Rust Receive Timeout Slice + +## Status + +Status: completed + +Sub-state: N1 fixed in Rust UDS and named-pipe receive; validated on Linux and `costa-win11`; known-bug gate removed from all robustness tests. + +## Requirements + +### Purpose + +Rust UDS and named-pipe receive must honor the configured call timeout instead of failing after one 100 ms abort-poll slice. + +### User Request + +From SOW-0040 decisions (2026-10-29): split the fix work into separate SOWs (N1 Rust timeout; N11 default packet size; handshake/timeout bounds and shutdown; dead-peer detection) and track the Windows pipe DACL exposure as its own security SOW. + +2026-10-29, after SOW-0040 landed: "remove NETIPC_REPRO_KNOWN_BUGS - there are no known bugs. All bugs must be fixed now. Zero known bug policy. We created the tests first to prove that they exist and are true bugs. Not to document them, but to fix them." Then: "I want one issue at a time, with proper explanation, risks, proposed solutions, risk of the solutions recommended and explicit recommendations with reasoning." + +### Assistant Understanding + +Facts: + +- Rust caps each wait at 100 ms when an abort flag exists and returns `Timeout` on the first empty slice: `src/crates/netipc/src/transport/posix.rs:932-934`, `:956-961`; `src/crates/netipc/src/transport/windows.rs:541-542`, `:631-637`. +- The client always passes an abort flag (`src/crates/netipc/src/service/raw/client_call.rs:329`, `:349`), so every Rust UDS/pipe call with a server slower than about 100 ms fails with a terminal `Timeout`, including the default 30000 ms. +- C (`netipc_uds_receive.c:48-86`, `netipc_named_pipe.c:379-442`) and Go (`uds_receive.go:103-104`, `pipe_receive.go:118-120`) re-wait until the real deadline. +- Reproduced (SOW-0040): Rust Linux failed after 101 ms, Rust Windows after 97 ms, with a 300 ms handler and a 2000 ms timeout. + +Inferences: + +- Downstream Netdata consumers are likely affected; not yet verified against Netdata usage. + +- Analysis 2026-10-29: with `timeout_ms == 0` ("wait indefinitely", `posix.rs:418`) plus an abort flag, Rust UDS also returned `Timeout` after 100 ms. Rust SHM receive (`client_call.rs:453`, `:484`) and the Rust server session loop already treat an empty slice as "wait again"; send paths have no slice logic; handshake reads have no timeout at all (SOW-0043). + +Unknowns: + +- None after the user decision below. + +### Acceptance Criteria + +- `test_robust_uds_call_honors_timeout_longer_than_100ms` (Rust Linux) and `test_robust_win_pipe_call_honors_timeout_longer_than_100ms` (Rust Windows) pass with no skip gate. +- Existing timeout/abort tests still pass; abort still interrupts promptly. +- Cross-language slow-handler coverage considered (sibling review suggested a 300 ms handler across all client/server pairs). + +## Analysis + +Sources checked: + +- SOW-0040 evidence and tests. +- Rust `src/crates/netipc/src/transport/posix.rs` (`ReceiveWait`, `poll_timeout_ms`, `wait_fd_readable`), `transport/windows.rs` (`wait_slice_ms`, `raw_recv_with_wait`, `wait_pipe_readable`), `service/raw/client_call.rs`. +- C `netipc_uds_receive.c:10-87`, `netipc_named_pipe.c:359-442`; Go `transport/posix/uds_receive.go:47-105`, `transport/windows/pipe_receive.go:74-125`. +- Specs `docs/level1-posix-uds.md:101-110`, `docs/level1-windows-np.md:98-106`, `docs/level2-typed-api.md:261-272`. + +Current state (before the fix): + +- Rust returned `Timeout` on the first empty 100 ms abort-poll slice in both receive functions; C and Go loop until the real deadline. + +Risks: + +- Calls that used to fail fast by accident now wait their real timeout against a hung server (correct, but a behavior change). +- Abort latency stays up to 100 ms, as in Go (spec drift tracked in SOW-0047). + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- `wait_fd_readable` (posix.rs) and `raw_recv_with_wait` (windows.rs) treat the end of one abort-poll slice as the end of the call. The slice exists only so the abort flag is checked about every 100 ms; the deadline is already enforced by `poll_timeout_ms()` / `wait_slice_ms()`, which return `Timeout` once it has passed. + +Evidence reviewed: + +- Code and specs listed under Analysis; SOW-0040 reproduction results (Rust Linux failed after 101 ms, Rust Windows after 97 ms, with a 300 ms handler and a 2000 ms timeout). + +Affected contracts and surfaces: + +- Rust transport receive behavior only (`UdsSession::receive_with_control`, `NpSession::receive_with_control`); no public API, wire format, or spec change. Its only callers are `client_call.rs:329`, `:349`. +- Test fixtures: interop service servers in C, Rust and Go on POSIX and Windows gain an optional `NIPC_HANDLER_DELAY_MS` server delay; the two service interop scripts run all 9 pairs a second time with a 300 ms handler. +- Robustness tests in all six files: the `NETIPC_REPRO_KNOWN_BUGS` gate is removed (user decision), so the tests for bugs owned by SOW-0042 to SOW-0045 now run and fail until those SOWs land. + +Existing patterns to reuse: + +- Go `waitMs()` / `pollReadableForReceive` loop and Rust SHM receive slice loop (`client_call.rs:420-495`, `.as_millis().max(1)`). + +Risk and blast radius: + +- Small: two `return` statements replaced by `continue`, plus a 1 ms minimum wait so the final millisecond does not busy-spin on `poll(0)`. + +Sensitive data handling plan: + +- No secrets or customer data involved; evidence is code paths, test names, and timings. Windows SIDs are never recorded. + +Implementation plan: + +1. Remove the known-bug gate helpers and call sites from the six robustness test files. +2. posix.rs: empty poll slice -> `continue`; remaining wait clamped to at least 1 ms. +3. windows.rs: empty slice -> `continue`; remaining wait at least 1 ms. +4. Add Rust transport tests: delayed response with `timeout_ms` 0 and 2000 plus abort flag; abort after 300 ms reports `Aborted`. +5. Add `NIPC_HANDLER_DELAY_MS` to the six interop service fixtures and a 300 ms pass to `tests/test_service_interop.sh` and `tests/test_service_win_interop.sh`. +6. Record the zero-known-bug policy in `AGENTS.md`; update pending SOWs that referenced the gate. + +Validation plan: + +- New tests fail without the fix and pass with it; N1 reproduction tests pass on Linux and `costa-win11`; existing timeout/abort tests pass; service interop (baseline and SHM) passes on both platforms; rustfmt, clippy correctness/suspicious gates, gofmt, go vet clean. + +Artifact impact plan: + +- AGENTS.md: update (zero-known-bug policy; robustness test command note). +- Runtime project skills: none affected (vendoring skill unchanged). +- Specs: none; the fix restores documented behavior. +- End-user/operator docs: none; no documented API or behavior changes. +- End-user/operator skills: none. +- SOW lifecycle: this SOW completes; SOW-0042 to SOW-0045 updated for gate removal and new findings; SOW-0046 and SOW-0047 created. + +Open-source reference evidence: + +- None. + +Open decisions: + +- None; decision 1 below recorded before implementation. + +## Implications And Decisions + +1. 2026-10-29, gate removal: the user ordered removing `NETIPC_REPRO_KNOWN_BUGS` under a zero-known-bug policy; all robustness tests run ungated from this SOW's commit, and the branch merges only when every reproduction test passes. +2. 2026-10-29, N1 fix: user chose option 1A - mirror Go's receive loop (an empty abort-poll slice means "wait again", `Timeout` only at the real deadline, at least 1 ms per wait). Rejected 1B (shared deadline helper across UDS, pipe and SHM: refactors working SHM code without fixing more). Option C (real abort fd/event) moved to SOW-0047. + +## Plan + +Executed as the implementation plan in the Pre-Implementation Gate. + +## Execution Log + +### 2026-10-29 + +- Read-only analysis round for all open robustness bugs (six parallel analyses). New findings were recorded in SOW-0042 to SOW-0045, and in new SOW-0046 (Go linux/arm64 build) and SOW-0047 (abort spec drift). +- Removed the gate from `raw_robustness_unix_tests.rs`, `raw_robustness_windows_tests.rs`, `robustness_unix_test.go`, `robustness_windows_test.go`, `test_robustness.c`, `test_win_robustness.c`. +- Applied the fix in `transport/posix.rs` and `transport/windows.rs`. +- Added `test_receive_with_abort_waits_past_poll_slice` and `test_receive_abort_after_poll_slice_reports_aborted` to `transport/posix_tests.rs`. +- Added the slow-handler interop pass (fixtures and both scripts). + +## Validation + +Acceptance criteria evidence: + +- Linux: `test_robust_uds_call_honors_timeout_longer_than_100ms` ok. +- Windows (`costa-win11`): `test_robust_win_pipe_call_honors_timeout_longer_than_100ms` ok. +- Abort still interrupts promptly: `test_receive_abort_after_poll_slice_reports_aborted` returns `Aborted` between 250 ms and 2000 ms after a 300 ms abort; existing `test_client_abort_unblocks_call` (Windows) and the lookup abort tests pass. +- Cross-language slow handler: added and passing (below). + +Tests or equivalent validation: + +- Fix reverted temporarily (reverse patch, then reapplied): both new transport tests FAILED; the slow-handler interop pass failed exactly the three Rust-client pairs (`Rust/Rust`, `C/Rust`, `Go/Rust`) with `cgroups call failed: Timeout`. With the fix: all pass. +- Linux `cargo test --lib`: 385 passed, 10 failed. The 10 failures are the ungated reproduction tests owned by open SOWs: K1 x2 and N5 (SOW-0044), K3 x2, K4 x2, N2, N3 (SOW-0043), N11 (SOW-0042). No other failures. +- Windows `cargo test --lib`: 266 passed, 7 failed, all owned by open SOWs: K2, N5 (SOW-0044), K3 x2, K4, N2, N3 (SOW-0043). +- C Linux `test_robustness`: 13 passed, 8 failed; the 8 failures are K3 x2, K4 x2, N2, N3, N5, N11 (open SOWs). The C N1 guard ("300ms handler succeeds with a 2000ms timeout") passes. +- Linux ctest `test_service_interop` and `test_service_shm_interop`: passed (18 pairs each, including 9 with a 300 ms handler). +- Windows ctest `test_service_win_interop` and `test_service_win_shm_interop`: passed. +- `cargo fmt --check` clean; CI clippy gates (`-D clippy::correctness -D clippy::suspicious`, all targets) pass; the only clippy warnings in touched files are pre-existing (`interop_service.rs` `type_complexity`, `needless_update`). `gofmt -l` clean; `go vet` clean for Go fixtures (unix and `GOOS=windows`) and `service/raw` (linux and windows). + +Real-use evidence: + +- The interop binaries are real client/server processes exchanging the cgroups snapshot service across C, Rust and Go on Linux and Windows with a slow handler. + +Reviewer findings: + +- No external reviewers were requested. Six read-only analyses informed the design; their findings beyond N1 are mapped under Followup. + +Same-failure search: + +- Every Rust path using a sliced wait was checked: UDS and named-pipe receive were the only ones returning on an empty slice. Rust SHM receive and the server session loop loop correctly; sends have no slicing; handshakes have no timeout (SOW-0043). C and Go loop correctly. + +Artifact maintenance gate: + +- AGENTS.md: updated - zero-known-bug policy under project overrides; robustness test command note no longer describes a gate. +- Runtime project skills: no update; `project-netdata-vendoring` is unaffected by a receive-loop fix. +- Specs: no update; the fix makes Rust conform to `docs/level1-posix-uds.md:101-110`, `docs/level1-windows-np.md:98-106`, `docs/level2-typed-api.md:261-272`. The abort-mechanism drift is tracked in SOW-0047. +- End-user/operator docs: no update; no public API, config, or documented behavior changed. +- End-user/operator skills: no update; `docs/netipc-integrator-skill.md` describes call timeouts the fix now honors. +- SOW lifecycle: completed and moved to `done/` in the same commit as the work. + +Spec update: + +- None needed (see artifact gate). + +Project skill update: + +- None needed (see artifact gate). + +End-user/operator docs update: + +- None needed (see artifact gate). + +End-user/operator skill update: + +- None needed (see artifact gate). + +Lessons: + +- A reproduction test that "always runs" only in some languages hides that the same bug class may exist elsewhere: the cross-language slow-handler interop pass now covers every client/server pair, not just the language that failed. +- Read-only design analysis across all open bugs before the first fix surfaced several unrelated serious bugs (Go arm64 build, missing `CLOEXEC`, pipe squatting) that per-issue analysis would have found much later. + +## Outcome + +Rust clients over UDS and named pipes now honor their call timeout like C and Go. Calls with handlers slower than 100 ms succeed; an abort still ends the call within about 100 ms. All robustness reproduction tests run ungated under the zero-known-bug policy. + +## Lessons Extracted + +- See Validation -> Lessons. + +## Followup + +- Abort-signal spec drift (Rust/Go poll a flag; spec describes an fd/event): SOW-0047. +- Go linux/arm64 build failure (`syscall.SYS_POLL`): SOW-0046. +- Remaining robustness bugs and the new sibling findings: SOW-0042 (packet size, agreed-size validation, explicit-size cap), SOW-0043 (handshake, timeouts, shutdown, lifecycle races, C retry parity, NotReady parity), SOW-0044 (dead-peer detection, `CLOEXEC`, `nipc_shm_owner_alive`, error codes), SOW-0045 (pipe and SHM object security, squatting, automated test). +- Triage of older pending SOWs (0031, 0032, 0037) under the zero-known-bug policy: pending user decision in the one-issue-at-a-time sequence; no work started. + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0042-20261029-uds-default-packet-size.md b/.agents/sow/done/SOW-0042-20261029-uds-default-packet-size.md new file mode 100644 index 00000000..863231fe --- /dev/null +++ b/.agents/sow/done/SOW-0042-20261029-uds-default-packet-size.md @@ -0,0 +1,213 @@ +# SOW-0042 - UDS Default Packet Size + +## Status + +Status: completed + +Sub-state: packet size capped at the kernel datagram limit in C, Rust and Go; client validates the agreed size; specs updated. + +## Requirements + +### Purpose + +Messages larger than one packet must be deliverable over UDS with the default (auto-detected) packet size on Linux. + +### User Request + +From SOW-0040 decisions (2026-10-29): split the fix work into separate SOWs (N1 Rust timeout; N11 default packet size; handshake/timeout bounds and shutdown; dead-peer detection) and track the Windows pipe DACL exposure as its own security SOW. + +### Assistant Understanding + +Facts: + +- C (`src/libnetdata/netipc/src/transport/posix/netipc_uds.c:24-34`), Rust (`src/crates/netipc/src/transport/posix.rs:792-811`) and Go (`src/go/pkg/netipc/transport/posix/uds.go:105-112`) set the packet size to `SO_SNDBUF`. +- On Linux a SOCK_SEQPACKET datagram of exactly `SO_SNDBUF` bytes fails with EMSGSIZE (socketpair check: 212992 fails, 211968 succeeds), so every chunked message fails with the default configuration. +- Reproduced (SOW-0040) in C, Rust and Go with a 512 KiB message; the same message with an explicit 64 KiB packet size passes. +- Spec: `docs/level1-posix-uds.md:73-86` defines `agreed_packet_size` chunking but not how the local packet size is derived. +- Analysis 2026-10-29 (measured, Linux 7.0 SEQPACKET socketpair): the largest sendable datagram is `getsockopt(SO_SNDBUF) - 32` (default 212992 -> 212960 works, 212992 fails); with a 1 MiB buffer sends fail with ENOBUFS above about 1.07 MiB. The peer's `SO_RCVBUF` does not matter. +- Sibling bug: clients accept any agreed packet size from HELLO_ACK, even above their own proposal (C `netipc_uds_handshake.c:102`, Go `uds_handshake.go:75`, Rust `posix.rs:1225` checks only the lower bound). +- Sibling bug: an explicitly configured `packet_size` is not capped by the kernel limit, so values above `SO_SNDBUF - 32` fail the same way. + +Inferences: + +- Downstream Netdata consumers are likely affected; not yet verified against Netdata usage. + +Unknowns: + +- Exact kernel source rule for the 32-byte overhead (measured, not read from kernel source); macOS/FreeBSD datagram limits (not measurable here, and the Go transport does not build there - SOW-0046). + +### Acceptance Criteria + +- `test_robust_default_packet_size_carries_large_message` (Rust), `TestRobustDefaultPacketSizeCarriesLargeMessage` (Go) and the C `test_robustness` N11 check pass. +- Packet-size derivation documented in `docs/level1-posix-uds.md`; interop across languages unchanged or migrated consistently. +- Fix design decided by the user: option 3A keeps single-packet behavior for messages up to the Linux default limit, so no throughput change and no benchmark gate. +- Configured sizes above the kernel limit are clamped; clients reject an agreed size above their proposal. + +## Analysis + +Sources checked: + +- SOW-0040 evidence and tests; analysis-round measurements (SEQPACKET socketpair, several `SO_SNDBUF` values). +- C `netipc_uds.c`, `netipc_uds_internal.h`, `netipc_uds_handshake.c`, `netipc_uds.h`; Rust `transport/posix.rs`; Go `transport/posix/uds.go`, `uds_handshake.go`, `uds_session.go`, `transport/internal/framing/handshake.go`. +- Specs `docs/level1-posix-uds.md` (Packet size, Chunking), `docs/level1-transport.md` (packet size negotiation), `docs/netipc-integrator-skill.md`. +- Benchmarks: `bench/drivers/c/bench_posix.c:427` (largest POSIX benchmark message 52096 bytes) - no benchmark chunks today. + +Current state (before the fix): + +- Proposal = full `SO_SNDBUF`; any chunked message failed with EMSGSIZE; explicit sizes unchecked; Go clients did not even check the lower bound of the agreed size. + +Risks: + +- Relies on the measured Linux `SO_SNDBUF - 32` rule; the always-running N11 reproduction test and the new kernel-limit test guard it. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- Each side proposed its full `SO_SNDBUF` as packet size; Linux accepts at most `SO_SNDBUF - 32` bytes per datagram, so the first full-size chunk of every chunked message failed. Explicit sizes and peer-announced sizes were never checked against the kernel limit. + +Evidence reviewed: + +- Code, specs and measurements listed under Analysis. + +Affected contracts and surfaces: + +- POSIX UDS handshake proposal (C, Rust, Go) and client-side HELLO_ACK validation; public config field comments (`netipc_uds.h`, Rust `ClientConfig`/`ServerConfig`, Go `ClientConfig`/`ServerConfig`). No wire-format change; mixed versions stay compatible because the server picks the minimum. +- Windows named pipes are not touched: they default to 64 KiB packets (`netipc_named_pipe.h:96`) and message-mode pipes have no per-datagram kernel limit comparable to SEQPACKET (reasoned, not measured). + +Existing patterns to reuse: + +- Existing detection helpers and their 65536 fallback; existing client lower-bound check in C and Rust; existing malformed-HELLO_ACK test harnesses in all three languages; `large_message_round_trip` in `test_robustness.c`. + +Risk and blast radius: + +- Default systems: packet size drops by 32 bytes (212992 -> 212960); messages up to that size still travel in one packet. Systems with raised buffers: capped at 256 KiB instead of failing near 1 MiB. +- A client now refuses a server that announces a larger size than proposed; compliant servers never do (they take the minimum). + +Sensitive data handling plan: + +- No secrets or customer data involved; evidence is code paths, sizes and test output. + +Implementation plan: + +1. Replace the SO_SNDBUF detector with a limit helper in each language: `min(SO_SNDBUF - 32, 262144)`, fallback 65536, configured value used only when non-zero and below the limit. +2. Use the helper for client and server proposals. +3. Clients reject `agreed_packet_size` above their proposal or not above 32 (Go gains both checks). +4. Update config comments and specs. +5. Tests: kernel-limit test (exact limit sends, +1 fails while kernel-limited) in Rust and Go; clamp tests; agreed-size rejection in C, Rust and Go; 512 KiB round trip with an oversized 4 MiB configured size in C. + +Validation plan: + +- New tests fail without the fix (C) and pass with it; N11 reproduction tests pass in C, Rust and Go; full CTest, Rust and Go suites show no failures other than reproduction tests owned by open SOWs; UDS and service interop pass; fmt, clippy and vet clean. + +Artifact impact plan: + +- AGENTS.md: no update; no workflow change. +- Runtime project skills: no update. +- Specs: update `docs/level1-posix-uds.md` and `docs/level1-transport.md`. +- End-user/operator docs: config field comments in public headers/types updated; no other doc describes packet size derivation. +- End-user/operator skills: `docs/netipc-integrator-skill.md` states raw packet size is not exposed; no update. +- SOW lifecycle: completes; SOW-0046 widened with the Go cross-platform build failures found during validation. + +Open-source reference evidence: + +- None. + +Open decisions: + +- None; decision recorded below. + +## Implications And Decisions + +1. 2026-10-29, Issue 3: user chose option 3A - propose `min(SO_SNDBUF - 32, 256 KiB)`, clamp configured sizes to the same limit, and make clients reject an agreed size larger than their proposal. Rejected 3B (fixed 64 KiB: chunks 64-208 KiB messages and needs benchmark evidence), 3C (raise `SO_SNDBUF`: does not fix the off-by-32, limited by `wmem_max`), 3D (re-split on EMSGSIZE: breaks receivers that expect exact chunk sizes). + +## Plan + +Executed as the implementation plan in the Pre-Implementation Gate. + +## Execution Log + +### 2026-10-29 + +- C: `nipc_uds_packet_size()` replaces `nipc_uds_detect_packet_size()`; constants in `netipc_uds_internal.h`; client agreed-size check in `netipc_uds_handshake.c`. +- Rust: `packet_size_limit()` replaces `detect_packet_size()`; `PACKET_SIZE_MAX`, `SNDBUF_OVERHEAD`; client check in `connect_and_handshake`. +- Go: `packetSizeLimit()` replaces `detectPacketSize()`; client range check in `connectAndHandshake`. +- Tests added in `transport/posix_tests.rs`, `transport/posix/uds_more_edge_test.go`, `tests/fixtures/c/test_uds.c`, `tests/fixtures/c/test_robustness.c`; the old detector tests were replaced. +- Cross-building Go during validation exposed that the Go POSIX transport fails to build on linux/386, arm, arm64, riscv64, freebsd and darwin; recorded in SOW-0046. + +## Validation + +Acceptance criteria evidence: + +- C `test_robustness` N11: "512 KiB message delivered with auto-detected packet size" PASS; new "oversized 4 MiB packet size (clamped)" PASS. +- Rust `test_robust_default_packet_size_carries_large_message` ok; Go `TestRobustDefaultPacketSizeCarriesLargeMessage` passes (absent from the failure list). + +Tests or equivalent validation: + +- Library fix reverted (reverse patch, then reapplied): C "HELLO_ACK packet size above the client proposal rejected" FAIL, auto-detected 512 KiB FAIL, oversized 4 MiB FAIL. With the fix all pass. (Rust and Go new tests call the new helpers and cannot compile against the old code; their N11 reproduction tests failed before this SOW.) +- Rust `test_packet_size_limit_matches_kernel_datagram_limit`: for default, 4096 and 65536 `SO_SNDBUF`, the limit sends and limit + 1 fails. Go `TestPacketSizeLimitMatchesKernelDatagramLimit`: same. +- Full CTest: 48/51 pass; failures are `test_robustness`, `test_service_rust`, `test_shm_rust`, all from reproduction tests owned by open SOWs (`test_shm_rust` runs `cargo test shm`, which matches the ungated K1/N5 SHM reproductions and exceeds its 30 s CTest timeout). +- Rust `cargo test --lib`: 388 passed, 9 failed (K1 x2, N5, K3 x2, K4 x2, N2, N3 - open SOWs). +- Go `go test ./...`: all packages ok except `service/raw` with 9 failures (K1 x2, N5, K3 x2, K4 x2, N2, N3 - open SOWs). +- UDS and service interop (`test_uds_interop`, `test_service_interop`, `test_service_shm_interop`, codec/cache interop) pass in the CTest run. +- `cargo fmt --check` clean; CI clippy gates pass; `gofmt -l` clean; `GOOS=windows go vet ./...` reports only pre-existing `unsafe.Pointer` warnings in untouched `transport/windows/shm.go`. + +Real-use evidence: + +- Interop binaries (real client/server processes, C/Rust/Go pairs) pass over UDS with the new proposals. + +Reviewer findings: + +- None requested. + +Same-failure search: + +- All three languages derived the proposal the same way and all three are fixed. Windows pipes use a fixed 64 KiB default and are not affected by the SEQPACKET limit. + +Artifact maintenance gate: + +- AGENTS.md: no update; no workflow change. +- Runtime project skills: no update; vendoring workflow unaffected. +- Specs: updated `docs/level1-posix-uds.md` (Packet size) and `docs/level1-transport.md` (packet size negotiation). +- End-user/operator docs: public config comments updated in `netipc_uds.h`, Rust `transport/posix.rs`, Go `uds_session.go`; no other doc describes packet-size derivation. +- End-user/operator skills: no update; `docs/netipc-integrator-skill.md` states raw packet size is not exposed. +- SOW lifecycle: completed and moved to `done/` with the work. + +Spec update: + +- Done (see artifact gate). + +Project skill update: + +- None needed (see artifact gate). + +End-user/operator docs update: + +- Config comments only (see artifact gate). + +End-user/operator skill update: + +- None needed (see artifact gate). + +Lessons: + +- Cross-compiling every claimed platform is cheap and found a whole class of build failures nobody had seen; it belongs in CI (SOW-0046). + +## Outcome + +POSIX UDS messages of any size now work with the default configuration in C, Rust and Go. Proposals never exceed what the kernel accepts, configured sizes are clamped, and clients refuse an agreed size larger than they proposed. + +## Lessons Extracted + +- See Validation -> Lessons. + +## Followup + +- Go POSIX transport build failures on linux/386, arm, arm64, riscv64, freebsd, darwin: SOW-0046. +- macOS/FreeBSD datagram limits unverified: SOW-0046 (cannot be checked until those builds work). + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0043-20261029-handshake-timeouts-and-shutdown.md b/.agents/sow/done/SOW-0043-20261029-handshake-timeouts-and-shutdown.md new file mode 100644 index 00000000..38c429b6 --- /dev/null +++ b/.agents/sow/done/SOW-0043-20261029-handshake-timeouts-and-shutdown.md @@ -0,0 +1,227 @@ +# SOW-0043 - Server Handshake Bounds And Shutdown + +## Status + +Status: completed + +Sub-state: completed 2026-10-31; K4 client deadlines continue in SOW-0051. + +## Requirements + +### Purpose + +Keep one slow or silent client from blocking other clients or shutdown, bound the server side of every handshake, and make server stop and shutdown reliable in C, Rust and Go on POSIX and Windows. + +### User Request + +From SOW-0040 decisions (2026-10-29): split the fix work into separate SOWs (N1 Rust timeout; N11 default packet size; handshake/timeout bounds and shutdown; dead-peer detection) and track the Windows pipe DACL exposure as its own security SOW. The user's 2026-10-30 instruction: complete all SOWs autonomously, take the recommended option at design forks and record it, run correctness tests per milestone. + +### Assistant Understanding + +Facts (line numbers verified 2026-10-31 against commit `b450b40`): + +- K3: the server reads HELLO inline on the accept thread with no deadline. C POSIX `nipc_uds_accept` (`src/libnetdata/netipc/src/transport/posix/netipc_uds_lifecycle.c:277-297`) calls `nipc_uds_server_handshake`, whose blocking `recv` is at `netipc_uds_handshake.c:214`; C Windows `nipc_np_accept` (`netipc_named_pipe.c:830-880`) calls `server_handshake`, whose `ReadFile` is reached through `raw_recv` (`:662`, `:282`); Rust `UdsListener::accept_with_config` (`transport/posix.rs:713`, handshake `:1345`) and `NpListener::accept_with_config` (`transport/windows.rs:1160`, handshake `:1437`); Go `Listener.AcceptWithConfig` (`transport/posix/uds_listener.go:98`, handshake `uds_handshake.go:101-116`) and the Windows listener (`transport/windows/pipe_listener.go:78`, `pipe_handshake.go:82-97`). One silent client blocks all accepts and shutdown. Reproduced in all languages on Linux and Windows (SOW-0040). +- N2: shutdown joins workers blocked in mid-message reads or response writes. C POSIX `nipc_server_destroy` joins at `netipc_service_posix_server.c:418-428` without unblocking anything (drain shuts sockets down only after its timeout, `:373-382`); Rust `server_unix.rs:115-117` and Go `server_unix.go:168` join unconditionally; C Windows `CancelSynchronousIo` is sent once (`netipc_service_win_server.c:143-155`) and joins wait `INFINITE` (`:406`, `:440`). Reproduced on all languages and platforms. +- N3: stop before run is lost because run sets the running flag unconditionally (C POSIX `netipc_service_posix_server.c:188`, C Windows `netipc_service_win_server.c:233`, Rust `server_unix.rs:29`/`server_windows.rs:23`, Go `server_unix.go:108`/`server_windows.go:158`). In C, `drain()` before `run()` destroys `sessions_lock` that `run()` then locks (`:405`, `:228`); `destroy()` before `run()` makes `run()` poll fd -1 forever (`:414`, `:191-196`); C Windows `stop()` before `run()` closes the listener and `run()` spins on `Sleep(10)` (`:352-353`, `netipc_named_pipe.c:838-839`). Reproduced everywhere. +- N4: Rust Windows `stop()` closes the stored listener handle (`service/raw/server.rs:103-118`) that `NpListener::drop` closes again (`transport/windows.rs:1201-1211`); after `ConnectNamedPipe` that handle value becomes the live session handle (`windows.rs:1168`), so `stop()` can close a session's pipe or a reused handle value. +- N6: C Windows `sctx->thread` is written after `sessions_lock` is released (`netipc_service_win_server.c:316-320`), and `CancelSynchronousIo` is one-shot, so a worker that enters a blocking call after the cancel is never released. +- N7: C POSIX drain may `shutdown()` a reused fd: it reads `s->session.fd` under `sessions_lock` (`netipc_service_posix_server.c:373-382`) but the session thread closes the fd without that lock (`netipc_service_posix_server_session.c:291`). +- N10: Go `Stop()` closes the listener (`server_unix.go:174-198`) while `Run` polls `listener.Fd()` unlocked (`server_unix.go:115`) and accepts on the fd unlocked (`uds_listener.go:98`). +- C Windows stop races: `server_wake_listener` reads `listener.pipe` unlocked while accept replaces it (`netipc_service_win_server.c:128-138`, `netipc_named_pipe.c:867`), and `stop()` plus the end of `run()` can both close the listener (`:345`, `:353`). +- C POSIX `destroy()` closes the listener before the accept loop exits (`netipc_service_posix_server.c:414-415`). +- `FlushFileBuffers` in the server-side close blocks on a client that stopped reading (`netipc_named_pipe.c:942`, Go `pipe_session.go:140`); it runs on the worker thread, so the stop path must be able to cancel it. +- Rust `run(&mut self)` holds a unique borrow (`server_unix.rs:18`, `server_windows.rs:13`), so `stop()` cannot be called from another thread through the same value; callers use `running_flag()` (42 call sites) and tests stop servers by storing `false` into it. Its meaning ("false until `run()` starts") is asserted by tests (`apps_lookup.rs:286-289`, `apps_lookup_unix_tests.rs:283-286`). + +Inferences: + +- Netdata's consumers are C on Linux (SOW-0044 survey, `ktsaou/netdata @ ae9113f2cc`); K3 and N2 affect them whenever a plugin connects and stalls or stops reading, and the daemon's shutdown then hangs. + +Unknowns: + +- None; the design forks below are decided under the user's autonomy instruction. + +### Acceptance Criteria + +- SOW-0040 K3, N2, N3 reproduction tests pass in C, Rust and Go on Linux and Windows. +- New tests: a silent client's slot is released at the handshake deadline; stop during a pending handshake returns promptly; over-capacity clients are refused without blocking the accept loop. +- No stop path closes a handle or fd that another thread may still use (N4, N6, N7, N10, C Windows stop races), shown by code inspection and the existing suites (Go with `-race`). +- Specs updated: server handshake deadline, worker-slot accounting, stop/shutdown guarantees. + +## Analysis + +Sources checked: + +- SOW-0040 tests and evidence (`.agents/sow/done/SOW-0040-20261029-robustness-repro-tests.md`); the code locations above; `docs/level1-transport.md`, `docs/level2-typed-api.md` (Shutdown section says drain/abort mechanics are implementation details). + +Current state: + +- The reproduction tests run ungated and fail: C POSIX four (K3 ×2, N2, N3) of the six; the two K4 checks move to SOW-0051. Windows: K3 ×2, N2, N3 here; the K4 check moves to SOW-0051. + +Risks: + +- Moving the handshake to the worker changes when the worker limit applies: over-capacity clients are now refused before HELLO_ACK instead of after it. +- Forced unblocking at shutdown cuts in-flight responses; this is intended for destroy/stop and must not affect drain's graceful window. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- The accept loop performs per-client I/O (HELLO read, HELLO_ACK write) with no deadline, so the slowest client sets the pace for every other client and for shutdown (K3). +- Shutdown assumes workers return between messages; a worker blocked in a socket or pipe call never re-checks the flag, and nothing unblocks it (N2, N6). +- Stop is a single flag that `run()` overwrites (N3), and several stop paths close handles that other threads still use (N4, N7, N10, C Windows races, C POSIX destroy ordering). + +Evidence reviewed: + +- Code locations under Facts (all verified 2026-10-31); SOW-0040 reproduction tests in `tests/fixtures/c/test_robustness.c:465-700`, `test_win_robustness.c`, Rust `service/raw_robustness_unix_tests.rs`, `raw_robustness_windows_tests.rs`, Go `service/raw/robustness_unix_test.go:430-452` and `robustness_windows_test.go`. + +Affected contracts and surfaces: + +- L1 C API: new `nipc_uds_accept_pending` / `nipc_uds_accept_handshake` and `nipc_np_accept_pending` / `nipc_np_accept_handshake`; `nipc_uds_accept` / `nipc_np_accept` keep their signatures and gain the handshake deadline. Rust and Go L1 listeners gain the same split. +- L2 managed server in all languages: handshake on the worker, worker-slot accounting, stop/drain/destroy semantics, a stop-requested flag next to the existing running flag. +- Docs: `docs/level1-transport.md` (handshake deadline), `docs/level1-posix-uds.md`, `docs/level1-windows-np.md`, `docs/level2-typed-api.md` (Shutdown, worker limit), `docs/netipc-integrator-skill.md`. + +Existing patterns to reuse: + +- `nipc_service_posix_poll_with_shutdown` and the Rust/Go 100 ms poll slices for stop-aware waits; `wait_readable` (`netipc_named_pipe.c:300-345`) and its Rust/Go equivalents for Windows readability; `server_wake_listener` for waking `ConnectNamedPipe`; drain's existing `shutdown(SHUT_RDWR)` unblock. + +Risk and blast radius: + +- Every server accept and shutdown path in six implementations. Mitigation: unchanged wire protocol (handshake bytes identical), per-language reproduction tests, full interop suites, Netdata Linux targets and Windows suites. +- Performance assessment (AGENTS.md Test Cadence): established-session steady state is unchanged. The changes run at accept/handshake (once per connection), at stop/drain/destroy, and in the session thread's exit path (closing its fd under `sessions_lock`). No per-request code changes: C `server_handle_session` (`netipc_service_posix_server_session.c:93-275`) and its Rust/Go/Windows equivalents keep their loops; only their setup moves into the worker. No benchmark run needed. + +Sensitive data handling plan: + +- No secrets involved; Windows SIDs in evidence are written as ``. Netdata evidence is cited as `ktsaou/netdata @ ae9113f2cc`. Applies to this SOW, specs, docs, the integrator skill and code comments. + +Implementation plan: + +1. C POSIX: L1 split (pending accept + handshake with deadline); managed server accepts raw connections, enforces the worker limit before the handshake, and runs config/SHM preparation plus the stop-aware handshake in the session thread; `stop_requested` flag honoured by `run()`; destroy signals, waits for the accept loop, closes the listener, then waits a bounded grace for workers and shuts down the sockets of the rest; session threads close their fd under `sessions_lock`. +2. C Windows: same L1 split; worker handshake; `stop_requested`; stop/drain/destroy only signal and wake (`server_wake_listener` uses the immutable pipe name); `run()` owns the listener close, destroy closes it only when `run()` never ran; `sctx->thread` set under the lock; cancellation repeats until the worker exits. +3. Rust POSIX and Windows: listener split; handshake on the worker; `stop_requested`; run exit unblocks workers (POSIX socket shutdown through a per-session guard, Windows cancellation loop); Windows `stop()` wakes the listener by connecting instead of closing handles. +4. Go POSIX and Windows: listener split; handshake on the worker goroutine; `stopRequested`; `Stop()` only signals and wakes, `Run` owns listener close; run exit unblocks workers. +5. Tests: new deadline/stop-during-handshake/over-capacity tests per language; docs. + +Validation plan: + +- After each language milestone: that language's unit and robustness suites on Linux (and Windows for the Windows part). +- At SOW close: full C ctest, Rust and Go suites (Go with `-race`), the four POSIX interop suites, `tests/run-linux-target.sh` on the Netdata Linux targets, the full Windows C ctest plus Rust and Go Windows suites. PSI/vmstat sampling during timing-sensitive runs. + +Artifact impact plan: + +- AGENTS.md: no workflow change expected; re-check at close. +- Runtime project skills: none expected (no vendoring in this SOW). +- Specs: `docs/` transport and typed-API docs as listed above. +- End-user/operator docs: same `docs/` files. +- End-user/operator skills: `docs/netipc-integrator-skill.md` (stop/shutdown guarantees, refusal behaviour). +- SOW lifecycle: split into SOW-0043, SOW-0051, SOW-0052 on 2026-10-31. + +Open-source reference evidence: + +- `ktsaou/netdata @ ae9113f2cc` (consumer survey recorded in SOW-0044). + +Open decisions: + +- None; decisions 1-5 below are assistant-applied under the user's autonomy instruction. + +## Implications And Decisions + +Assistant-applied decisions (2026-10-31, under the user's 2026-10-30 autonomy instruction; recommended option taken, recorded for user review): + +1. Scope split (process). SOW-0043 held about twenty defects across six implementations. Split into SOW-0043 (server handshake bound and shutdown), SOW-0051 (client deadlines: K4, N8, abort during connect, attach retry, reconnect count, NOT_READY parity; the Rust client-side flush was later resolved here, decision 6) and SOW-0052 (EINTR, stale-socket race, TRUNCATED classification, SHM before HELLO). Reason: each SOW lands as one reviewable, bisectable commit (AGENTS.md: one SOW at a time). +2. K3 design: move the handshake from the accept loop to the session worker, with a fixed server handshake deadline of 5000 ms observed in 100 ms stop-aware slices. Alternative rejected: a deadline on the accept thread, which still serializes clients behind each silent one (a reconnecting silent client throttles every accept by the deadline). Classification: long-term-best. The deadline is a constant, not a config field, to keep the public config structs unchanged; Netdata clients send HELLO immediately on connect, so 5 s only bounds stalled peers. +3. Worker limit: a connection takes a worker slot from accept, including its handshake; over-capacity connections are closed before HELLO_ACK. Today they get HELLO_ACK OK and are then dropped, so the client believes it is READY and fails its first call. Refusing before HELLO_ACK makes the connect itself fail, which the client already handles by retrying on its next refresh. +4. Shutdown: stop/destroy (and Rust/Go run exit) give workers a bounded grace of 1000 ms to finish their current message, then force-unblock the rest (POSIX `shutdown(SHUT_RDWR)` on session sockets, Windows `CancelSynchronousIo` repeated until the worker exits). Drain keeps its caller-supplied graceful window. In-flight responses on stuck sessions are cut, which is what shutdown means; the spec's Shutdown section is updated to state it. +5. Stop state: a stop-requested flag set by stop/drain/destroy and never cleared, checked by `run()` before it starts; the existing running flag keeps its meaning ("the accept loop is running") because Rust exposes it publicly (`running_flag()`) and tests depend on it. Servers are single-use: `run()` after a stop returns at once. + +6. Windows session close (2026-10-31, found by validation). The Rust Windows N2 test failed intermittently (1 in about 12 runs): after the stop path cancelled the worker's blocked `WriteFile`, the worker entered `session.close()`, whose `FlushFileBuffers` waits for a client that never reads. Instrumented runs on `costa-win11` showed `CancelSynchronousIo` itself blocking for the 2 s until the test dropped its client, and `DisconnectNamedPipe` from the stopper blocking for the same 2 s (a synchronous pipe handle serializes its I/O). A standalone probe showed that a plain server-side `CloseHandle` returns in 0 ms with the client not reading, and that the client still reads every message written before the close and then gets `ERROR_BROKEN_PIPE` (109), for 3×100 B, 4×8000 B and 1×60000 B messages. The flush therefore protects no data and only adds an unbounded wait. Decision: session close in C, Rust and Go closes the handle only, for both roles (the Rust client-role flush listed in SOW-0051 goes too). Classification: long-term-best; one rule in three implementations instead of a stop-only special case that would still race with a worker already inside the flush. + +7. Bounded wait for a worker slot (2026-10-31, found by validation). On the `i386-musl` target, C `test_service` "Test 3k" closed a client of a one-worker server and refreshed a new one at once (`tests/fixtures/c/test_service.c:3496-3510`); the old worker had seen the hangup but its thread had not exited yet, so decision 3 refused the new connection and the client was not READY. Before this SOW the refused client got HELLO_ACK OK and was dropped later, and the C client's internal reconnect hid it. A refresh right after a close is a normal pattern (the client's own reconnect after an error does it). Decision: when all workers are busy, the accept loop waits up to 200 ms (two 100 ms poll slices; a worker whose client left exits within one) for a slot, observing stop, then refuses. Waiting costs nothing because a full server cannot serve any other connection meanwhile. Applied in C POSIX/Windows (`server_wait_for_slot_locked`), Rust (`wait_for_worker_slot` in `service/raw/common.rs`) and Go (`acquireWorkerSlot`). Classification: long-term-best. + +8. Test policy (user, 2026-10-31): correctness tests take seconds, synchronize without long sleeps, and fuzz/stress/benchmarks run only before vendoring when needed. Applied to this SOW's close validation: `tests/run-linux-target.sh` now excludes `fuzz_*`, `test_stress*`, `test_lookup_scale*` and benchmark tooling from ctest, Rust skips `test_stress*`, Go skips `^TestStress`; the Windows validation runs use the same exclusions. The test-suite rework itself is SOW-0053. + +Rejected (with evidence): + +- Idle authenticated clients holding worker slots indefinitely: not a defect. Netdata plugins keep long-lived sessions open by design; an idle timeout would break them. + +## Plan + +See the implementation plan in the gate; milestones are the per-language steps 1-4, each followed by that language's tests. + +## Execution Log + +2026-10-31: + +- C POSIX (milestone 1): `NIPC_SERVER_HANDSHAKE_TIMEOUT_MS` (`netipc_protocol.h`); L1 split `nipc_uds_accept_pending` / `nipc_uds_accept_handshake` (`netipc_uds.h`, `netipc_uds_lifecycle.c`) with `nipc_uds_wait_readable` for the deadline; `stop_requested` in `netipc_service.h`; `netipc_service_posix_server.c` run/stop/drain/destroy rewritten (stop check before and after marking running, raw accept, capacity check before the handshake, destroy waits for the accept loop before closing the listener); handshake, SHM preparation and session publish moved into `netipc_service_posix_server_session.c`; the session thread closes its fd under `sessions_lock` (N7); `nipc_service_posix_server_wait_sessions` gives a 1000 ms grace, then shuts the remaining sockets down. +- C Windows (milestone 2): `nipc_np_accept_pending` / `nipc_np_accept_handshake` (`netipc_named_pipe.h/.c`); a peer gone before HELLO keeps reporting `NIPC_NP_ERR_RECV` as before the wait was added (found by `test_named_pipe` "peer closes before HELLO"). `netipc_service_win_server.c`: run checks the stop flag, drops the wake connection, enforces capacity before the handshake, creates and stores the worker thread under the lock (N6), and owns the listener close; stop only signals and wakes; `server_wake_listener` uses only the immutable pipe name (stop race); drain/destroy use `nipc_service_win_server_wait_sessions` (grace, then `CancelSynchronousIo` repeated until every worker exits). +- Rust (milestone 3): `SERVER_HANDSHAKE_TIMEOUT_MS`; `UdsListener::accept_pending` -> `PendingConnection::handshake(.., timeout_ms, abort)` and `NpListener::accept_pending` -> `PendingPipe::handshake`; `NpListenerWaker` replaces the stored listener handle, so `stop()` never closes a handle (N4); `server_unix.rs` / `server_windows.rs` run the handshake on the worker, refuse over capacity before HELLO_ACK, and `stop_workers` applies the grace then socket shutdown (POSIX) or the `CancelSynchronousIo` loop (Windows). +- Go (milestone 4): `protocol.ServerHandshakeTimeoutMs`; `Listener.AcceptPending` -> `PendingConn.Handshake(.., timeoutMs, abortCh)` on POSIX and Windows, plus Windows `Listener.Wake`; `server.go` gains `stopChan`/`markStopped` (flag never cleared, stop channel aborts handshakes); POSIX `Stop()` no longer closes the listener (N10), Run owns it; workers run the handshake, POSIX publishes session fds for `shutdown(SHUT_RDWR)` under the lock that also guards their close; Windows workers lock their OS thread and publish a thread handle for the `CancelSynchronousIo` loop; Windows `Stop()` re-wakes the accept every 50 ms until Run unpublishes the listener. +- Tests: the capacity tests in Rust (`raw_unix_tests.rs` `test_server_rejects_session_at_worker_capacity`) and Go (`more_unix_test.go` `TestUnixServerRejectsSessionAtWorkerCapacity`) now assert that the over-capacity connect fails; new "silent client released at the handshake deadline" test in C POSIX/Windows (`test_robustness.c`, `test_win_robustness.c`), Rust POSIX/Windows and Go POSIX/Windows (one worker, a silent client, a legitimate client must be served within deadline + 4 s; POSIX also checks the server closed the silent socket). Stop during a pending handshake is covered by the existing K3 "silent client does not block shutdown" tests, whose silent client now sits in a worker's handshake. +- Validation fixes: bounded worker-slot wait before refusing (decision 7). Windows session close no longer flushes or disconnects (decision 6; `netipc_named_pipe.c` `nipc_np_close_session`, Rust `NpSession::close`, Go `Session.Close`, unused Go `flushFileBuffers` and the Rust `FlushFileBuffers` import removed). Go one-step `AcceptWithConfig` now applies `ServerHandshakeTimeoutMs`, like C `nipc_uds_accept`/`nipc_np_accept` and Rust `accept_with_config`. Go Windows `pipe_edge_test.go`: five tests closed a `rawPipePair` end that the helper's cleanup closed again; on a reused handle value this closed a Go runtime event and crashed the test binary (`runtime: setevent failed; errno=6`, seen once in the full Windows run). New `rawPipePairWithClosers` returns closers that tell the cleanup to skip. +- Docs: `docs/level1-transport.md` (accept split, server handshake deadline), `docs/level1-posix-uds.md` and `docs/level1-windows-np.md` (split API names, Windows wake rule), `docs/level2-typed-api.md` (worker handshake, refusal before HELLO_ACK, Shutdown guarantees), `docs/netipc-integrator-skill.md` (refusal, 5000 ms bound, 1000 ms stop grace); C header and Rust/Go run/stop doc comments. + +## Validation + +Acceptance criteria evidence: + +- K3, N2, N3 reproduction tests (SOW-0040) pass in C, Rust and Go on Linux, on all Netdata Linux targets and on Windows (runs below). The only remaining robustness failures are the two K4 client-deadline tests per language (`test_robustness` / `test_win_robustness` "refresh + call with 1s timeout return within 6s", Rust `test_robust_client_bounded_when_server_never_accepts` / `_never_sends_hello_ack` and the Windows equivalent, Go `TestRobustClientBoundedWhenServerNever{Accepts,SendsHelloAck}` and `TestWinRobustClientBoundedWhenServerNeverSendsHelloAck`); K4 is client-side and is SOW-0051 scope (decision 1). +- New tests pass everywhere: silent client released at the handshake deadline (C POSIX/Windows, Rust POSIX/Windows, Go POSIX/Windows); over-capacity connect refused (Rust `test_server_rejects_session_at_worker_capacity`, Go `TestUnixServerRejectsSessionAtWorkerCapacity`); stop during a pending handshake (K3 "silent client does not block shutdown" tests, whose silent client now sits in a worker handshake). +- Handle/fd ownership (N4, N6, N7, N10, C Windows stop races): by inspection, stop paths only signal and wake; Run owns the listener close in every implementation; Rust Windows `stop()` uses `NpListenerWaker` and closes no handle; C Windows `sctx->thread` is set under `sessions_lock`; POSIX session fds are shut down and closed under the same lock. Go `-race` on Linux reports no race. +- Specs/docs updated (see artifact gate). + +Tests (2026-10-31, after all validation fixes): + +- Linux host: full ctest (correctness set) fails only K4 entries (`test_robustness`, and `test_service_rust` through its Rust K4 tests); Rust lib (stress skipped) 390 passed, 2 K4 failed; Go `-race` (stress skipped) only the 2 K4 tests fail; POSIX interop `interop_codec.sh`, `test_uds_interop.sh`, `test_shm_interop.sh`, `test_service_interop.sh` pass. +- Netdata Linux targets (`tests/run-linux-target.sh`, C + Rust + Go + interop): x86_64-musl, i386-gnu, i386-musl, armv7-gnu, armv7-musl, armv6-musl, aarch64-gnu, aarch64-musl fail only K4 (C ctest about 65-68 s, Rust 390 passed / 2 K4 failed, Go K4 only; all interop suites pass). armv7-gnu-time64: the first run failed broadly (23 Rust tests, 2 Go SHM tests, `test_uds_interop` "server not ready after 10s") while host memory PSI `some` avg10 reached 40-75 % (other workloads on the host); the rerun with memory `some` avg10 at most 7 % failed only K4 (Rust 390 passed / 2 K4 failed, C ctest 68 s, all interop suites pass), so the first run was a host stall, not a defect. +- Windows (`costa-win11`, MSYS2): C ctest (20 correctness tests, 85 s), only the `test_win_robustness` K4 check fails; Rust 274 passed, 1 K4 failed; Go only `TestWinRobustClientBoundedWhenServerNeverSendsHelloAck` fails; no leftover processes. The Rust Windows N2 test, intermittent before decision 6, passed 30 of 30 repeated runs. +- Timing triage: PSI and `/proc/vmstat` were sampled every 30 s during the target runs (`compact_stall` and memory `some` recorded per target). + +Real-use evidence: + +- The interop suites run real C, Rust and Go servers and clients against each other over UDS and SHM on every Linux target; the Windows probe (`CloseHandle` on a non-reading client) confirmed decision 6 against the real kernel behaviour. + +Performance assessment: + +- No established-session per-request code changed (gate). Decision 6 removes `FlushFileBuffers` from session close only; decision 7 adds a wait in the accept loop only when every worker is busy. Neither runs per request, so no benchmark run is needed. + +Reviewer findings: + +- No external review was run for this SOW; validation failures found by the suites (Test 3k slot race, `test_named_pipe` "peer closes before HELLO", Rust Windows N2 flush hang, Go Windows double close, Go one-step accept without deadline) were fixed and are recorded in the Execution Log and decisions 6-7. + +Same-failure search: + +- `FlushFileBuffers`: no remaining call in C, Rust or Go (`grep -rn FlushFileBuffers src tests` finds only comments). +- `DisconnectNamedPipe`: remaining calls are on pre-handshake refusal/error paths owned by the accept or worker thread (`netipc_service_win_server.c:170-209`, `netipc_service_win_server_session.c:334`, `netipc_named_pipe.c:861-900`, Rust `PendingPipe::drop` and listener error path, Go `pipe_listener.go:115-169`), where no other thread has I/O on the handle and no server data is pending. +- Double close of test pipe handles: the pattern exists only in Go `pipe_edge_test.go` (fixed with `rawPipePairWithClosers`); the C and Rust Windows tests have no shared pipe-pair cleanup helper. +- Stop flag overwritten by run: all six servers now check a never-cleared stop-requested flag before and after marking running. + +Artifact maintenance gate: + +- `AGENTS.md`: no change in this SOW; the user's test policy (decision 8) goes into Test Cadence through SOW-0053, which owns the suite rework. +- Runtime project skills: `project-netdata-vendoring` unaffected (no vendoring); its fuzz/stress pre-vendoring step is added by SOW-0053. +- Specs: `docs/level1-transport.md`, `docs/level1-posix-uds.md`, `docs/level1-windows-np.md`, `docs/level2-typed-api.md` updated (accept split, 5000 ms server handshake deadline, Windows wake and close-only rules, worker handshake, 200 ms slot wait and refusal before HELLO_ACK, Shutdown guarantees). No `.agents/sow/specs/` entry needed: the public docs carry these rules. +- End-user/operator docs: the same `docs/` files plus C header and Rust/Go doc comments. +- End-user/operator skills: `docs/netipc-integrator-skill.md` updated (refusal, 5000 ms bound, 1000 ms stop grace). +- SOW lifecycle: split into SOW-0043, SOW-0051, SOW-0052; SOW-0053 created from the user's policy; SOW-0051 updated (client flush resolved here). This SOW moves to `done/` in its commit. + +Lessons: see Lessons Extracted. Follow-up mapping: see Followup. + +## Outcome + +Completed 2026-10-31. A silent or slow client can no longer block accepts or shutdown in any of the six server implementations: the handshake runs on the worker with a 5000 ms deadline, over-capacity connections are refused before HELLO_ACK after a 200 ms slot wait, stop is never lost, stop paths no longer close handles other threads use, and shutdown force-unblocks workers after a 1000 ms grace. Windows session close no longer flushes. Client-side deadlines (K4) remain open in SOW-0051. + +## Lessons Extracted + +- Refusing earlier exposes client paths that the late refusal hid (Test 3k): when moving a failure earlier in a protocol, re-check callers that relied on the later one. +- On synchronous Windows pipes, `CancelSynchronousIo` and `DisconnectNamedPipe` from another thread serialize behind a blocked `FlushFileBuffers`; prove such assumptions with a standalone probe before designing a stop path around them. +- Slow suites under memory pressure produce mass timeout failures that look like regressions; sample PSI and rerun before triage (AGENTS.md Test Cadence already says so; it applied here). + +## Followup + +- SOW-0051 (client call deadlines) and SOW-0052 (transport hygiene): split from this SOW on 2026-10-31. +- SOW-0053 (deterministic correctness suite): created 2026-10-31 from the user's test policy; runs next. + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0044-20261029-dead-peer-detection.md b/.agents/sow/done/SOW-0044-20261029-dead-peer-detection.md new file mode 100644 index 00000000..dc6a8177 --- /dev/null +++ b/.agents/sow/done/SOW-0044-20261029-dead-peer-detection.md @@ -0,0 +1,255 @@ +# SOW-0044 - Dead Peer Detection + +## Status + +Status: completed + +Sub-state: completed 2026-10-31. First of the two Netdata bug-fix SOWs (this one, then SOW-0043); vendoring both into Netdata is tracked by SOW-0050. + +## Requirements + +### Purpose + +Servers must release sessions whose client left (cleanly or by crashing), and clients must notice a crashed server, on both SHM transports. + +### User Request + +From SOW-0040 decisions (2026-10-29): split the fix work into separate SOWs (N1 Rust timeout; N11 default packet size; handshake/timeout bounds and shutdown; dead-peer detection) and track the Windows pipe DACL exposure as its own security SOW. + +### Assistant Understanding + +Facts: + +- K1: Rust and Go POSIX SHM server sessions ignore client departure on SHM receive timeout (`src/crates/netipc/src/service/raw/server_session_unix.rs:54`, `src/go/pkg/netipc/service/raw/server_unix.go:208-209`); C checks the session socket (`netipc_service_posix_server_session.c:121-125`). Worker slots and `.ipcshm` files leak. Reproduced in Rust and Go; C guard passes (SOW-0040). +- K2: Windows SHM server sessions detect only the clean-close flag (`netipc_win_shm.c:671-680`, `:893-907`; C `netipc_service_win_server_session.c:47-48`, Rust `server_session_windows.rs:49`, Go `server_windows.go:250-251`). A crashed client holds a worker until server stop. Reproduced in all three languages. The spec (`docs/level1-windows-shm.md:189-211`) has the same gap. +- N5: SHM clients never notice a crashed server and wait the full call timeout (POSIX: `nipc_shm_owner_alive` exists at `netipc_shm.c:874` but is unused in the call loop; Windows: only `resp_server_closed`, `netipc_win_shm.c:893-906`). Reproduced in all languages on Linux and Windows (10 s with a 10 s timeout). +- Amplifier: a client whose SHM attach fails falls back to a new baseline session (`netipc_service_posix_client_connect.c:138-150`, Go `client_unix.go:104-115`), leaking one slot per fallback on Rust/Go servers. +- Related: refused or broken SHM sessions surface as `Truncated` / `buffer too short` instead of a disconnect error in Rust and Go. +- Analysis 2026-10-29: no POSIX transport sets `SOCK_CLOEXEC`/`O_CLOEXEC` (C `netipc_uds_lifecycle.c:202,244,275`, Rust `posix.rs:266,646,712`, Go `uds_listener.go:34,98`, `uds_session.go:95`); a forking process (Netdata spawning plugins) leaks session sockets into children, which keeps the peer from ever seeing EOF and defeats socket-based dead-peer detection. +- Analysis 2026-10-29: the `nipc_shm_owner_alive` generation check can never fail, because the cached generation is read from the same mapped header it is later compared against (`netipc_shm.c:621` vs `:887`); its `kill(pid, 0)` check breaks across PID namespaces. +- Correction 2026-10-29: the "fallback leak" is not a separate client bug (C and Go close the session socket before the baseline retry); it leaks only because of K1. The error-code issue is cross-language: C also returns `NIPC_ERR_TRUNCATED` for every SHM/socket transport failure (`netipc_service_posix_client_call.c:101,124`), and no language has a "disconnected" error. +- Sibling bug: the client's SHM attach retry waits its full 5 s even when the server already closed the session socket (refusal at capacity). + +Netdata usage (verified 2026-10-30, `netdata/netdata` fork `ktsaou/netdata @ ae9113f2cc`): + +- All consumers are C on Linux; no Go or Rust consumer exists and the Rust crate is not built into any binary (`src/crates/Cargo.toml:61` workspace member only; `.github/workflows/rust-tests.yml:86` excludes it). +- Servers: netdata daemon (`cgroups-snapshot`, `cgroups-lookup`; `src/collectors/cgroups.plugin/cgroup-netipc.c:212-252`, `cgroup-lookup-netipc.c:282-333`) and apps.plugin (`apps-lookup`; `src/collectors/apps.plugin/apps-lookup-netipc.c:310-354`). +- Clients: apps.plugin, ebpf.plugin, network-viewer.plugin; all long-lived, 5000 ms call timeout (`apps-cgroups-lookup-client.c:12`, `ebpf_cgroup.c:13`, `network-viewer-apps-lookup-client.c:21`). +- Every consumer prefers `NIPC_PROFILE_SHM_FUTEX` (e.g. `cgroup-netipc.c:216-217`), so live sessions run on POSIX SHM. +- Impact: N5 hits Netdata today (each client call waits the full 5 s after the daemon or apps.plugin dies). K1 and K2 do not (C POSIX servers already detect departure; no Windows consumer). Missing close-on-exec does not leak today: the spawn server forks before NetIPC fds exist and closes all non-stdio fds (`src/libnetdata/spawn_server/spawn_server_nofork.c:352-362,1577-1595`); it remains defence in depth. +- No Netdata consumer checks `NIPC_ERR_TRUNCATED` or calls `nipc_shm_owner_alive`; consumers check only TIMEOUT, ABORTED, OVERFLOW, BAD_LAYOUT, BAD_ITEM_COUNT. + +Unknowns: + +- None blocking; design decisions recorded below. + +### Acceptance Criteria + +- SOW-0040 K1, K2, N5 tests pass; C guards keep passing. +- Windows spec revised with a crash-detection rule (for example named-pipe break or process liveness). +- Error codes for refused/broken SHM sessions reviewed for cross-language consistency. + +## Analysis + +Sources checked: + +- See SOW-0040 (`.agents/sow/done/SOW-0040-20261029-robustness-repro-tests.md`) for evidence, test files and commands. + +Current state: + +- Reproduction tests exist and run ungated (the `NETIPC_REPRO_KNOWN_BUGS` gate was removed in SOW-0041 under the zero-known-bug policy); they fail until this SOW lands. + +Risks: + +- Cross-language behavior change; interop and Netdata vendoring must be validated. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- SHM sessions carry requests through shared memory, so a peer's death is invisible to the SHM wait: the futex/event wait just times out. The session's control channel (UDS socket on POSIX, named pipe on Windows) stays open for the whole SHM session and is closed by the kernel when the peer process exits; it is the only reliable, namespace-independent liveness signal, but only C POSIX servers consult it. +- Transport failures are reported as `TRUNCATED` (receive) or `NOT_READY` (send) because no "peer gone" service error exists. +- `nipc_shm_owner_alive` cannot detect a replaced region (cached and compared generation come from the same mapping) and `kill(pid, 0)` is wrong across PID namespaces and after PID reuse. +- Sockets are created without close-on-exec, so any fork+exec in a consumer would keep session sockets open in the child and mask EOF. + +Evidence reviewed: + +- SOW-0040 reproduction tests: C `tests/fixtures/c/test_robustness.c:407-460,700-760`; Go `robustness_unix_test.go:226-272,498-530`, `robustness_windows_test.go:185-200,342+`; Rust `service::raw::tests::robustness`, `service::raw::windows_tests::robustness`. +- C peer check: `src/libnetdata/netipc/src/service/netipc_service_posix_server_session.c:43-86,118-126`. +- Rust/Go servers ignoring departure: `src/crates/netipc/src/service/raw/server_session_unix.rs:54`, `src/go/pkg/netipc/service/raw/server_unix.go:208-209`; Windows: `netipc_service_win_server_session.c:47-48`, `server_session_windows.rs:49`, `server_windows.go:250-251`. +- Client SHM waits already sliced at 100 ms for abort: C `netipc_service_posix_client_call.c:77-102`, `netipc_service_win_client_call.c:72-95`; Rust `client_call.rs:440-488`; Go `client_unix.go:200-240`, `client_windows.go:190-225`. +- Error mapping: C `netipc_service_posix_client_call.c:47,58,102,127`, `netipc_service_win_client_call.c:94,118`; Rust `client_call.rs:215,249,270,456,477,487,498,508`; Go `client_unix.go:157-237`, `client_windows.go:146-224`. +- Retry path: `netipc_service_common.c:383-406` (non-overflow, non-timeout errors reconnect and retry once). +- Windows pipe disconnect probe: `netipc_named_pipe.c:300-345` (`PeekNamedPipe` + `is_disconnect_error`); Rust `transport/windows.rs` `wait_readable`; Go `pipe_session.go:89` `WaitReadable`. +- SHM attach retry: C `netipc_service_posix_client_connect.c:118-150`; Rust `client_unix.rs:20-50`, `client_windows.rs:15-45`; Go `client_unix.go:95-120`, `client_windows.go`. +- `owner_alive`: C `netipc_shm.h:211`, `netipc_shm.c:874-905`; Rust `transport/shm.rs:178`; Go `transport/posix/shm_linux.go:121-140`; tests `test_shm.c:542,1278-1392`, `shm_tests.rs:673-760`, `shm_edge_test.go:62-80`, `shm_more_edge_test.go:177-205`. +- Close-on-exec: C `netipc_uds_lifecycle.c:145,202,244,275`; Rust `transport/posix.rs:266,646,712`; Go `uds_listener.go:34,98`, `uds_session.go:95`. +- Netdata usage: see Facts (Netdata usage). + +Affected contracts and surfaces: + +- Public error enums: C `nipc_error_t` gains `NIPC_ERR_DISCONNECTED` appended after `NIPC_ERR_ABORTED` (no existing value changes); Rust `NipcError::Disconnected`; Go `protocol.ErrDisconnected`. Error-to-string helpers in all three. +- Public transport API removal: `nipc_shm_owner_alive`, `ShmContext::owner_alive`, `(*ShmContext).OwnerAlive`. +- Behavior: SHM servers (all languages, POSIX and Windows) end a session when its control channel reports the peer gone; SHM clients fail a waiting call with DISCONNECTED within one 100 ms slice of the server's death, then take the existing reconnect-and-retry-once path; SHM attach stops retrying when the server closed the session socket and reports the connect as failed without disabling SHM for the context. +- Sockets are close-on-exec on every platform. +- Wire protocol: unchanged. Interop: unchanged. +- Specs: `docs/level1-posix-shm.md`, `docs/level1-windows-shm.md`, the service-level error documentation, `docs/netipc-integrator-skill.md`. + +Existing patterns to reuse: + +- C `session_peer_disconnected()` (poll with zero timeout for HUP/ERR/RDHUP, `MSG_PEEK` EOF check): move to a shared POSIX service helper; port to Rust (`poll_fd` neighbourhood in `server_session_unix.rs`) and Go (`poll_unix.go`). +- Windows: `nipc_np_wait_readable(session, 0, ...)` returning `NIPC_NP_ERR_DISCONNECTED`; Rust `wait_readable(0)` returning `NpError::Disconnected`-class error; Go `WaitReadable(0)`. +- Client 100 ms abort slices: the liveness probe runs in the same slice loop after an empty wait. + +Risk and blast radius: + +- Hot path: no extra work while messages flow; the probe runs only after an empty 100 ms wait (one `poll()`/`PeekNamedPipe` syscall per idle 100 ms per session). Performance assessment (AGENTS.md, Test Cadence): no benchmark run needed. On an established session's success path the only added work is one `Option` field read of the session fd per Rust SHM call (`client_call.rs:286`); every other addition runs only after an empty 100 ms wait (C `netipc_service_posix_client_call.c:115-118`, Rust `client_call.rs:467-472`, Go `client_unix.go:227-230`, servers on SHM receive timeout), on error branches (`socket_failure`, `session_failure`, `socketFailure`), or at connection setup (close-on-exec flags, attach-retry probe). The SHM transport diffs only remove `owner_alive` and add `O_CLOEXEC` at open. +- False positives: a probe can only report "gone" on HUP/ERR/EOF or a broken pipe, which the kernel raises only when the peer end is closed. During SHM sessions no data flows on the control channel, so `POLLIN` with `MSG_PEEK` returning data does not count as disconnect (same as C today). +- Behavior change for callers that matched `TRUNCATED`/`NOT_READY` on disconnects: none in Netdata; recorded in docs. +- API removal of `owner_alive`: no known users (Netdata does not call it). +- Close-on-exec on platforms without `SOCK_CLOEXEC` (macOS): `fcntl(FD_CLOEXEC)` right after creation; a fork in the tiny window between the two calls can still inherit it (documented platform limit; Go uses `syscall.ForkLock` there). + +Sensitive data handling plan: + +- No secrets, customer data or private endpoints involved. Netdata evidence is cited as `ktsaou/netdata @ ae9113f2cc` with repo-relative paths. Windows SIDs, if any appear in test output, are written as ``. Applies to this SOW, specs, docs, the integrator skill and code comments. + +Implementation plan: + +1. Error code: add DISCONNECTED to C/Rust/Go error enums and string helpers. +2. Shared liveness probe per platform/language (POSIX socket probe; Windows pipe probe). +3. Servers: on SHM receive timeout, probe the control channel and end the session if gone (Rust/Go POSIX; C/Rust/Go Windows). +4. Clients: in the SHM wait slices, probe after each empty slice and return DISCONNECTED; classify every client transport send/receive failure (SHM and socket/pipe) as DISCONNECTED when the probe says the peer is gone, otherwise keep the current code. +5. Attach retry: probe in the retry loop; on disconnect close the session and report the connect failed without disabling SHM. +6. Remove `owner_alive` and its tests in all three languages; remove now-unused fields/codes only if nothing else uses them. +7. Close-on-exec for all sockets (C, Rust, Go), with `fcntl` fallback where `SOCK_CLOEXEC`/`accept4` is unavailable. +8. Strengthen the K1/K2/N5 reproduction tests to assert DISCONNECTED where a client error is observed; add a close-on-exec test. +9. Specs/docs/integrator skill updates. + +Validation plan: + +- SOW-0040 K1, K2, N5 reproduction tests pass in C, Rust and Go on Linux and `costa-win11`; C guards keep passing. +- Full C ctest, Rust and Go suites on the host and Windows; `tests/run-linux-target.sh` on the 9 Netdata Linux targets. +- Interop scripts (`tests/test_shm_interop.sh`, `tests/test_service_interop.sh`, `tests/test_uds_interop.sh`). +- Benchmarks: not needed; the performance assessment under Risk and blast radius finds no change on the established-session per-request path (decision 4). +- Formatting/lint: `cargo fmt --check`, gofmt, go vet, shellcheck, `git diff --check`. + +Artifact impact plan: + +- AGENTS.md: expected no change (no workflow change); re-check at close. +- Runtime project skills: `project-netdata-vendoring` expected unaffected; re-check at close. +- Specs: `docs/level1-posix-shm.md`, `docs/level1-windows-shm.md`, service error documentation. +- End-user/operator docs: same docs (they are the public specs). +- End-user/operator skills: `docs/netipc-integrator-skill.md` (new error code, liveness behavior, `owner_alive` removal). +- SOW lifecycle: moved to `current/` 2026-10-30; SOW-0043 follows; Netdata vendoring after both. + +Open-source reference evidence: + +- `ktsaou/netdata @ ae9113f2cc` (Netdata consumer survey; paths listed in Facts). + +Open decisions: + +- None; see Implications And Decisions. + +## Implications And Decisions + +User decisions (2026-10-30): + +1. Delivery order: SOW-0044 first, then SOW-0043, then a single Netdata vendoring PR after both; run a read-only Netdata usage survey first (done, see Facts). +2. Error code (decision 1A): add a real "disconnected" error in C, Rust and Go, appended to the enum, used for every peer-gone case on SHM and socket/pipe transports; it keeps the existing reconnect-and-retry-once path. +3. `owner_alive` (decision 2A): remove it from C, Rust and Go together with its tests. +4. Test cadence (2026-10-30, raised while this SOW's before/after benchmarks were running; refined the same day): correctness tests only, targeted to the affected surface, between fixes and at SOW close. Benchmarks run only when a change can affect established-session steady state, judged by a per-SOW performance assessment with evidence; when needed they run once after every fix touching those paths is in, and all regressions are fixed before a single rerun. Recorded in `AGENTS.md` (Test Cadence) and `.agents/skills/project-netdata-vendoring/SKILL.md` (step 7, performance assessment). The in-flight benchmark runs were stopped. + +Assistant-applied (single sound option, stated to the user): control-channel liveness probe as the dead-peer signal on POSIX and Windows; close-on-exec on all sockets; SHM attach retry stops when the server closed the session socket. + +## Plan + +See Pre-Implementation Gate, Implementation plan. + +## Execution Log + +2026-10-30: + +- Error code: C `NIPC_ERR_DISCONNECTED` appended after `NIPC_ERR_ABORTED` (`netipc_protocol.h`); Rust `NipcError::Disconnected` (+ Display); Go `protocol.ErrDisconnected`. +- Liveness probe: C `nipc_service_posix_peer_disconnected()` (moved out of the static server helper, declared in `netipc_service_posix_internal.h`) and `nipc_service_win_peer_disconnected()` (static inline, `netipc_service_win_internal.h`); Rust `server_session_unix::peer_disconnected`, `server_session_windows::peer_disconnected`; Go `peerDisconnected` (`poll_unix.go`, adds `syspoll.RdHup`) and `pipePeerDisconnected` (`client_windows.go`). +- Servers: probe on idle SHM receive in Rust/Go POSIX and C/Rust/Go Windows. +- Clients: probe after each idle 100 ms SHM wait slice (all languages, both platforms); WinSHM close flag maps to DISCONNECTED; socket/pipe send/recv failures map to DISCONNECTED only when they are the transport's send/recv-failure class (or a broken pipe) and the probe confirms the peer is gone. +- Correction during validation: the first version probed after any transport error, which relabelled a protocol violation (wrong response `message_id`) as DISCONNECTED when the test server closed right after sending it (Go Windows `TestWinCall*RejectsMalformedResponseEnvelope`, `TestWinClientTransportReceiveWinShmError`). Narrowed to the send/recv-failure class, matching decision 1A ("end of stream, EPIPE, ECONNRESET, broken pipe"); a missing session is not treated as a disconnect. +- Attach retry: all languages/platforms stop when the probe reports the server closed the session, return DISCONNECTED, and keep SHM enabled (previously the client waited 5 s and then permanently disabled SHM for the context). +- `owner_alive` removed (C function + `nipc_shm_ctx_t.owner_generation` cache, Rust method + field, Go method + field) with its tests; the on-disk `owner_generation` header field stays (stale-region recovery uses it). +- Close-on-exec: C `SOCK_CLOEXEC` / `accept4` / `pipe2` with `fcntl` fallback; Rust `SOCK_CLOEXEC` / `accept4` with fallback, `O_CLOEXEC` on all SHM opens; Go `SOCK_CLOEXEC` / `Accept4` (linux, freebsd) with `ForkLock` fallback, and `F_DUPFD_CLOEXEC` instead of `syscall.Dup` for SHM descriptors (Go `Dup` leaked the region fd). +- Tests: N5 asserts DISCONNECTED (C/Rust/Go, POSIX and Windows); new baseline-socket disconnect test, SHM attach-stop test (POSIX and Windows), close-on-exec tests (C all fds via `/proc/self/fd`; Rust/Go transport-level listener/accepted/client sockets and SHM fds). +- Proof the new C tests detect the bugs: built against unfixed HEAD, all new checks fail (N5 SHM call returned after 10000 ms; baseline returned TRUNCATED; refused refresh took 5001 ms and lost SHM; 5 socket/pipe fds without close-on-exec). +- Specs: `docs/level1-posix-shm.md` (Peer liveness, attach stop), `docs/level1-windows-shm.md` (Detecting a crashed peer, attach stop), `docs/level1-posix-uds.md` (close-on-exec), `docs/level2-typed-api.md` (disconnected error); integrator skill updated. + +## Validation + +Acceptance criteria evidence: + +- K1, K2, N5 reproduction tests pass in C, Rust and Go on Linux and Windows; the C guards keep passing. N5 now fails the call with DISCONNECTED in about 1 s instead of waiting the 10 s timeout (Windows C: `err=16` after about 1 s). +- Windows spec revised: `docs/level1-windows-shm.md` "Detecting a crashed peer" (named-pipe break probed after an idle wait) and the attach stop rule. +- Error codes: all peer-gone cases on SHM, UDS and named pipes now return the new disconnected error in all three languages (decision 1A); other SHM failures keep their previous codes. +- Proof the new tests detect the bugs: the new C checks built against unfixed HEAD all fail (see Execution Log). Go armv7 A/B on a calm host: HEAD fails `TestRobustShmSessionsReleasedAfterClientCrash` and `TestRobustShmClientNoticesCrashedServer` 8/8 (call waits 10 s), the fixed tree passes 8/8 in 1.3 s. + +Tests and equivalent validation: + +- Linux host: C ctest, Rust (391 pass), Go: the only failures are the six SOW-0043 reproduction tests per language (`test_robustness` handshake/timeout/shutdown checks; `test_service_rust` runs the same Rust filter). +- Interop: `interop_codec`, `test_uds_interop` (18), `test_shm_interop` (9), `test_service_interop` (18) all pass. +- Netdata Linux targets (`tests/run-linux-target.sh`, C + Rust + Go + interop): i386-gnu, x86_64-musl, aarch64-gnu, aarch64-musl, armv7-gnu-time64, armv6-musl, i386-musl clean apart from the SOW-0043 tests. Failures seen in the first pass on armv7-gnu, armv7-musl, armv6-musl and i386-musl coincided with high memory-stall time (`/proc/pressure/memory` some avg10 70-84%) charged to the test session itself, while over 100 GB of RAM stayed available; kernel counters point to direct reclaim and compaction stalls on fragmented memory (`/proc/vmstat` compact_stall, allocstall), but the counters were not sampled per run, so the cause is not proven. Symptoms: e.g. a 100 ms SHM receive timeout measured 49 s, helpers missing a 10 s ready window. Reruns cleared all of them. The final armv7-musl rerun was sampled during the run: it finished in 192 s (the two failed runs took 1101 s and 1315 s), C and Rust failed only the six SOW-0043 tests, interop passed 6/6, memory-stall time stayed at 0 and `compact_stall`/`allocstall` did not move. Go is skipped on musl targets by design (pure Go does not link libc; covered by the gnu targets). +- Windows (costa-win11, MSYS2): Rust 269 pass, five SOW-0043 failures; Go only the five `TestWinRobust*` SOW-0043 failures. Full unfiltered C ctest (37 tests, including every Windows interop suite): 34 pass; `test_win_robustness` fails only its five SOW-0043 checks; `test_lookup_scale_win_interop` and `test_lookup_scale_win_shm_interop` hit the 1800 s ctest timeout from a test-harness defect, fixed here (next item). +- Windows harness defect found by that run: the interop scripts stopped servers with `kill`, then polled for the 60 s client timeout before `taskkill`. The C fixtures are built with MSYS `gcc` and link `msys-2.0.dll`; the MSYS runtime acts on a signal only when a thread re-enters it, so a server blocked in a Win32 wait never exits on SIGTERM (measured: C server alive 22 s after `kill`, Rust and Go servers gone in about 185 ms). The lookup servers never exit on their own (`tests/fixtures/c/interop_service_win.c:431,455`), so each C-server pair cost about 100 s at idle priority and the 18-pair test overran 30 min. The C client itself completes the 8192-item lookup in 111-133 ms with or without `nice`. Fixed: `stop_server` in `tests/test_service_win_interop.sh`, `tests/test_cache_win_interop.sh` and `tests/test_lookup_scale_win_interop.sh` waits a 2 s grace, then force-stops the Windows PID, and no longer ends in an unbounded `wait`. The first rerun showed the force-stop silently skipped: the scripts looked up `taskkill` on PATH, and a minimal PATH without `System32` (as in the validation wrapper) left every C server running; `taskkill` is now resolved through `SYSTEMROOT` when PATH lacks it. Rerun of every affected Windows interop suite (`test_cache_win_go`, `test_win_shm_interop`, `test_service_win_interop`, `test_service_win_shm_interop`, both lookup-scale tests, `test_cache_win_interop`, `test_cache_win_shm_interop`): 8/8 pass, lookup-scale in 68 s and 66 s, zero interop processes left running. +- Static: `cargo fmt --check`, Rust 1.91.0 build, gofmt, `go vet` (linux and `GOOS=windows`), `git diff --check` clean; clippy warnings only on lines this SOW did not change. +- Benchmarks: not needed (decision 4); see the performance assessment under Risk and blast radius. + +Real-use evidence: + +- The interop suites run real C, Rust and Go server/client processes across UDS and SHM; the crash tests kill real helper processes mid-session. + +Reviewer findings: + +- Self-review during validation found the over-broad probe (any transport error relabelled as DISCONNECTED); fixed by narrowing to the send/recv-failure class (Execution Log, correction). + +Same-failure search: + +- Windows server stop: every Windows test script was checked for `kill` without a forced fallback. `tests/test_win_shm_interop.sh` (cleanup, not-ready path, and the server wait after a client failure) and `tests/run-windows-bench.sh` (cleanup, not-ready path, forced-stop path followed by an unbounded `wait`, reachable with `NIPC_WINDOWS_TOOLCHAIN=msys`) now use a bounded force-stop helper. The other Windows scripts start no background servers. + +- Close-on-exec: every remaining `socket`/`accept`/`pipe` call without the flag (C `netipc_uds_lifecycle.c:39,51`, `netipc_service_posix_client.c:102`; Rust `posix.rs:759,780`; Go `uds_cloexec_other.go:15,24`) is the fallback for platforms without `SOCK_CLOEXEC`/`accept4`/`pipe2` and sets the flag immediately (Go under `syscall.ForkLock`). C and Rust SHM opens all use `O_CLOEXEC`; Go uses `os.OpenFile` (close-on-exec by default) and `F_DUPFD_CLOEXEC`. +- `owner_alive`: no code references remain; only the removal note in `docs/netipc-integrator-skill.md`. +- Peer-gone error mapping: all client receive/send paths (C, Rust, Go; POSIX and Windows; baseline and SHM) reviewed and mapped. + +Sensitive data gate: + +- This SOW, the docs, the integrator skill, `AGENTS.md`, the vendoring skill, the scripts and code comments contain no secrets, customer data, private endpoints or workstation paths; Netdata evidence is cited as `ktsaou/netdata @ ae9113f2cc`. `.agents/sow/audit.sh` sensitive-pattern scan: clean. + +Artifact maintenance gate: + +- `AGENTS.md`: updated with the Test Cadence section (decision 4), the clarified benchmark command line, and the benchmark item in the vendoring preflight rule. +- Runtime project skills: `project-netdata-vendoring` gains the performance-assessment step (step 7, blocker and report line), per decision 4. No vendoring happens in this SOW. +- Specs: the public specs under `docs/` were updated (level1 POSIX SHM, Windows SHM, POSIX UDS, level2 typed API). No `.agents/sow/specs/` change: the behaviour is fully captured in the public specs. +- End-user/operator docs: same `docs/` updates. +- End-user/operator skills: `docs/netipc-integrator-skill.md` updated (new error code, liveness behaviour, close-on-exec, `owner_alive` removal). +- SOW lifecycle: moved pending -> current at start; completed -> done in the closing commit. New pending SOW-0050 tracks the Netdata vendoring of this SOW and SOW-0043. + +## Outcome + +Completed. Dead peers are detected in C, Rust and Go on POSIX and Windows: SHM servers end sessions whose control channel the client closed (K1, K2), SHM clients fail a waiting call with the new disconnected error within about one 100 ms slice of the server's death instead of waiting the full timeout (N5), peer-gone failures on UDS, named pipes and SHM report that disconnected error, SHM attach stops retrying once the server closed the session, and every NetIPC socket and SHM fd is close-on-exec. `owner_alive` is removed. The Windows interop scripts now force-stop their servers, so the full Windows ctest no longer loses an hour to two timed-out tests. Remaining SOW-0043 reproduction tests fail as expected and are next. + +## Lessons Extracted + +- Map a transport failure to "peer disconnected" only for the send/recv-failure class plus a confirming probe. Probing after any error mislabels protocol violations when the peer closes right after replying. +- Go `syscall.Dup` does not set close-on-exec; use `F_DUPFD_CLOEXEC`. +- A liveness check that compares a cached value with the same shared memory it was read from can never fail; liveness must come from an independent channel (the session socket or pipe). +- `/proc/pressure/memory` measures time stalled in the allocator, not free RAM: stalls can be high with most RAM available (reclaim and compaction on fragmented memory). Before blaming a timing failure on the host, sample `/proc/vmstat` (compact_stall, allocstall) and the per-cgroup `memory.pressure` during the run; do not attribute it to another workload without evidence. +- On Windows, MSYS-built test fixtures (`msys-2.0.dll`) do not die on `kill` while blocked in a Win32 wait; scripts must force-stop the Windows PID after a short grace. A test that is "slow" may be a harness waiting out its own timeouts: time the product binary on its own before suspecting it. +- A test with no per-step output under ctest looks hung; stall detection must allow for the longest legitimate step. + +## Followup + +- SOW-0043 (handshake, timeouts, shutdown): the six POSIX and five Windows reproduction tests still failing belong to it; next. +- The SHM "peer dead" error (C `NIPC_SHM_ERR_PEER_DEAD` `netipc_shm.h:55`, Rust `ShmError::PeerDead` `shm.rs:72`, Go `ErrShmPeerDead` `shm_linux.go:77`) is not returned by any path, and was not at HEAD either (`owner_alive` returned a boolean). Removal rejected: dropping a public enum member renumbers the following C values (an ABI break) for no behavioural gain; the value stays reserved. +- Netdata vendoring: single PR after SOW-0043 (user decision 3a), through the vendoring preflight; tracked by `.agents/sow/pending/SOW-0050-20261031-netdata-vendor-robustness-fixes.md`. + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0045-20261029-windows-pipe-security.md b/.agents/sow/done/SOW-0045-20261029-windows-pipe-security.md new file mode 100644 index 00000000..8978c77f --- /dev/null +++ b/.agents/sow/done/SOW-0045-20261029-windows-pipe-security.md @@ -0,0 +1,251 @@ +# SOW-0045 - Windows Pipe Security + +## Status + +Status: completed + +Sub-state: completed 2026-10-31. + +## Requirements + +### Purpose + +Decide and enforce the Windows named-pipe access policy so that unauthorized local (or remote) principals cannot open NetIPC pipes. + +### User Request + +From SOW-0040 decisions (2026-10-29): split the fix work into separate SOWs (N1 Rust timeout; N11 default packet size; handshake/timeout bounds and shutdown; dead-peer detection) and track the Windows pipe DACL exposure as its own security SOW. + +### Assistant Understanding + +Facts: + +- Pipes are created with default security (`NULL` attributes) in C (`netipc_named_pipe.c:217`), mirrored in Rust and Go. +- Observed DACL on `costa-win11` (user-session server, all three languages): `D:(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;)(A;;FR;;;WD)(A;;FR;;;AN)` - Everyone and Anonymous have read access. +- A read-only open is enough to occupy a pipe instance and block the accept loop (observed 7.4 s client delay against the C server), so any local principal can likely cause a denial of service. Not verified with a second user. +- `PIPE_REJECT_REMOTE_CLIENTS` is not set; remote reachability over SMB is untested speculation. +- The default DACL derives from the creating token, so a LocalSystem service may differ from the user-session result. +- Analysis 2026-10-29: pipe squatting. Clients open the pipe without `SECURITY_SQOS_PRESENT` (C `netipc_named_pipe.c:903`, Rust `windows.rs:737-745`, Go `pipe_session.go:159-167`), so a server may impersonate them at full SecurityImpersonation level, and clients never verify the server's identity. A local user who creates the pipe name first (for example while the real server restarts) receives HELLO and the auth token. +- Analysis 2026-10-29: Windows SHM mappings and events are also created with NULL security attributes (C `netipc_win_shm.c:349-356`, `:409`, `:423`; Rust `win_shm.rs:405-407`, `:453`, `:474`; Go `shm.go:198-205`, `:260`, `:283`). + +- Analysis 2026-10-31, Netdata usage (`netdata/netdata @ ae9113f2cc`): + - Windows consumers: `apps.plugin` serves apps lookup (`src/collectors/apps.plugin/apps-lookup-netipc.c:318-352`), and `network-viewer.plugin` is a client (`src/collectors/network-viewer.plugin/network-viewer-apps-lookup-client.c:727-730`). + - The auth token comes from the agent invocation ID (`src/libnetdata/netipc/netipc_netdata.c:9-12`), so only children of one agent share it. + - The Windows service has no `Account` attribute (`packaging/windows/netdata.wxs.in:347-369`), so it runs as LocalSystem and its plugins inherit that account. Server and clients therefore always run as the same principal in production. +- Analysis 2026-10-31, current code: + - Servers already use `FILE_FLAG_FIRST_PIPE_INSTANCE` (C `netipc_named_pipe.c:211`, Rust `windows.rs:1407`, Go `pipe_listener.go:258`), so an attacker cannot add instances to a pipe the server already owns. Squatting needs the attacker to create the name first. + - The Windows SHM creators already reject an existing name (`ERROR_ALREADY_EXISTS`: C `netipc_win_shm.c:358,415,430`; Rust `win_shm.rs:418,462`; Go `shm.go:210,268,292`), so a pre-created mapping or event fails that session instead of being used. + - With `FR` for Everyone, an unprivileged user can only open the pipe read-only. That cannot send HELLO, so it gets no data, but it occupies an instance (the DoS observed in SOW-0040). + +Inferences: + +- In production the risk is local: any user on a Netdata Windows host can occupy pipe instances, and can squat the pipe name while the agent restarts to collect the auth token. The token only grants apps lookup data, and it changes with every agent invocation. + +Unknowns: + +- Fix design choices are user decisions to be gathered when this SOW starts. + +### Acceptance Criteria + +- Explicit pipe security descriptor (and remote-client rejection) decided by the user and implemented in C, Rust and Go. +- Verified with a second, non-privileged Windows user and with a service-context server. +- Spec `docs/level1-windows-np.md` documents the access policy. + +## Analysis + +Sources checked: + +- See SOW-0040 (`.agents/sow/done/SOW-0040-20261029-robustness-repro-tests.md`) for evidence, test files and commands. + +Current state: + +- No automated reproduction test exists yet; the DACL exposure was verified manually on `costa-win11` during SOW-0040. This SOW must add one (for example a `GetSecurityInfo` DACL assertion) before the fix. + +Risks: + +- Cross-language behavior change; interop and Netdata vendoring must be validated. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- Every Windows NetIPC object is created with `NULL` security attributes, so it takes the creator token's default DACL (Everyone and Anonymous read on the observed host), and clients trust whatever process owns the pipe name. See Facts. + +Evidence reviewed: + +- Facts above; creation and open sites: + - C: `netipc_named_pipe.c` `create_pipe_instance()` (`CreateNamedPipeW(..., NULL)`), `create_client_pipe()` (`CreateFileW(..., 0 flags)`); `netipc_win_shm.c` mapping and event creation (`NULL` attrs). + - Rust: `transport/windows.rs` `CreateNamedPipeW` (null attrs), `create_client_pipe()` (`CreateFileW`, flags 0); `transport/win_shm.rs` `CreateFileMappingW` / `CreateEventW` (null attrs). + - Go: `transport/windows/pipe.go` `CreateNamedPipeW` call, `pipe_session.go:162` `syscall.CreateFile(..., 0, 0)`, `shm.go` mapping/event calls with 0 attrs. +- Windows test host token: the ssh session is an elevated administrator (High integrity, `BUILTIN\Administrators` enabled and "Group owner"), so objects it creates are owned by Administrators; `LanmanServer` is running, so `\\127.0.0.1\pipe\...` reaches the pipe through SMB loopback. +- L2 mapping of L1 connect errors: C `netipc_service_win_client_connect.c:90-101`, Rust `service/raw/client_windows.rs:88-92`, Go equivalent. + +Affected contracts and surfaces: + +- Windows transport in C, Rust, Go: pipe creation flags and DACL, client open flags, client owner check, SHM mapping/event DACL. +- New L1 error: C `NIPC_NP_ERR_UNTRUSTED_SERVER` (appended to `nipc_np_error_t`), Rust `NpError::UntrustedServer`, Go `ErrUntrustedServer`. L2 maps it to the existing `AUTH_FAILED` client state (the client could not authenticate the server), so no L2 enum changes. +- C build: `netipc_named_pipe` links `advapi32`. Netdata's Windows build must link it too (check in SOW-0050). +- Spec: `docs/level1-windows-np.md`, `docs/level1-windows-shm.md`; integrator guidance. + +Existing patterns to reuse: + +- Test hooks per language (`*_for_tests` in C under `NIPC_INTERNAL_TESTING`, Rust `#[cfg(test)]` statics, Go unexported vars). +- Existing Windows test binaries/suites (`test_named_pipe.c`, `test_win_shm.c`, Rust `windows.rs` / `win_shm.rs` tests, Go `transport/windows` tests). + +Risk and blast radius: + +- A server and client running as different non-admin accounts can no longer talk. Netdata runs both as LocalSystem; developers run both as one user. +- The owner check rejects a pipe owned by any account other than the client's own user, SYSTEM or Administrators: a legitimate server running as a different non-admin account is refused (intended). +- `PIPE_REJECT_REMOTE_CLIENTS` blocks SMB access; no supported use needs it. +- Building the security descriptor per pipe instance and per SHM object adds a few system calls to connection setup only. + +Performance assessment: + +- Connection setup and session SHM creation only (pipe instance creation per accept, client open, SHM object creation per SHM session). No established-session request path changes; no benchmark run. + +Sensitive data handling plan: + +- SIDs in evidence are written as well-known names (`SY`, `BA`) or ``; the temporary test account is named generically and its password is generated at run time and never written to a durable artifact. Covers this SOW, specs, docs, skills and code comments. + +Implementation plan: + +1. Shared descriptor per language: SDDL `D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;)` built from the process token (`OpenProcessToken`, `GetTokenInformation(TokenUser)`, `ConvertSidToStringSidW`, `ConvertStringSecurityDescriptorToSecurityDescriptorW`), used for every pipe instance, SHM mapping and event. +2. Pipes: add `PIPE_REJECT_REMOTE_CLIENTS` to the pipe mode. +3. Clients: open with `SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION`; after the open and before HELLO, read the pipe owner (`GetSecurityInfo`, `OWNER_SECURITY_INFORMATION`) and require the process user SID, `S-1-5-18` or `S-1-5-32-544`; otherwise close and return the untrusted-server error. +4. L2 maps the untrusted-server error to `AUTH_FAILED`. +5. Tests per language (deterministic): the listener pipe DACL is exactly the three ACEs (protected, no Everyone/Anonymous); SHM mapping and events carry the same DACL; an open through `\\127.0.0.1\pipe\` is refused; the server sees only an identification-level token when it impersonates the client; a test hook that removes the client's trusted owners makes connect fail with the untrusted-server error. +6. Manual cross-account validation on the Windows test host (user decision 5): a temporary non-admin user is refused by the pipe; a squatter pipe created by that user is rejected by a client; a SYSTEM-context server (scheduled task) serves an elevated client; both accounts/tasks removed afterwards. +7. Docs. + +Validation plan: + +- Windows ctest, Rust and Go suites on the Windows test host, with mutants for each new rule; the manual cross-account run; Linux/FreeBSD builds unaffected (Windows-only files) checked by building; interop scripts for Windows (`tests/test_*_win*.sh`) as covered by the ctest run. + +Artifact impact plan: + +- AGENTS.md: no change expected. +- Runtime project skills: `project-netdata-vendoring` unchanged; SOW-0050 records the `advapi32` link check. +- Specs: `docs/level1-windows-np.md` (access policy, remote rejection, client server check, new error), `docs/level1-windows-shm.md` (object DACL). +- End-user/operator docs: same files. +- End-user/operator skills: `docs/netipc-integrator-skill.md` (same-account rule on Windows, AUTH_FAILED meaning). +- SOW lifecycle: this SOW only. + +Open-source reference evidence: + +- None needed; Windows API behavior is verified on the test host. + +Open decisions: + +- None. User decisions 1-5 are recorded below; the error mapping (new L1 error, L2 `AUTH_FAILED`) is assistant-applied as long-term-best: distinct at L1 for diagnosis, no new L2 state. + +## Implications And Decisions + +User decisions (2026-10-31, user): + +1. Pipe DACL: explicit descriptor granting full access to the server's own account, SYSTEM and Administrators only. +2. Always set `PIPE_REJECT_REMOTE_CLIENTS`. +3. Clients open with `SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION` and, before sending HELLO, verify the pipe owner is the client's own account, SYSTEM or Administrators; otherwise the connect fails. +4. The same DACL applies to Windows SHM mappings and events. +5. Validation may create, on the Windows test host, a temporary non-admin local user and a scheduled task running a test server as SYSTEM; both are removed afterwards. +- Ordering (2026-10-31, user, supersedes earlier ordering): finish all code changes first — SOW-0045, SOW-0058, SOW-0057, SOW-0049, SOW-0059, and SOW-0056 when the FreeBSD 15.1 VM arrives — then run the full benchmarks and the SOW-0050 vendoring together; benchmark regressions are fixed and re-vendored. + +## Plan + +See the gate implementation plan. + +## Execution Log + +### 2026-10-31 + +- Activated; gate filled. +- C: new `src/libnetdata/netipc/src/transport/windows/netipc_win_security.{c,h}` (owner-only security attributes, pipe owner check, test hook `nipc_np_set_trusted_owner_for_tests()`), linked with `advapi32`. `netipc_named_pipe.c` uses it for every pipe instance, adds `PIPE_REJECT_REMOTE_CLIENTS`, opens clients and the listener wake with `SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION`, and checks the owner before HELLO (`NIPC_NP_ERR_UNTRUSTED_SERVER`). `netipc_win_shm.c` creates the mapping and both events with the same descriptor. The L2 server wake (`netipc_service_win_server.c`) uses the SQOS flags too; L2 connect maps the new error to `AUTH_FAILED`. +- Rust: new `transport/win_security.rs` (own `advapi32` FFI; thread-local test hook `TRUSTED_OWNER_FOR_TESTS`, since Rust tests run in parallel threads); same changes in `transport/windows.rs`, `transport/win_shm.rs`, `service/raw/client_windows.rs` (`NpError::UntrustedServer`). +- Go: new `transport/windows/security.go` (advapi32 LazyDLL, pure Go); same changes in `pipe.go`, `pipe_listener.go`, `pipe_session.go`, `shm.go`, `service/raw/client_windows.go` (`ErrUntrustedServer`). +- Tests (per language): pipe DACL readback for the first and a later instance; SHM mapping and event DACL readback; SMB loopback open `\\127.0.0.1\pipe\...` refused; connect refused when the trusted owner is `BUILTIN\Users` (bounded by a 2000 ms connect deadline so a regression fails instead of hanging); connect accepted when the trusted owner is the real pipe owner, with the server seeing only `SecurityIdentification` when it impersonates the client; L2 client ends in `AUTH_FAILED` against an untrusted owner (C `test_win_service`, Rust `raw_windows_tests`, Go classifier test). +- MSYS gcc lacks `_snwprintf`, so the C helper builds the SDDL with narrow `snprintf` and the `A` SDDL APIs. +- The temporary test account had no batch-logon right (`SeBatchLogonRight` is Administrators, Backup Operators and Performance Log Users on the test host), and `Start-Process -Credential` from the ssh session fails with `STATUS_DLL_INIT_FAILED`. The account was therefore added to Performance Log Users (no admin rights; checked by the script) and its processes ran as scheduled tasks. + +## Validation + +Acceptance criteria evidence: + +- Explicit descriptor and remote-client rejection, decided by the user (decisions 1-4), implemented in C, Rust and Go: see Execution Log; DACL readback tests pass in all three languages. +- Verified with a second, non-privileged user and a service-context (SYSTEM) server, on the Windows test host with a scratch script (not committed: it creates accounts and hard-codes host paths). Results, 13 of 13 PASS: + - the temporary account is not an administrator; + - a Go client running as that account is refused by the C, Rust and Go servers running as the admin account (`connect failed: Access is denied.`); + - a Go server running as that account squats a pipe name; the C (`connect failed: 22` = `NIPC_NP_ERR_UNTRUSTED_SERVER`), Rust and Go (`pipe owned by an untrusted account`) clients refuse it, and the squatter logs `hello recv: peer disconnected`, so no HELLO or auth token reached it; + - C, Rust and Go servers started by a SYSTEM scheduled task serve C, Rust and Go clients of the admin account (`PASS`). +- Spec `docs/level1-windows-np.md` documents the access policy (new "Access policy" section). + +Tests or equivalent validation (Windows test host, MSYS2): + +- `win-rust-go.sh`: Rust `cargo test --lib` 271 passed; Go `go test ./...` all packages ok. +- `win-ctest-full.sh`: 20/20 ctest (C unit, robustness, interop C/Rust/Go for pipe, SHM, service and cache). +- Rust 1.91.0 (MSRV, MSVC toolchain) `cargo test --lib`: 271 passed. +- Mutation checks, each rule reverted on its own, every mutant killed: C 8/8 (pipe NULL descriptor, no `PIPE_REJECT_REMOTE_CLIENTS`, no SQOS, owner check always true, owner check removed, SHM mapping NULL descriptor, SHM events NULL descriptor, unprotected DACL); Rust 8/8 and Go 8/8 with the same mutants. A first C run hung on the two owner-check mutants (the client then sent HELLO to a listener that never accepted); the test now uses a 2000 ms connect deadline and the rerun killed them. +- Linux: `cmake --build build` clean; `cargo clippy` gate (Linux and `x86_64-pc-windows-gnu`) no correctness/suspicious findings; `cargo fmt --check` with stable and 1.91.0; `go vet ./...` (Linux and `GOOS=windows`) clean except the pre-existing `possible misuse of unsafe.Pointer` notes in `shm.go`/`shm_test.go`; `gofmt -l` clean. +- Timing sleeps added: none in correctness tests. The 2000 ms connect deadline is a bound on the failure path only; the passing path returns immediately. + +Real-use evidence: + +- The cross-account run above uses the real interop fixtures as separate processes under real accounts. + +Reviewer findings: + +- No external review was requested for this SOW. + +Same-failure search: + +- `CreateNamedPipe*`, `CreateFile*`, `CreateFileMapping*`, `CreateEvent*`, `CreateMutex*`, `CreateSemaphore*` across `src/libnetdata`, `src/crates/netipc/src` and `src/go/pkg`: every named object now takes the owner-only descriptor. The remaining `CreateEventW(NULL, ..., NULL)` calls (`netipc_service_win_client.c:75`, `netipc_service_win.c:147`) are unnamed, process-private events. The C L2 server wake (`netipc_service_win_server.c:32`) was the one remaining pipe open without SQOS flags and was fixed. + +Performance assessment: + +- Connection setup and SHM session creation only: `create_pipe_instance()` per accept, the owner check per connect, descriptor creation per SHM session (C `netipc_named_pipe.c:207-230`, `netipc_win_shm.c` `create_secure_mapping`/`create_secure_event`; the Rust and Go equivalents). No established-session request path changed; no benchmark run. + +Sensitive data gate: + +- The SOW, docs and comments name SIDs only as well-known aliases or ``; the test account password was generated in memory and never written; host and user names are by role. The local account SID printed in test logs was not copied into any artifact. + +Artifact maintenance gate: + +- AGENTS.md: no change; no workflow or command changed. +- Runtime project skills: `project-netdata-vendoring` unchanged; the vendoring consequence (new source file for Netdata's `SourceLists.cmake`) is recorded in SOW-0050. +- Specs: `docs/level1-windows-np.md` (Access policy), `docs/level1-windows-shm.md` (mapping and event DACL). No `.agents/sow/specs/` change: the public spec holds the rule. +- End-user/operator docs: the same two docs. +- End-user/operator skills: `docs/netipc-integrator-skill.md` (Windows same-account rule; `AUTH_FAILED` meaning). +- SOW lifecycle: this SOW completed and moved to `done/`; SOW-0050 updated. +- Build/test scripts: `tests/run-coverage-c-windows.sh` lists the new C source. + +Spec update: done (see above). + +Project skill update: none needed; no change in how work is done here. + +End-user/operator docs and skills: updated (see above). + +Lessons: see below. + +Follow-up mapping: + +- Netdata `SourceLists.cmake` entry for `netipc_win_security.c`: tracked in SOW-0050. +- Orphan profile folders of the temporary account on the Windows test host: the account was deleted, but Windows kept its two profile hives loaded after the scheduled tasks ended, so `Remove-CimInstance` was refused, and no process ran under those accounts. This is test-host housekeeping, not product work: the two `C:\Users\` profile folders of the deleted account are removed once Windows unloads them (or after the host's next reboot, which needs the user's approval); the user was told. +- No deferred work otherwise. + +## Outcome + +Windows pipes, SHM mappings and events now grant access only to SYSTEM, Administrators and the creating account, refuse remote clients, and clients refuse to send HELLO to a pipe owned by any other account; servers can only identify clients. All three languages, verified by unit tests, mutants and a cross-account run. + +## Lessons Extracted + +- Restoring a mutated source with `cp`/`mv` can leave it older than the object built from the mutant, so ninja keeps the mutant: a full ctest after the C mutant run failed on that stale object. Mutation scripts must `touch` the restored file (the Rust/Go script did). +- On the Windows test host, processes for another account cannot be started from the ssh session with `Start-Process -Credential`; scheduled tasks need the account to have batch-logon rights. +- A negative connect test against a listener that never accepts must carry a deadline, or the regression it guards against hangs the suite. + +## Followup + +- Netdata source list: SOW-0050. + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0046-20261029-go-cross-platform-build.md b/.agents/sow/done/SOW-0046-20261029-go-cross-platform-build.md new file mode 100644 index 00000000..8abb6b5a --- /dev/null +++ b/.agents/sow/done/SOW-0046-20261029-go-cross-platform-build.md @@ -0,0 +1,341 @@ +# SOW-0046 - Go Cross-Platform Build + +## Status + +Status: completed + +Sub-state: reopened 2026-10-29 because the first close parked C/Rust 32-bit coverage in SOW-0037 (the user ruled an untestable Netdata target a blocker); completed 2026-10-30 with C, Rust and Go suites and interop executed on every Netdata Linux target. See `## Reopened - 2026-10-29` and `## Validation (reopened scope)`. + +Correction (2026-11-07, SOW-0070): the Go suites ran once per architecture, on the glibc targets and on armv6-musl; the other four musl targets skip Go by design (pure Go does not link libc), as the validation table shows. The CI matrix had not run on GitHub at close; it has run since, in the Runtime Safety `linux-targets` job. + +## Requirements + +### Purpose + +The pure-Go POSIX transport and service packages must build on every platform the docs claim and Netdata ships: Linux on all its architectures (including arm64 and 32-bit), FreeBSD and macOS. + +### User Request + +2026-10-29: "there are no known bugs. All bugs must be fixed now. Zero known bug policy." and "I want one issue at a time, with proper explanation, risks, proposed solutions, risk of the solutions recommended and explicit recommendations with reasoning." + +### Assistant Understanding + +Facts: + +- `syscall.SYS_POLL` does not exist on linux/arm64 (and other newer architectures such as riscv64 and loong64, which only have `ppoll`), but is used by `src/go/pkg/netipc/transport/posix/uds_receive.go:91` and `src/go/pkg/netipc/service/raw/poll_unix.go:38`. +- Verified 2026-10-29: `GOOS=linux GOARCH=arm64 go vet ./...` in `src/go` fails with `undefined: syscall.SYS_POLL`. +- Found 2026-10-29 during SOW-0042 validation (`GOOS= GOARCH= go build ./...` in `src/go`): the Go POSIX transport builds only on linux amd64, ppc64le and s390x. Failures: + - linux/arm64, linux/riscv64: `undefined: syscall.SYS_POLL` (`uds_receive.go:91`, `service/raw/poll_unix.go:38`). + - linux/386, linux/arm: `cannot use iovlen (variable of type uint64) as uint32 value` (`uds.go:170`, `syscall.Msghdr.Iovlen` width differs); linux/386 also `undefined: syscall.SYS_SENDMSG` (socketcall architecture). + - freebsd/amd64: `Iovlen` is `int32` (`uds.go:170`). + - darwin/arm64: `Iovlen` is `int32` and `undefined: syscall.MSG_NOSIGNAL` (`uds.go:177`; macOS uses `SO_NOSIGPIPE`). +- The code dates from 2026-03 (`git blame uds.go:165-172` -> 5372e97f1); `docs/level1-transport.md:463`, `:499` claim macOS/FreeBSD support. +- No CI job cross-builds Go for other architectures or operating systems, so nothing catches it. +- 2026-10-29: Rust library `cargo check --lib` passes for aarch64-unknown-linux-gnu and armv7-unknown-linux-gnueabihf. No local 32-bit C toolchain exists (no multilib, no ARM cross gcc). +- Found 2026-10-29 while running the Go suite on 32-bit (zero-known-bug policy, fixed here): + - `transport/posix/shm_linux.go` `ShmReceive` checked `int(mlen) <= maxCopy`; on 32-bit `int(0xFFFFFFFF)` is -1, so a peer's forged length passed and `buf[:mlen]` panicked (`TestShmChaosForgedLength` on 386 and arm). A client could crash a 32-bit Go server. + - `ShmClientAttach` summed offsets and capacities as `uint32` and `int`; on 32-bit a forged header with offsets near 4 GiB passed validation (attach returned no error). + - The same response-end wrap exists in C (`netipc_shm.c`, 32-bit `size_t`) and Rust (`shm.rs`, 32-bit `usize`); request end was already guarded there. Windows C and Go attach are guarded; Windows Go receive rejects negative `int32` lengths and is not affected. + - `TestShmSendBadParamGuards` built `ShmContext.data` from small byte slices that may be stack-allocated and unaligned; atomics on them raise SIGBUS on arm64 (test-only defect; production data is a page-aligned mmap). + +Inferences: + +- Downstream Netdata consumers may be affected; not yet verified against Netdata usage. + +Unknowns: + +- Fix design choices are user decisions, presented one issue at a time. + +### Acceptance Criteria + +- `go build ./...` and `go vet ./...` succeed for linux amd64/386/arm/arm64/riscv64/ppc64le/s390x, freebsd/amd64 and darwin/arm64 (final list decided with the user); Go stays pure (no cgo). +- CI guards the cross-architecture build. + +## Analysis + +Sources checked: + +- Read-only analysis round recorded in SOW-0041 (`.agents/sow/done/SOW-0041-20261029-rust-receive-timeout-slice.md`). + +Current state: + +- Fixed by this SOW; see Validation. + +Risks: + +- Replacing `poll` with `ppoll` changes timeout encoding (timespec instead of milliseconds); errors there would affect every Go receive and SHM server wait. + +## Pre-Implementation Gate + +Status: filled 2026-10-29 + +Problem / root-cause model: + +- The Go POSIX transport calls raw syscalls through `syscall.Syscall` with linux/amd64 assumptions: `SYS_POLL` (absent on arm64, riscv64, loong64), `Msghdr.Iovlen` assigned from a `uint64` variable (the field is `uint32` on 386/arm, `int32` on BSD/darwin), and `SYS_SENDMSG` (absent on linux/386, which multiplexes socket calls through `socketcall(2)`). +- 32-bit test files use untyped constants above `MaxInt32` (`1<<31`, `uint64(^uint32(0))+1`) that fail to compile even where the test skips at runtime. + +Evidence reviewed: + +- `GOARCH= go vet ./...` failures listed under Facts. +- Go standard library `syscall` tables: linux/386 sends through `SYS_SOCKETCALL` with call number 16 (`syscall_linux_386.go`); `SYS_PPOLL` exists on every Linux architecture. +- Kernel `ppoll(2)`: a NULL sigmask ignores `sigsetsize`; a NULL timespec waits indefinitely. On 32-bit architectures the legacy `ppoll` takes the 32-bit `struct timespec`, which matches `syscall.Timespec` there; relative timeouts of at most a few seconds are not affected by the 2038 limit. + +Affected contracts and surfaces: + +- Go only: `transport/posix` send and receive paths, `service/raw` SHM/UDS readiness polling. No wire format, API or behavior change; C and Rust are untouched (Rust library `cargo check` already passes on aarch64/armv7). + +Existing patterns to reuse: + +- Build-tagged per-platform files already used in the Go packages (`*_unix.go`, `*_windows.go`). +- A small internal package for shared syscall helpers, so `transport/posix` and `service/raw` share one poll implementation instead of two copies. + +Risk and blast radius: + +- Every Go UDS receive and SHM server wait goes through the new `syspoll.Poll`; an encoding error in the timespec would break all Go receives. Mitigated by the full Go test suite on amd64 and 386 plus interop. +- The 386 socketcall path is new code used only on linux/386; covered by running the full Go test suite natively as 386. + +Sensitive data handling plan: + +- No secrets, customer data or private endpoints involved; evidence is code paths, command names and test output only. Applies to this SOW, specs, docs, skills and code comments. + +Implementation plan: + +1. New `internal/syspoll` package: `PollFd` plus `Poll` using `SYS_PPOLL` on Linux and `SYS_POLL` on other unix systems. +2. `transport/posix/uds_receive.go` and `service/raw/poll_unix.go` use `syspoll`. +3. `uds.go` sets `Iovlen` from untyped constants and calls `rawSendmsg`, implemented in `uds_sendmsg.go` (direct `SYS_SENDMSG`) and `uds_sendmsg_linux_386.go` (socketcall), per decision 1A. +4. Make 32-bit test constants non-constant expressions. +5. CI: cross-architecture Go vet plus Go tests on 386 and, under qemu, arm64/arm. + +Validation plan: + +- `go vet ./...` (library and tests) for linux amd64, 386, arm, arm64, riscv64, loong64, ppc64le, s390x, mips64le. +- Full Go test suite on amd64 and natively on 386; on arm64 and arm under qemu user emulation. Only the known open reproduction tests (SOW-0043/0044) may fail. +- `tests/test_uds_interop.sh` and `tests/test_service_interop.sh` with the standard build, plus a 386 Go fixture against C/Rust. +- `gofmt`, staticcheck and gosec-relevant annotations kept consistent. + +Artifact impact plan: + +- AGENTS.md: assessed at close; no workflow change expected. +- Runtime project skills: none affected (vendoring skill unaffected by internal Go file layout). +- Specs: `docs/` describe protocol and transport behavior, not the syscall used; Linux architecture coverage noted where the docs list platforms. +- End-user/operator docs: same as specs. +- End-user/operator skills: `docs/netipc-integrator-skill.md` assessed at close. +- SOW lifecycle: the Go part of SOW-0037 is delivered here and noted there; FreeBSD and macOS need their own SOWs. + +Open-source reference evidence: + +- `golang/go` standard library `src/syscall/syscall_linux_386.go` (socketcall numbers); cited by file, no local mirror used. + +Open decisions: + +- None for this SOW. FreeBSD and macOS design questions belong to their own SOWs. + +## Implications And Decisions + +1. 2026-10-29, platform scope: the user stated NetIPC must build and run on every platform Netdata builds for - Linux on all architectures, Windows, FreeBSD and macOS ("netipc should build and run on all these platforms"). An earlier assistant recommendation to narrow support to Linux + Windows was wrong: it inferred product scope from Netdata's current CMake gates (`src/libnetdata/CMakeLists.txt:89-118`, `NetdataPluginApps.cmake:33-48`, `NetdataOptions.cmake:102`, `rust-tests.yml:86` excluding netipc), which exist only because NetIPC does not build elsewhere. The work is split: this SOW covers Linux on all architectures; FreeBSD and macOS get their own SOWs, each presented to the user separately. +2. 2026-10-29, linux/386 send path (decision 1A): on linux/386 only, send through `SYS_SOCKETCALL` (sendmsg = 16); keep the no-copy raw `sendmsg` on every other platform. +3. 2026-10-29, scope extension without a design fork: the 32-bit SHM bounds bugs found by this SOW's validation are fixed here under the zero-known-bug policy and the cross-language parity rule (same contract in C, Rust and Go). The fixes only widen arithmetic; behavior on 64-bit is unchanged. Executing the C/Rust suites on 32-bit stays in SOW-0037, which owns that acceptance criterion. + +## Plan + +1. `internal/syspoll` (ppoll on Linux, poll elsewhere) used by `transport/posix` and `service/raw`. +2. `Iovlen` from untyped constants; `rawSendmsg` split per decision 1A. +3. 32-bit-safe test constants; aligned test SHM buffers. +4. 32-bit-safe SHM receive and attach bounds in Go; matching attach guard in C and Rust; regression tests in all three. +5. Runtime Safety CI job; docs and integrator skill; follow-up SOWs. + +## Execution Log + +### 2026-10-29 + +- Implemented plan items 1-5. Files: `src/go/pkg/netipc/internal/syspoll/*`, `transport/posix/{uds.go,uds_receive.go,uds_sendmsg.go,uds_sendmsg_linux_386.go,shm_linux.go}`, `service/raw/poll_unix.go`, Go tests (`uds_more_edge_test.go`, `shm_more_edge_test.go`, `cache_test.go`, `lookup_common_test.go`), `netipc_shm.c`, `tests/fixtures/c/test_shm.c`, `shm.rs`, `shm_tests.rs`, `.github/workflows/runtime-safety.yml`, `docs/level1-posix-shm.md`, `docs/netipc-integrator-skill.md`. +- Created SOW-0048 (FreeBSD) and SOW-0049 (macOS); noted SOW-0046 delivery in SOW-0037 and the SOW-0031/SOW-0048 overlap. + +## Validation + +Acceptance criteria evidence: + +- `GOARCH= go vet ./...` (library and tests) passes for amd64, 386, arm, arm64, riscv64, loong64, ppc64le, s390x, mips64le. Go stays pure: stdlib `syscall` only, no cgo. +- CI: new `linux-architectures` job in `.github/workflows/runtime-safety.yml` (actionlint clean). Not yet observed running on GitHub; the binfmt check step fails loudly if QEMU handlers are missing. + +Tests: + +- Full Go suite (`go test ./...`, low priority) on amd64 and native 386 (~170 s each), and on arm64 and arm under qemu user emulation (~177 s each): every package passes except the same 9 open reproduction tests on every architecture (`TestRobust*` for SOW-0043/SOW-0044). The 386/arm-only `TestShmChaosForgedLength` panic and the arm64 SIGBUS are gone. +- Proof of the attach bug: the new `request-end-wraps-uint32` and `response-end-wraps-uint32` cases fail on 386 against the previous `shm_linux.go` (`attach error = `) and pass after the fix; they pass on amd64 both before and after, as expected. +- C: `build/bin/test_shm` 131 passed, 0 failed, including `test_shm_response_end_wraps_uint32`. +- Rust: `cargo test --lib test_client_attach` 9 passed, including `test_client_attach_rejects_response_end_wrapping_u32`; `cargo fmt --check` clean; `cargo check --lib` clean for aarch64 and armv7. + +Real-use evidence: + +- Interop with Go fixtures built as 386 (native) and arm64 (qemu) against native C and Rust: `tests/test_uds_interop.sh` 18/18, `tests/test_shm_interop.sh` 9/9, `tests/test_service_interop.sh` 18/18 (including the 300 ms handler round) for both. + +Limits: + +- C and Rust fixes are not executed on a 32-bit target locally: no 32-bit C toolchain, the ARM musl Rust target is not installed, and a root-owned unreadable cargo registry file (`fnv-1.0.7/lib.rs`, a proptest dependency) blocks compiling Rust test targets for new targets on this workstation. Tracked in SOW-0037 at the time; resolved in the reopened scope (see `## Validation (reopened scope)`). +- Local staticcheck is older than the Go 1.27 toolchain (export data error); CI runs staticcheck with its own toolchain. + +Reviewer findings: + +- No external review was requested for this SOW; self-review corrected an initial false claim that Windows Go receive shared the `int(mlen)` defect (it rejects negative `int32` lengths), and removed a redundant `respEnd > MaxUint32` clause to keep parity with C and Rust. + +Same-failure search: + +- `int()` comparisons across Go non-test code: framing (`receive.go`) is bounded by `totalMessageLen` against `maxInt`; Windows SHM attach has explicit overflow guards; only POSIX SHM receive and attach were affected. +- C/Rust attach: POSIX response end unguarded (fixed); request end already guarded; Windows C guarded for both. +- Test SHM buffers: all nine hand-built `ShmContext.data` slices now use `alignedShmData`; other atomic-helper tests hit the bounds check before any atomic access. + +Artifact maintenance gate: + +- AGENTS.md: no change; commands and workflow unchanged (the new CI job uses existing scripts' conventions). +- Runtime project skills: no change; the vendoring skill is unaffected by internal Go files. +- Specs: `docs/level1-posix-shm.md` client attach step 6 now states the header validation and overflow-safe arithmetic that all implementations perform. +- End-user/operator docs: `docs/level1-transport.md` still claims FreeBSD/macOS UDS support; that discrepancy is owned by SOW-0048/SOW-0049, not changed here. +- End-user/operator skills: `docs/netipc-integrator-skill.md` lists the Linux architectures CI covers. +- SOW lifecycle: status `completed`, moved to `done/` in the same commit as the work. + +Lessons extracted: see below. + +Follow-up mapping: see Followup. + +## Outcome + +Go builds and runs on Linux amd64, 386, arm and arm64 (tested) and vets on nine Linux architectures. Four 32-bit memory-safety and robustness bugs were fixed (Go SHM receive forged-length panic; Go, C and Rust SHM attach overflow), and CI now guards Linux architectures for Go and the Rust library. + +## Lessons Extracted + +- Running the suite on 32-bit and on a strict-alignment architecture found real bugs that amd64 tests cannot: `int(uint32)` wraps on 32-bit, and unaligned atomics only fault off x86. Cross-architecture test execution, not just builds, belongs in CI. +- Treat every `int(x)` of a peer-controlled `uint32` as suspect on 32-bit; compare in the unsigned domain or in 64-bit. + +## Followup + +- SOW-0048: FreeBSD support (C/Rust/Go build gating, packet sizing). +- SOW-0049: macOS support (needs a baseline transport design). +- SOW-0037: merged into this SOW and closed (its scope is delivered in the reopened scope). +- The 9 open reproduction tests remain owned by SOW-0043 and SOW-0044; until those land, `go test ./...` in CI (existing `go-static` job and the new job) stays red by design of the zero-known-bug policy. + +## Regression Log + +None yet. + +## Reopened - 2026-10-29 + +Why reopened: + +- The closed SOW verified the C/Rust attach fix on 64-bit only and parked 32-bit execution in SOW-0037. User, 2026-10-29: "A NETDATA SUPPORTED CONFIGURATION/ARCHITECTURE/OS WHICH WE CANNOT TEST IS A BLOCKER. IT IS NOT A NOTE." +- Netdata-supported Linux targets (netdata/netdata `.github/data/distros.yml`, `packaging/makeself/build-static.sh`): x86_64, i386 (Debian 12 packages), armv7/armhf, armv6l (static), aarch64; glibc and musl (Alpine 3.22/3.23 Core, all static builds). + +Done so far (uncommitted): + +- Workstation cross toolchains documented in the host docs (versioned `-14` cross gcc names, no 32-bit x86 C, `gcc-multilib` conflict); cargo registry ownership fixed with user approval. +- C suite cross-built for armhf (glibc, time64/LFS) and aarch64, run under QEMU; Rust suite on armv7 and aarch64 under QEMU; Go armv6 under `QEMU_CPU=arm1176`. +- 32-bit proof of the attach fix: C (`test_shm` old code `FAIL: wrapping response end rejected`, fixed passes) and Rust (old code panics at `shm.rs:448`, fixed passes) on 32-bit ARM. +- Test-harness bugs found on these targets and fixed: `fallocate` link wrapper missed `fallocate64` on glibc LFS builds (9 `test_shm` + 1 `test_service` false failures); Rust fd-reuse races (`test_poll_fd_invalid_fd_returns_error`, `test_accept_on_closed_listener_returns_accept_error`, which cascaded into `test_pipeline_10` ENOTSOCK and `test_stress_rapid_connect_disconnect`); Rust listener readiness race (socket file exists at bind, before listen) in four test helpers plus the raw hello-ack server; follow-up-subcall timeout tests with 30/75 ms budgets widened in C, Rust and Go (POSIX and Windows). +- `test_shm_timeout` hung once (25 s) in the first armhf full run and never again in 242 runs; fixture now unbuffers stdout and bounds the forked child so a recurrence reports evidence. + +Results (only the known SOW-0043/0044 reproduction tests fail): + +| Target | C | Rust | Go | +|---|---|---|---| +| x86_64 glibc | 7 known | 9 known | 9 known | +| armhf glibc (QEMU) | 7 known | 9 known (x3) | 9 known | +| aarch64 glibc (QEMU) | 7 known | 9 known | 9 known | +| i386 | no toolchain | no toolchain | 9 known | +| armv6l (QEMU arm1176) | no toolchain | no toolchain | 9 known | +| musl (any arch) | no toolchain | no target | libc-independent | + +User decisions (2026-10-29, recorded before implementation): + +1. Toolchains (A, long-term-best): install on the workstation the apt packages `gcc-i686-linux-gnu`, `libc6-dev-i386-cross` and `musl-tools` (dry run: no removals; never `gcc-multilib`, which removes the ARM cross compilers), rustup targets `i686-unknown-linux-gnu`, `x86_64-unknown-linux-musl`, `i686-unknown-linux-musl`, `armv7-unknown-linux-musleabihf`, `arm-unknown-linux-musleabihf`, `aarch64-unknown-linux-musl`, and the latest stable Zig as a user-level tarball for musl C on i386/armv6/armv7/aarch64. +2. CI (A, long-term-best): a CI matrix running the full C, Rust and Go suites on every Netdata Linux target (x86_64, i386, armv6l, armv7, aarch64) for glibc and musl. +3. SOW scope (A, long-term-best): SOW-0037 is merged into this SOW and closed as merged; its remaining scope (Rust ARM musl `pthread_t` test compile, C/Rust suites and interop on 32-bit) is delivered here. +4. Product race (A, long-term-best): the bind-to-listen window treated as stale by stale-socket recovery is tracked in SOW-0043 (server lifecycle) with a reproduction test first, then the fix. + +Reopened-scope gate addendum (2026-10-29): + +- Problem model: C and Rust (and Go on ARMv6) were never executed on the 32-bit, ARMv6 and musl targets Netdata ships, so target-specific product and test-harness defects were invisible. +- Netdata 32-bit glibc layout: Netdata's build sets neither `_TIME_BITS` nor `_FILE_OFFSET_BITS` (netdata/netdata @ 57e634eb4f `CMakeLists.txt`, no matches), so Debian 12 i386/armhf packages use the legacy 32-bit `time_t`/`off_t` layout, while Debian 13/Ubuntu armhf compilers default to time64 (`arm-linux-gnueabihf-gcc -dM -E` defines `_TIME_BITS 64`). Both layouts are therefore tested for armv7 glibc; i386 glibc (Debian 12 only) is legacy. +- Target list: x86_64-musl, i386-gnu (legacy), i386-musl, armv7-gnu (legacy), armv7-gnu-time64, armv7-musl, armv6-musl, aarch64-gnu, aarch64-musl; x86_64 glibc is the existing host CI. +- Implementation: one shared runner `tests/run-linux-target.sh` used locally and by a `linux-targets` CI matrix; static linking so no target sysroot is needed at run time; QEMU pinned to each target's baseline CPU (arm1176, cortex-a7, cortex-a53) so newer instructions trap; Zig 0.17.0 (SHA-256 pinned in CI) for musl C. +- Risk: CI cost (9 parallel jobs of about 4-7 minutes); emulator timing makes tight timing tests flaky, so timing-dependent tests found flaky are fixed to wait on conditions, never gated. +- Sensitive data: only target names, file paths, command names and test results are recorded; no host inventories or private endpoints in SOWs, docs, skills or comments. + +Findings and fixes (reopened scope): + +- Product bug: the Rust library did not compile for 64-bit musl (x86_64/aarch64, Netdata static builds): `posix.rs` built `libc::msghdr` with a struct literal, but musl's 64-bit `msghdr` has private padding fields. Fixed by zero-initializing and assigning fields; clean on all 9 targets and on MSRV 1.91. +- Test bug (SOW-0037 scope): `raw_unix_tests.rs` moved a musl `pthread_t` (a raw pointer, not `Send`) into a thread; now carried as an integer. +- Test bug, all languages: SHM dead-owner tests forged owner PID 99999 as "dead"; hosts with a large `pid_max` (here 4194304) reuse it, and FreeBSD's PID range ends at 99999. Replaced by `INT32_MAX`, above every OS PID limit (C `test_shm.c`, Rust `shm_tests.rs`, Go `shm_linux_test.go`). (The armv7-gnu `test_shm` "stale file cleaned up" failure first attributed to this had a different cause; see the LFS product bug below.) +- Test bug, Rust and Go: the worker-capacity test assumed the first session's worker slot frees within a fixed 100 ms; under QEMU the server still dropped the verifier at capacity (`verification call: Truncated` on aarch64-gnu and armv7-gnu-time64). Now retries within a 5 s deadline. The C equivalent (`test_multi_server`) already passes. +- Test bug, C: `test_stress` long-run memory check took its RSS baseline before starting client threads, so thread and, under QEMU, emulator warm-up counted as growth (10.5 MB on arm1176, 9.2 MB on cortex-a7, 1.5 MB for the same armv6 binary on QEMU `max`). Baseline now taken after a 10 s warm-up; steady-state growth over 50 s is -52 kB (arm1176), -44 kB (cortex-a7), -12 kB (host native). +- Product bug, C only: on 32-bit glibc builds without large-file support (Netdata's Debian 12 i386/armhf packages), `readdir()` and `fstat()`/`fstatat()` fail with `EOVERFLOW` when a directory offset or inode number exceeds 32 bits, so SHM stale cleanup silently skipped the run directory (`netipc_shm.c` `nipc_shm_cleanup_stale`: a NULL from `readdir()` looks like end of directory) and stat failures classified files as live. Reproduced deterministically on armv7-gnu under qemu-user (guest trace: `getdents64(...) = 112` then `close`, no entry inspected); on real systems it affects 32-bit userlands on 64-bit kernels with filesystems that hand out 64-bit inode numbers (XFS inode64, btrfs, tmpfs inode64). Rust (std `readdir64`) and Go (`getdents64`) were unaffected. Fix: `_FILE_OFFSET_BITS 64` defined at the top of `netipc_shm.c` and `netipc_uds_lifecycle.c`; no shared header exposes `off_t`, `struct stat`, `ino_t` or `DIR`, `time_t` is unaffected, and Netdata's build has no unity/precompiled-header mode, so the per-file define is ABI-neutral and travels with the vendored source. The test `fallocate` wrapper now defines `__wrap_fallocate64` on every glibc build. Assistant judged the option set had one sound choice; user confirmed (2026-10-30). +- Test bug, Rust and Go: the worker-capacity test deadlocked forever when an assertion failed before the blocked handler was released (Rust: unwinding dropped `TestServer`, whose stop joins a handler spinning on `release`; Go: `defer releaseOnce()` was registered before `defer ts.stop()`, so stop ran first). Observed as an 8-hour hang of the armv7-gnu-time64 Rust suite, diagnosed from the futex join chain of the hung threads. Fixed with a release guard dropped before the server (Rust) and reordered defers (Go); the Rust 500 ms "handler entered" wait is now a 5 s deadline. Same-pattern search: the other blocking test handlers are bounded (`recv_timeout`, or the release is unconditional). +- Test bug, harness: the CTest wrapper `test_shm_rust` (`cargo test shm -- --test-threads=1`) also selects the SHM robustness reproduction tests added in 526db47; run serially the set takes about 34 s against a 30 s CTest timeout, so the wrapper reported `Timeout` and hid the results of the other 69 SHM tests. Timeout raised to 120 s (as for `test_service_rust`, which also carries robustness tests); the wrapper now reports 69 passed and only the 3 known SHM robustness failures. +- Environment: a host memory-reclaim storm (memory pressure "some" 60-88%, system CPU 70-98%, per the host's Netdata, 21:53-22:18 UTC on 2026-10-29) froze emulated test processes for about 85 s at a time and produced a burst of timing failures on armv7-gnu; those results were discarded and the matrix rerun. +- Observation moved to SOW-0043: peer disconnect during a call is reported as `TRUNCATED` in all three languages, which misclassifies over-capacity disconnects. +- Harness lesson: C fixtures use fixed `/tmp` paths, so two targets must never run in parallel on one host (recorded in `AGENTS.md`). + +## Validation (reopened scope) + +Acceptance criteria evidence (2026-10-30, `bash tests/run-linux-target.sh `, all parts, final tree): + +| Target | C | Rust | Go | Interop | +|---|---|---|---|---| +| i386-gnu (legacy) | 7 known (`test_robustness`) | 9 known | 9 known | 6/6 | +| i386-musl | 7 known | 9 known | not run (Go is libc-independent; covered by i386-gnu) | 6/6 | +| x86_64-musl | 7 known | 9 known | not run (as above) | 6/6 | +| armv7-gnu (legacy) | 7 known | 9 known | 9 known | 6/6 | +| armv7-gnu-time64 | 7 known | 9 known | 9 known | 6/6 | +| armv7-musl | 7 known | 9 known | not run (as above) | 6/6 | +| armv6-musl | 7 known | 9 known | 9 known | 6/6 | +| aarch64-gnu | 7 known | 9 known | 9 known | 6/6 | +| aarch64-musl | 7 known | 9 known | not run (as above) | 6/6 | + +"Known" means only the SOW-0043/SOW-0044 robustness reproduction tests fail. The full matrix ran serially with host memory pressure sampled every 10 s; three timing failures (armv7-musl interop "server not ready", aarch64-musl `test_stress_5000_items` and interop, armv6-musl apps-lookup facade timeout) each fell inside a measured memory-reclaim window (peaks 62-70%) and passed when those parts were rerun on a calm host (no pressure above 10%). + +Tests: + +- Host x86_64 glibc: C CTest (all but Windows-only and fuzz) only `test_robustness` and the Rust wrapper `test_service_rust` (9 known) fail after the `test_shm_rust` timeout fix (69 passed, 3 known); `cargo test --lib` 389 passed, 9 known; `go test ./...` only `TestRobust*`. +- Windows (costa-win11, MSYS): `test_win_service` and `test_win_service_extra` pass, `test_win_robustness` known; Rust `cargo test --lib` 266 passed, 7 failures all Windows robustness reproduction tests; Go every package passes except `TestWinRobust*` in `service/raw`. +- Rust MSRV 1.91 and latest stable build on all 9 targets; `cargo fmt --check` clean; clippy adds no warnings on changed lines; `gofmt`/`go vet` clean; `shellcheck tests/run-linux-target.sh` and `actionlint` clean; `git diff --check` clean. +- `test_stress` memory check, armv6 arm1176 / armv7 cortex-a7 / host: -52 kB / -44 kB / -12 kB over 50 s after warm-up. + +Real-use evidence: + +- Cross-language interop (UDS, SHM, service, service SHM, cache, cache SHM) between C, Rust and Go binaries built for each target passes on all 9 targets. +- LFS fix proof: armv7-gnu `test_shm` "stale file cleaned up" failed 3/3 before (guest trace: `getdents64` returned entries, then `close` with none inspected) and passes 131/131 after. + +Reviewer findings: + +- No external review requested. Self-review corrected the earlier attribution of the armv7-gnu stale-file failure to PID reuse (the real cause was the LFS bug) and found the `test_shm_rust` timeout masking. + +Same-failure search: + +- Non-LFS directory/stat use: only `netipc_shm.c` and `netipc_uds_lifecycle.c` in library C code use `opendir`/`readdir`/`stat`; both now define `_FILE_OFFSET_BITS 64`. Rust std uses `readdir64`, Go uses `getdents64`. No shared header exposes `off_t`, `struct stat`, `ino_t` or `DIR`. +- Blocked-handler deadlocks in tests: the other handlers that block (`test_client_abort_unblocks_call` in Rust Unix/Windows, Go `TestUnixClientAbortUnblocksCall`/`TestWinClientAbortUnblocksCall`, `TestRobustClientBoundedWhenServerNeverSendsHelloAck`) are bounded by `recv_timeout` or release unconditionally; only the worker-capacity tests deadlocked. +- CTest wrappers whose name filter selects robustness tests: `test_shm_rust` (fixed) and `test_service_rust` (120 s, sufficient); `test_uds_rust` finishes in about 3 s. +- Fixed "dead" PIDs: all 99999 uses in C, Rust and Go SHM tests replaced. + +Artifact maintenance gate: + +- AGENTS.md: added the `tests/run-linux-target.sh` command and the rule never to run two targets in parallel (fixed `/tmp` fixture paths). +- Runtime project skills: unchanged; the vendoring skill's preflight is unaffected (no vendoring in this SOW). +- Specs: `docs/level1-posix-shm.md` stale-scan section now requires 64-bit inode/offset handling on 32-bit; validation sentence updated for the target matrix. +- End-user/operator docs: no README or operator guide describes target coverage or 32-bit build flags; the public spec and integrator skill carry these. +- End-user/operator skills: `docs/netipc-integrator-skill.md` lists the target matrix, the local command, and that the C sources enable LFS themselves. +- SOW lifecycle: SOW-0037 merged and closed in `done/`; bind/listen stale race and `TRUNCATED` misclassification recorded in SOW-0043; this SOW `completed` in `done/`. + +Lessons extracted (reopened scope): + +- qemu-user exposes host 64-bit directory offsets to 32-bit guests, which makes a latent LFS bug deterministic; native i386 on a 64-bit kernel hides it (compat syscalls return 32-bit hashes). Keep both emulated and native 32-bit targets. +- A test that blocks a server handler must release it on every exit path before the server is stopped, or a failed assertion becomes a hang (Rust drop order, Go defer order). +- On a shared workstation, emulated timing results are only meaningful alongside a memory/CPU pressure log; rerun pressure-correlated failures on a calm host before classifying them. +- A wrapper timeout shorter than its selected tests hides every result in the run; size wrapper timeouts for the tests their filter selects. + +Follow-up mapping (pre-close search for defer/later/follow-up/future/TODO/pending): + +- Robustness reproduction failures (C 7, Rust 9, Go 9; Windows equivalents): tracked in SOW-0043 and SOW-0044. +- Bind/listen stale-recovery race and peer-disconnect-as-`TRUNCATED`: tracked in SOW-0043. +- FreeBSD and macOS (including Rust test readiness on non-Linux, which falls back to file existence): tracked in SOW-0048 and SOW-0049. +- `linux-targets` CI job not yet observed on GitHub: it runs on the next push; failures there are handled under the SOW that owns the push. +- SOW-0037: merged here and closed. +- No other deferred items remain. + diff --git a/.agents/sow/done/SOW-0047-20261029-abort-signal-spec-conformance.md b/.agents/sow/done/SOW-0047-20261029-abort-signal-spec-conformance.md new file mode 100644 index 00000000..9561897e --- /dev/null +++ b/.agents/sow/done/SOW-0047-20261029-abort-signal-spec-conformance.md @@ -0,0 +1,195 @@ +# SOW-0047 - Abort Signal Spec Conformance + +## Status + +Status: completed + +Sub-state: specs amended to document the 100 ms abort-observation bound; no code change. + +## Requirements + +### Purpose + +The documented client abort mechanism and the Rust/Go implementations must agree. + +### User Request + +2026-10-29: "there are no known bugs. All bugs must be fixed now. Zero known bug policy." and "I want one issue at a time, with proper explanation, risks, proposed solutions, risk of the solutions recommended and explicit recommendations with reasoning." + +Issue 2 decision, 2026-10-29: after asking what instant abort means and where abort is used, the user answered "100ms is ok". + +### Assistant Understanding + +Facts: + +- The specs say a client abort is a file descriptor (POSIX) or event (Windows) waited on together with the socket or pipe (`docs/level1-posix-uds.md:107-110`, `docs/level1-windows-np.md:104-106`). +- C implements that (`netipc_uds_receive.c`, abort pipe fd polled with the socket; `netipc_named_pipe.c:396-397`, abort event checked about every 1 ms). +- Rust (`Option<&AtomicBool>`, `transport/posix.rs`, `transport/windows.rs`) and Go (abort channel, `uds_receive.go`, `pipe_receive.go`) instead poll a flag every 100 ms, so an abort can take up to 100 ms to be noticed. +- SOW-0041 kept the 100 ms flag polling (user decision 1A) and did not change this. +- C SHM response waits also use 100 ms abort-check slices (`netipc_service_posix_client_call.c:77-100`); a futex wait cannot also watch a descriptor, so no language can offer instant abort on SHM. +- Abort users: in this repo only API plumbing (Rust typed clients and `raw/cgroups_cache.rs:215`, Go typed clients and `raw/cgroups_cache.go:358-360`). In Netdata (`netdata/netdata @ 57e634eb4f`) three C shutdown paths: `src/collectors/apps.plugin/apps-cgroups-lookup-client.c:682`, `src/collectors/network-viewer.plugin/network-viewer-apps-lookup-client.c:594`, `src/collectors/ebpf.plugin/ebpf_cgroup.c:344,355` (called from `ebpf.c:917`). No Rust or Go Netdata code calls abort. +- Abort is observed only while waiting for a response; connect and HELLO_ACK waits ignore it (recorded in SOW-0043 with the K4 fix). + +Inferences: + +- Downstream Netdata consumers may be affected; not yet verified against Netdata usage. + +Unknowns: + +- Fix design choices are user decisions, presented one issue at a time. + +### Acceptance Criteria + +- Spec and implementation agree in all three languages: the specs document that a call blocked waiting for its response observes an abort within 100 ms on every transport. + +## Analysis + +Sources checked: + +- Read-only analysis round recorded in SOW-0041 (`.agents/sow/done/SOW-0041-20261029-rust-receive-timeout-slice.md`). + +Current state (before this SOW): + +- Specs required descriptor/event-based abort; Rust and Go (all transports) and C (SHM) poll a flag every 100 ms. + +Risks: + +- Changing Rust/Go to real abort descriptors changes public client APIs; amending the spec changes a documented guarantee. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- The L1 specs describe one abort mechanism (descriptor/event waited on with the connection); the implementations use bounded flag polling except C sockets. Abort is used only on shutdown paths, so a 100 ms bound is the practical contract. + +Evidence reviewed: + +- Specs `docs/level1-posix-uds.md:95-113`, `docs/level1-windows-np.md:92-111`, `docs/level2-typed-api.md:268-272`, `docs/level3-snapshot-api.md:148-155`, `docs/getting-started.md:160-165`, `docs/netipc-integrator-skill.md:396-402`, `:672-685`; code and Netdata call sites listed under Facts. + +Affected contracts and surfaces: + +- Documentation only: L1 POSIX UDS and Windows named-pipe receive contracts, L2 abort contract, integrator skill. No code, API, wire format, or test change. + +Existing patterns to reuse: + +- `NIPC_CLIENT_ABORT_POLL_MS` (100) in C, the 100 ms slice in Rust (`transport/posix.rs`, `transport/windows.rs`, `client_call.rs`) and Go (`receiveAbortPollMs`). + +Risk and blast radius: + +- The documented guarantee changes from immediate to within 100 ms for descriptor-less implementations; all known users abort only at shutdown. + +Sensitive data handling plan: + +- No secrets or customer data involved; Netdata evidence cites the public upstream repository and commit only. + +Implementation plan: + +1. L1 UDS and named-pipe specs: abort may be observed through a descriptor/event or through slices of at most 100 ms; an empty slice is never the deadline. +2. L2 spec: a call blocked waiting for its response observes abort within 100 ms on every transport, including SHM. +3. Integrator skill: state the same bound. +4. Record the connect/handshake abort gap in SOW-0043. + +Validation plan: + +- Re-read every abort statement in `docs/` for consistency with the code in all three languages; no behavior change, so no test change. + +Artifact impact plan: + +- AGENTS.md: no update; no workflow change. +- Runtime project skills: no update. +- Specs: update `docs/level1-posix-uds.md`, `docs/level1-windows-np.md`, `docs/level2-typed-api.md`. +- End-user/operator docs: `docs/getting-started.md` and `docs/level3-snapshot-api.md` make no latency claim; no update. +- End-user/operator skills: update `docs/netipc-integrator-skill.md`. +- SOW lifecycle: completes; SOW-0043 gains the connect/handshake abort finding. + +Open-source reference evidence: + +- `netdata/netdata @ 57e634eb4f` paths listed under Facts. + +Open decisions: + +- None; decision recorded below. + +## Implications And Decisions + +1. 2026-10-29, Issue 2: user accepted a 100 ms abort-observation bound ("100ms is ok"), i.e. option 2A - amend the specs to match the implementations. Rejected 2B (real abort descriptors in Rust/Go: public L1 API change, new descriptors to manage, and SHM still cannot be instant) and 2C (shorter slices: more idle wake-ups, no consumer needs it). + +## Plan + +Executed as the implementation plan in the Pre-Implementation Gate. + +## Execution Log + +### 2026-10-29 + +- Amended `docs/level1-posix-uds.md`, `docs/level1-windows-np.md`, `docs/level2-typed-api.md`, `docs/netipc-integrator-skill.md`. +- While writing the L2 bound, found that connect/HELLO_ACK waits ignore abort; narrowed the documented bound to "a call blocked waiting for its response" and recorded the gap in SOW-0043. + +## Validation + +Acceptance criteria evidence: + +- C POSIX sockets: abort pipe polled with the socket (within the bound). C Windows pipes: abort event checked about every 1 ms. C SHM, Rust (all), Go (all): 100 ms slices. All satisfy the amended text. + +Tests or equivalent validation: + +- Documentation-only change; existing abort tests (Rust `test_receive_abort_after_poll_slice_reports_aborted`, `test_client_abort_unblocks_call`, lookup abort tests in C, Rust and Go) already exercise the behavior now documented. + +Real-use evidence: + +- Netdata's three C abort call sites are shutdown paths; the network-viewer comment already expects abort to cover UDS and SHM. + +Reviewer findings: + +- None requested. + +Same-failure search: + +- `grep -n -i abort docs/*.md`: only the L1 UDS, L1 named-pipe and L2 sections made a mechanism or latency claim; L3 and getting-started make none. + +Artifact maintenance gate: + +- AGENTS.md: no update; no workflow change. +- Runtime project skills: no update; vendoring skill unaffected. +- Specs: updated (L1 UDS, L1 named pipe, L2). +- End-user/operator docs: no update needed; `getting-started.md` and `level3-snapshot-api.md` describe abort without a latency claim. +- End-user/operator skills: updated `docs/netipc-integrator-skill.md`. +- SOW lifecycle: completed and moved to `done/` with the doc change. + +Spec update: + +- Done (see artifact gate). + +Project skill update: + +- None needed; no workflow change. + +End-user/operator docs update: + +- None needed (see artifact gate). + +End-user/operator skill update: + +- Done (see artifact gate). + +Lessons: + +- Writing a precise guarantee exposed an unguarded path (connect/handshake ignore abort); spec wording should be checked against every wait in the call, not only the main receive. + +## Outcome + +The specs now state the abort contract all three languages implement: a call blocked waiting for its response observes an abort within 100 ms on every transport. + +## Lessons Extracted + +- See Validation -> Lessons. + +## Followup + +- Connect/HELLO_ACK waits ignore abort: SOW-0043 (with the K4 connect/handshake timeout fix). + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0048-20261029-freebsd-support.md b/.agents/sow/done/SOW-0048-20261029-freebsd-support.md new file mode 100644 index 00000000..a59f65d1 --- /dev/null +++ b/.agents/sow/done/SOW-0048-20261029-freebsd-support.md @@ -0,0 +1,235 @@ +# SOW-0048 - FreeBSD Support + +## Status + +Status: completed + +Sub-state: delivered 2026-10-31. SOW-0031 (Linux SHM build gating) was merged in before implementation. + +## Requirements + +### Purpose + +NetIPC must build and run on FreeBSD in C, Rust and Go, with cross-language interoperability, as the docs already claim. + +### User Request + +2026-10-29: "netipc should build and run on all these platforms" (Linux on all architectures, Windows, FreeBSD and macOS). Fix designs are presented to the user one issue at a time. + +### Assistant Understanding + +Facts: + +- C: the build fails on `#include ` because CMake enables the SHM transport `if(NOT APPLE)` (`CMakeLists.txt:69`), which includes FreeBSD. +- Rust: `transport/posix` server code uses Linux-only symbols: `__errno_location` (`server_session_unix.rs:299`) and the Linux-only `prepare_unix_accept` (`server_unix.rs:43`). +- Go: `GOOS=freebsd go vet` failed before SOW-0046 on the `Msghdr.Iovlen` width; SOW-0046 fixed that. `service/raw/client_unix.go` and `server_unix.go` reference Linux-only SHM types, so the service layer still does not build. +- FreeBSD 13/14 cap an AF_UNIX SEQPACKET packet at `SO_SNDBUF` (default 8 KiB); FreeBSD 15 reworked unix sockets (review D44151). The SOW-0042 packet-size proposal `min(SO_SNDBUF-32, 262144)` must be checked against this. +- `docs/level1-transport.md:466` and `:502` claim FreeBSD support with UDS only. + +Inferences: + +- SHM stays Linux-only on FreeBSD per the docs; the build gate must say so in all three languages. + +Unknowns: + +- FreeBSD test host availability (the user is preparing a VM). + +### Acceptance Criteria + +- C, Rust and Go build and pass their test suites on FreeBSD; interop across all language pairs passes there. +- CI or a documented manual procedure guards FreeBSD. +- From SOW-0031: Linux C/Rust/Go SHM and service targets keep their behavior and tests; native Windows and `MSYSTEM=MSYS` builds and tests stay green; FreeBSD (and, for the build gates, macOS) never compile Linux futex sources; CMake conditions, public docs and target availability agree. +- A POSIX peer on a platform without SHM never offers or accepts an SHM profile, in any language. + +## Analysis + +Sources checked: + +- SOW-0046 investigation (`.agents/sow/done/SOW-0046-20261029-go-cross-platform-build.md`). +- SOW-0031 (merged in). +- FreeBSD 14.3 build of `b66b224` on the test VM, 2026-10-31 (rust 1.98.1 and go 1.26.10 installed there from FreeBSD packages under the user's standing permission for that VM). + +Current state (2026-10-31, `b66b224`): + +- C: `netipc_shm.c:37` and `tests/fixtures/c/test_service.c:29` include ``; CMake builds SHM, the service library and SHM tests `if(NOT APPLE)` (`CMakeLists.txt:73,291,309,342,478,548,609,638,665,684,734,747,771,1144,1157,1176`), which includes FreeBSD. `netipc_service` links `netipc_shm` unconditionally (`CMakeLists.txt:100-102`). +- Rust: `server_session_unix.rs:368` uses the glibc-only `libc::__errno_location`; `tests/fixtures/rust/src/bin/interop_shm.rs` gates on `not(windows)` instead of Linux. The SHM module and service call sites are already `cfg(target_os = "linux")`. +- Go: `service/raw/client_unix.go` and `server_unix.go` reference `posix.Shm*`, defined only in `transport/posix/shm_linux.go`. +- Rust and Go on non-Linux unix still pass the configured SHM profile bits to the handshake, so two such peers can agree on an SHM profile in HELLO_ACK and then use the socket. This breaks the HELLO_ACK contract (`docs/level1-wire-envelope.md`, "selected_profile is final"). +- Netdata builds NetIPC only `if(OS_LINUX)` / `elseif(OS_WINDOWS)` (`netdata/netdata @ ae9113f2cc`, `src/libnetdata/CMakeLists.txt:88-118`), and its consumers use `#if OS_LINUX || OS_WINDOWS`. FreeBSD support is a library deliverable; enabling it in Netdata is a separate Netdata change. + +Risks: + +- Packet-size negotiation differences across FreeBSD versions. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- The build and the handshake treat "unix" as "Linux". C gates by `NOT APPLE`, Go's service layer assumes Linux SHM symbols exist, Rust uses one glibc errno accessor, and non-Linux peers can negotiate a profile their platform lacks. + +Evidence reviewed: + +- See Analysis, current state. + +Affected contracts and surfaces: + +- CMake target availability on Linux, FreeBSD, macOS, native Windows and MSYS. +- C `netipc_shm.h` error enum (one new value at the end), `netipc_shm.c` platform split. +- L1 POSIX handshake in C, Rust and Go: platform profile mask for client and server. +- Go `transport/posix` exported SHM API on non-Linux unix. +- Test fixtures and scripts that assume Linux. +- Docs: `docs/level1-transport.md`, `docs/level1-posix-uds.md`, `docs/level1-posix-shm.md`, `docs/netipc-integrator-skill.md`. + +Existing patterns to reuse: + +- Rust `cfg(target_os = "linux")` gates; Go per-OS files (`internal/syspoll` from SOW-0046); CMake `NETIPC_WINDOWS_RUNTIME`. +- The profile-drop path of SOW-0055 (unchanged; the mask runs before negotiation). + +Risk and blast radius: + +- Build gates can drop Linux targets silently; validation compares the Linux ctest list before and after. +- The handshake mask runs on every connect on every POSIX platform; on Linux it must be a no-op, which the existing Linux SHM tests and interop prove. +- Windows is untouched except by the shared CMake file; the Windows suites run at close. + +Performance assessment: + +- The mask is one AND on handshake config at connection setup. Nothing on an established session's request path changes on Linux, so no benchmark run. + +Sensitive data handling plan: + +- No secrets, customer data or private host details. The VM is named only as "the FreeBSD test VM"; evidence is code paths and command output. This covers this SOW, specs, docs, skills and code comments. + +Implementation plan: + +1. L1 POSIX handshake platform mask (decision 2) in C, Rust and Go, with tests. +2. C: `netipc_shm.c` Linux implementation under `__linux__`, unsupported stubs otherwise (decision 3); CMake gates by explicit capability; SHM-only C tests and SHM sections of mixed tests gated to Linux. +3. Rust: portable errno, `interop_shm` gated to Linux. +4. Go: `shm_other.go` stubs (decision 3). +5. Make the C, Rust and Go suites, ctest and interop pass on the FreeBSD VM; fix every FreeBSD-specific failure (packet size, socket semantics, test-harness portability). +6. Docs and a FreeBSD validation procedure (decision 4). + +Validation plan: + +- FreeBSD VM: configure, build, ctest, `cargo test`, `go test ./...`, interop scripts. +- Linux: ctest (same test list as before), `cargo test`, `go test`, the 9 Netdata targets. +- Windows: ctest, Rust, Go. +- Cross-checks: `cargo check --target x86_64-unknown-freebsd`, `GOOS=freebsd go vet ./...`. +- Mask tests fail before the fix. + +Artifact impact plan: + +- AGENTS.md: add the FreeBSD validation command (decision 4). +- Runtime project skills: none expected; checked at close. +- Specs: `docs/level1-transport.md`, `docs/level1-posix-uds.md`, `docs/level1-posix-shm.md`. +- End-user/operator docs: same files. +- End-user/operator skills: `docs/netipc-integrator-skill.md` platform notes. +- SOW lifecycle: SOW-0031 merged in and closed. + +Open-source reference evidence: + +- FreeBSD unix socket rework: review D44151 (cited with commit if used). + +Open decisions: + +- None; decisions 1-4 below are assistant-applied under the user's autonomy instruction (long-term-best). + +## Implications And Decisions + +1. SOW-0031 is merged into this SOW and closed (process decision; AGENTS.md requires merging overlapping SOWs before implementation). +2. The L1 POSIX handshake removes profiles the platform cannot provide from both supported and preferred sets, on the client and on the server, before HELLO / negotiation. Linux keeps baseline + SHM_HYBRID + SHM_FUTEX; other POSIX systems keep baseline only. Reason: one place per language that every caller passes through; L2 and integrators cannot get it wrong. +3. C and Go keep the SHM API present on every POSIX system. Off Linux it returns a new "unsupported" error (C `NIPC_SHM_ERR_UNSUPPORTED`, appended to the enum; Go `ErrShmUnsupported`). Rust keeps its existing `cfg(target_os = "linux")` gates. Reason: the C file list stays the same on every POSIX platform (Netdata's `NETIPC_POSIX_TRANSPORT_FILES` needs no split) and the service code needs no guards. Rust already has idiomatic gates, and decision 2 keeps the stubs unreachable. +4. FreeBSD is guarded by a documented manual procedure on the FreeBSD test VM, recorded in AGENTS.md project commands. Reason: GitHub-hosted runners do not offer FreeBSD natively; a CI VM action can follow as its own SOW if the user wants it. + +## Plan + +The gate's implementation plan, executed in order. + +## Execution Log + +### 2026-10-29 + +Created from the SOW-0046 platform-scope split. + +### 2026-10-31 + +- Platform profile mask in the L1 POSIX handshake: C `NIPC_UDS_PLATFORM_PROFILES` (`netipc_uds_internal.h`, applied in `netipc_uds_handshake.c` to the client HELLO and the server supported/preferred sets), Rust `PLATFORM_PROFILES` (`transport/posix.rs`), Go `HelloConfig.PlatformProfiles` / `ServerHelloConfig.PlatformProfiles` (`framing/handshake.go`, set by `transport/posix/shm_linux.go` and `shm_other.go`). +- Bug found while adding the mask: clients accepted a successful HELLO_ACK whose `selected_profile` they never offered, or that had zero or several bits set. All three languages, on POSIX and Windows, now reject it as a protocol error (C `netipc_uds_handshake.c`, `netipc_named_pipe.c`; Rust `transport/mod.rs` `selected_profile_offered()` used by `posix.rs` and `windows.rs`; Go `framing.ClientHandshake`, shared by both platforms). Same-failure search: every client handshake path (UDS and Named Pipe, C/Rust/Go) was checked and fixed. +- C SHM: `NIPC_SHM_ERR_UNSUPPORTED` appended to the enum; `netipc_shm.c` keeps the implementation under `__linux__` and has stubs elsewhere. +- CMake: `NETIPC_POSIX_RUNTIME` (POSIX, not Apple) and `NETIPC_POSIX_SHM` (Linux) replace the `NOT APPLE` gates; SHM tests, SHM interop and all POSIX benchmarks build only with `NETIPC_POSIX_SHM`. +- Rust: portable errno in `server_session_unix.rs`; `interop_shm` and SHM-only code gated to Linux; per-platform `SNDBUF_OVERHEAD`; dead-code gates so the library, tests and `bench_posix` build warning-free on Linux, FreeBSD and Windows. +- Go: SHM errors and `ShmRole` moved to `transport/posix/shm.go`; `shm_other.go` stubs; `uds_sndbuf_freebsd.go` / `uds_sndbuf_other.go`; SHM service tests moved to `service/raw/shm_linux_test.go`. +- Robustness suites now run on every POSIX system, like the C one: Rust `raw_robustness_unix_tests.rs` (module gate dropped; SHM cases gated to Linux) and Go (`robustness_unix_test.go` now `unix`; SHM cases moved to `robustness_shm_linux_test.go`). +- C tests: SHM parts of `test_service.c`, `test_service_extra.c`, `test_service_payload_limits.c`, `test_service_method_limits.c` and `test_robustness.c` gated to Linux; `test_robustness.c` uses `realpath(argv[0])` instead of `/proc/self/exe` and an fstat scan instead of `/proc/self/fd`; new `test_uds.c` case "Platform profile mask". +- `tests/interop-lib.sh`: `interop_pid_runs` uses `ps` where `/proc/PID` does not exist. +- FreeBSD measurement on the test VM: the largest SEQPACKET datagram equals `SO_SNDBUF` exactly (default 8192, `net.local.seqpacket.maxseqpacket`); Linux accepts `SO_SNDBUF - 32`. +- The Rust and Go mask tests first derived their expectation from the constant under test, so a mutant mask still passed. Both now key off the OS (`cfg!(target_os = "linux")`, `runtime.GOOS`). + +## Validation + +Acceptance criteria evidence: + +- C, Rust and Go build and pass on FreeBSD 14.3 (test VM): ctest 24/24 (includes `test_uds_interop`, `test_service_interop`, `test_cache_interop`, `interop_codec`), `cargo test` 316 passed, `go test ./...` ok, `tests/interop_codec.sh` "ALL INTEROP TESTS PASSED". The Rust and Go robustness suites run and pass there (14 tests each, counting the helper-process entry). +- FreeBSD guard: AGENTS.md project commands carry the FreeBSD procedure (decision 4). +- SOW-0031 criteria: the Linux ctest list is unchanged (32 tests, compared before/after); Linux, native Windows and MSYS suites pass (below); FreeBSD builds no futex source (`netipc_shm.c` stubs, `NETIPC_POSIX_SHM` off). +- No SHM profile off Linux: mask tests in all three languages (C `test_uds` "Platform profile mask", Rust `test_profile_preference_selects_shm_futex`, Go `TestProfileSelection`) pass on FreeBSD and fail with the mask removed (mutants on the VM: C "FAIL: selected profile matches the platform", Rust "left: 4 right: 1", Go "selected = 0x4/0x4, want 0x1"); the tree was restored afterwards. + +Tests or equivalent validation: + +- Linux: build warning-free; ctest 32/32. `test_shm_full_backing_store` skips without root (`kernel.apparmor_restrict_unprivileged_userns = 1`, pre-existing), so it was run as root with `NIPC_REQUIRE_TMPFS_TEST=1`: PASS. `cargo test` 386 passed; `go test ./...` ok. +- Selected-profile fix: C `test_uds.c` modes `MALFORMED_ACK_SELECTED_NOT_OFFERED/NONE/TWO`, C `test_named_pipe.c` `FAKE_ACK_SELECTED_*`, Rust `test_connect_rejects_selected_profile_not_offered` (POSIX and Windows), Go three cases in `uds_more_edge_test.go`; mutation-checked on Linux. +- Windows (costa-win11, MSYS2): Rust 266 passed (one new), Go ok, ctest 20/20. +- Netdata Linux targets: all 9 pass (`x86_64-musl`, `i386-gnu`, `i386-musl`, `armv7-gnu`, `armv7-gnu-time64`, `armv7-musl`, `armv6-musl`, `aarch64-gnu`, `aarch64-musl`). The first run of `armv7-gnu` failed to build its Go tests because it read `uds_test.go` while that file was mid-edit; the four targets that ran before the last edits (`x86_64-musl`, `i386-gnu`, `i386-musl`, `armv7-gnu`) were rerun on the final tree and pass. +- Static checks: `cargo clippy --all-targets --all-features --locked -- -D clippy::correctness -D clippy::suspicious` passes on Linux, `x86_64-pc-windows-gnu` and FreeBSD; `gofmt` clean; `go vet` clean for linux and freebsd. `GOOS=windows go vet` reports pre-existing "possible misuse of unsafe.Pointer" in `transport/windows/shm.go` and `shm_test.go`, files this SOW does not touch. + +Real-use evidence: + +- FreeBSD cross-language interop (C, Rust, Go servers and clients over UDS, service and cache layers) is the runnable path; it passes. + +Reviewer findings: + +- No external review run; the mutants above serve as the check that the new tests can fail. + +Same-failure search: + +- Linux-only assumptions: `grep` for `/proc/self`, `__errno_location`, `linux/futex.h`, `NOT APPLE`, `shm_linux` symbols across C, Rust, Go, CMake and test scripts; every hit is gated or ported. +- Selected-profile validation: all client handshakes (UDS, Named Pipe) in three languages. + +Performance assessment: + +- The mask and the selected-profile check run once per handshake (`netipc_uds_handshake.c` client and server config setup; Rust `posix.rs` handshake functions; Go `framing/handshake.go`). Nothing on an established session's request path changed on Linux or Windows; no benchmark run. + +Sensitive data gate: + +- No secrets, customer data, private endpoints or host names in this SOW, docs, AGENTS.md, specs or code comments; the VMs are named by role only. + +Artifact maintenance gate: + +- AGENTS.md: FreeBSD validation command added. +- Runtime project skills: `project-netdata-vendoring` is unaffected; vendoring preflight does not change (Netdata builds NetIPC on Linux and Windows only). +- Specs: `docs/level1-transport.md` (platform mask), `docs/level1-posix-uds.md` (FreeBSD overhead), `docs/level1-wire-envelope.md` (client rejects an unoffered `selected_profile`), `docs/level1-posix-shm.md` (stubs off Linux). No `.agents/sow/specs/` change: the public docs carry these rules. +- End-user/operator docs: the same `docs/` files. +- End-user/operator skills: `docs/netipc-integrator-skill.md` platform notes. +- SOW lifecycle: SOW-0031 merged and closed in `done/`; this SOW moves to `done/`; SOW-0056 created for FreeBSD 15. + +## Outcome + +NetIPC builds and passes its C, Rust and Go suites and cross-language interop on FreeBSD, using the UDS baseline only. On non-Linux POSIX systems the handshake can no longer negotiate SHM, whatever the configuration. Clients on every platform now reject a HELLO_ACK that selects a profile they did not offer. + +## Lessons Extracted + +- A test that derives its expectation from the constant it checks cannot fail. Platform-dependent expectations must come from the platform (`cfg!`, `runtime.GOOS`, `#if`), not from the code under test. The mutants found two such tests. +- Editing tree files while a Linux-target run is reading them can produce false failures (armv7-gnu Go saw a half-applied edit). Do not edit files during a target run, or rerun the targets that read them. + +## Followup + +- FreeBSD 15 and the supported 14.x point releases: SOW-0056 (needs a test host from the user). +- FreeBSD CI runner: rejected for now (decision 4: no native GitHub-hosted FreeBSD runner); the AGENTS.md procedure guards FreeBSD. +- macOS (the darwin Go `MSG_NOSIGNAL` build failure and Apple gates): SOW-0049. +- Enabling NetIPC in Netdata's FreeBSD build is a Netdata change outside this repository; no Netdata consumer uses it today. +- Search of this SOW for defer/later/follow-up/future/TODO/pending: only the items above and the word "pending" in historical text. + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0049-20261029-macos-support.md b/.agents/sow/done/SOW-0049-20261029-macos-support.md new file mode 100644 index 00000000..bf50245d --- /dev/null +++ b/.agents/sow/done/SOW-0049-20261029-macos-support.md @@ -0,0 +1,289 @@ +# SOW-0049 - macOS Support + +## Status + +Status: completed + +Sub-state: delivered; validated on macOS, Linux (native and all 9 Netdata targets), FreeBSD 14.3 and Windows. + +## Requirements + +### Purpose + +NetIPC must build and run on macOS in C, Rust and Go, with cross-language interoperability. + +### User Request + +2026-10-29: "netipc should build and run on all these platforms" (Linux on all architectures, Windows, FreeBSD and macOS). Fix designs are presented to the user one issue at a time. + +### Assistant Understanding + +Facts: + +- macOS has no AF_UNIX `SOCK_SEQPACKET` (apple-oss-distributions/xnu `bsd/kern/uipc_proto.c` registers only stream and datagram unix protocols), so the documented UDS SEQPACKET baseline (`docs/level1-transport.md:466`) cannot work as specified. +- Go: darwin lacks `syscall.MSG_NOSIGNAL` (`transport/posix/uds.go`; macOS uses `SO_NOSIGPIPE`); `service/raw/client_unix.go` and `server_unix.go` reference Linux-only SHM types. +- Rust: same Linux-only symbols as FreeBSD (`server_session_unix.rs:299` `__errno_location`, `server_unix.rs:43`). +- A macOS host is reachable for testing (macOS 26.5, arm64; has clang, cargo/rustc and bash 3.2; no cmake, ninja or go; checked 2026-10-31). +- Measured on that host (2026-10-31): `socket(AF_UNIX, SOCK_SEQPACKET, 0)` fails with "Protocol not supported"; `fchmod()` on an unbound AF_UNIX stream socket fails with EINVAL, and the socket file is created `0777 & ~umask`. The SOW-0032 owner-only mechanism (`fchmod` before `bind`) therefore fails closed on macOS, so the macOS listener needs its own owner-only mechanism. + +Inferences: + +- A macOS baseline transport needs a design: stream socket with message framing, or datagram socket; either changes the transport spec for macOS. + +Unknowns: + +- The transport design choice (user decision). + +### Acceptance Criteria + +- C, Rust and Go build and pass their test suites on macOS; interop across all language pairs passes there. +- The transport spec documents the macOS baseline. + +## Analysis + +Sources checked: + +- SOW-0046 investigation (`.agents/sow/done/SOW-0046-20261029-go-cross-platform-build.md`). +- Build and test probe on the macOS host (2026-11-01, tree synced from commit `7b73adc`, cmake 4.4.5, ninja 1.13.2, Go 1.27.3 installed under the host user's `~/opt`): + - `CMakeLists.txt:39-44` keeps the POSIX service stack off on macOS (`NOT APPLE`); with that gate lifted on the host copy only, the C library, C services and Rust build; the only C failure is `tests/fixtures/c/test_robustness.c` calling the Linux-only `pthread_timedjoin_np`. + - Go fails only at `src/go/pkg/netipc/transport/posix/uds.go:170` (`syscall.MSG_NOSIGNAL` undefined on darwin). + - With the default gate, CTest runs 10 tests; the UDS tests in C, Rust and Go fail at socket creation (SEQPACKET). +- Socket probes on the macOS host (2026-11-01): + - socket file mode right after `bind()`: `0755` (umask 022); + - `chmod()` by path after bind, then `rename()` to another path in the same directory: both succeed; the renamed socket is `0600` and connectable; + - `connect()` to a mode-0000 socket owned by the same user: `EACCES`, so macOS enforces socket file permissions on connect; + - non-blocking connects to a full backlog: `ECONNREFUSED` at once; + - `MSG_NOSIGNAL` (0x80000) is defined in the SDK and makes `send()` to a closed peer return `EPIPE` without a signal; `SO_NOSIGPIPE` is accepted; + - default AF_UNIX stream `SO_SNDBUF` and `SO_RCVBUF`: 8192; + - no `pipe2()` or `accept4()` declarations; `SOCK_CLOEXEC` undefined. +- Receive paths: every language reads the first packet and decides "complete or first chunk" from the received datagram length: + - C `src/libnetdata/netipc/src/transport/posix/netipc_uds_receive.c:171-189`, `:357-366`, continuation reads `:242-266`; + - Rust `src/crates/netipc/src/transport/framing.rs` `receive_message` over the `raw_recv_control` closure (`src/crates/netipc/src/transport/posix.rs:360-383`); + - Go `src/go/pkg/netipc/transport/internal/framing/receive.go` over the `Recv` hook (`src/go/pkg/netipc/transport/posix/uds_receive.go:111-150`). +- Senders fill the first chunk exactly to `packet_size - 32` payload bytes (C `netipc_uds_send.c:175-177`, Rust `framing.rs:157-162`, Go `transport/internal/framing/send.go`), and every continuation header carries `chunk_payload_len` (`docs/level1-wire-envelope.md:125-157`). +- Higher layers already treat a timeout or abort as a broken session (C `src/libnetdata/netipc/src/service/netipc_service_common.c:422-426`). +- No macOS CI job exists (`.github/workflows/*` run on `ubuntu-latest` and one `windows-latest`); the repository is public. + +Current state: + +- macOS builds only the protocol and UDS transport (C), Rust and the Go codec; no transport works at runtime. + +Risks: + +- A framing layer for stream sockets is new protocol surface that must stay identical across three languages. +- Stream sockets have no message boundaries: a partially consumed message leaves the byte stream desynchronized, so receive failures after partial consumption must end the session. +- Robustness tests that craft raw SEQPACKET datagrams (short packets, truncated chunks) behave differently on a stream; their macOS expectations must be derived, not weakened. +- macOS ships bash 3.2; shell interop scripts may use newer bash features. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- The baseline transport relies on a socket type macOS lacks; the owner-only socket mechanism relies on `fchmod()` of an unbound socket, which macOS rejects; Go uses a send flag darwin's `syscall` package lacks. See Facts and Analysis. + +Evidence reviewed: + +- See Analysis (source paths, host build probe, socket probes). + +Affected contracts and surfaces: + +- Specs: `docs/level1-posix-uds.md` (new macOS stream section: socket type, framing, packet size, owner-only bind, partial-receive rule), `docs/level1-transport.md` (baseline transport table, SHM platform note). +- C: `src/libnetdata/netipc/src/transport/posix/` (socket creation, raw send/receive, handshake receive, listener bind, close cleanup), `CMakeLists.txt` (POSIX runtime on macOS). +- Rust: `src/crates/netipc/src/transport/posix.rs` (same points), a new stream helper module. +- Go: `src/go/pkg/netipc/transport/posix/` (same points, darwin build files). +- Tests: C `tests/fixtures/c/test_uds.c`, `test_robustness.c`, `test_service.c`; Rust `transport/posix_tests.rs`, `service/raw_unix_tests.rs`, `service/raw_robustness_unix_tests.rs`; Go `transport/posix/*_test.go`, `service/raw/*_unix_test.go`; shell interop scripts. +- CI: a new macOS workflow job. +- Wire format: unchanged (decision 1A). +- Profile bit 0 keeps its value; on macOS it denotes the stream baseline (peers on one host always share the platform). + +Existing patterns to reuse: + +- The per-language "receive one packet" hook (C `nipc_uds_raw_recv`, Rust `raw_recv_control` closure, Go `Recv` hook): the macOS stream reader plugs in there, so the shared framing code is unchanged. +- FreeBSD-style platform splits: C `#if` blocks in `netipc_uds_lifecycle.c`, Rust `#[cfg(target_os = ...)]` helpers in `posix.rs`, Go build-tagged files (`uds_cloexec_*.go`, `uds_sndbuf_*.go`). +- Existing EINTR injection (C `--wrap`, Rust `eintr_inject`, Go fault hooks) for the stream helper tests on Linux. +- Endpoint lock (`{service}.lock`) to guard the new bind directory. + +Risk and blast radius: + +- Linux, FreeBSD and Windows keep their current code paths: the stream reader, stream sender and rename-bind are selected at compile time for macOS only; the stream helpers are compiled on every POSIX system for unit tests, but Linux and FreeBSD sessions do not call them. +- Interop risk: all three languages must read the same packet lengths from the same headers; covered by the macOS interop suites (C/Rust/Go pairs). +- Security: the owner-only guarantee on macOS moves from `fchmod` to a private-directory bind plus rename; a mistake would expose the endpoint to other users. Covered by a test that checks the final mode and the absence of a wider window, and by the existing peer-permission tests. + +Sensitive data handling plan: + +- No secrets, customer data or private host names; the macOS host is named by role ("macOS test host") in SOWs, specs, docs, project skills, agent instructions and code comments. Evidence is code paths and command output only. + +Implementation plan: + +1. Spec: write the macOS stream section in `docs/level1-posix-uds.md` and update `docs/level1-transport.md`. +2. C: stream helpers (`read exactly`, `send all` over iovecs, packet reader that derives the packet length from the header) compiled on all POSIX; macOS sessions use them in data and handshake receives and sends; a receive that consumed bytes without returning a complete message shuts the socket down; `SO_NOSIGPIPE` on every session socket on macOS; fixed 256 KiB packet-size limit on macOS; rename-bind through `{run_dir}/{service}.d/s` under the endpoint lock, with stale-directory cleanup; enable the POSIX runtime in CMake on macOS; portable replacement for `pthread_timedjoin_np` in the robustness test. +3. Rust: the same in `posix.rs` plus a stream helper module, with unit tests on Linux. +4. Go: the same in darwin build files plus shared stream helpers, with unit tests on Linux. +5. Tests: give each raw-datagram robustness test a macOS-correct expectation (stream semantics), keep every test running on every platform, no skips. +6. CI: add a macOS job (CTest, `cargo test`, `go test`) with pinned actions, matching the existing workflows' pinning. +7. Docs and skill: integrator skill, AGENTS.md validation commands for macOS. + +Validation plan: + +- Linux: CTest, `cargo test` (stable and 1.91), `go test`, all four interop scripts, clippy and fmt gates; the new stream helper tests run here with EINTR and partial-transfer injection. +- macOS host: CTest, `cargo test`, `go test`, interop scripts across all language pairs. +- FreeBSD 14.3: CTest and `cargo test` (shared POSIX code changed). +- Netdata Linux targets for the C and Rust POSIX files touched (all 9, C/Rust/interop). +- Windows: `cargo test` and clippy for the Windows target (shared Rust crate), plus CTest when C shared files change. +- Performance assessment: no established-session code on Linux, FreeBSD or Windows changes; the macOS data path is new and not benchmarked (no macOS benchmark floors exist). Evidence recorded per file at close. + +Artifact impact plan: + +- AGENTS.md: add macOS validation commands (project-wide platform list). +- Runtime project skills: assess at close (vendoring skill unaffected unless Netdata vendors macOS files). +- Specs: `docs/level1-transport.md`, `docs/level1-posix-uds.md`. +- End-user/operator docs: same, plus `docs/getting-started.md` if it lists platforms. +- End-user/operator skills: `docs/netipc-integrator-skill.md`. +- SOW lifecycle: split from SOW-0046. + +Open-source reference evidence: + +- apple-oss-distributions/xnu `bsd/kern/uipc_proto.c` (no local checkout; the claim is backed by the direct measurement on the macOS host recorded in Analysis). + +Open decisions: + +- None; see Implications And Decisions. + +## Implications And Decisions + +User decisions (2026-10-31, user): + +1. macOS baseline transport: AF_UNIX `SOCK_STREAM` with explicit message framing for the NetIPC envelope (new spec section; C, Rust and Go). +2. cmake, ninja and Go (latest stable) may be installed on the macOS test host. +3. Scheduling: after the SOW-0050 Netdata vendoring (Netdata does not build NetIPC on macOS). +- Ordering (2026-10-31, user, supersedes earlier ordering): finish all code changes first — SOW-0045, SOW-0058, SOW-0057, SOW-0049, SOW-0059, and SOW-0056 when the FreeBSD 15.1 VM arrives — then run the full benchmarks and the SOW-0050 vendoring together; benchmark regressions are fixed and re-vendored. + +User decisions (2026-11-01, user: "long-term-best" for every item): + +1. Stream framing (1A): no new bytes on the wire. A packet is the 32-byte header plus the bytes it declares: an outer header (`NIPC` magic) declares `min(payload_len, packet_size - 32)` bytes for its first packet, a continuation header (`NCHK` magic) declares `chunk_payload_len`. Senders already fill the first chunk exactly, so the bytes are identical to the SEQPACKET transport. +2. Packet size on macOS (2A): fixed 256 KiB limit, or the configured size when smaller; `SO_SNDBUF` is not consulted (a stream has no per-message cap). Chunking code unchanged. +3. Owner-only socket on macOS (3A): bind inside a private `0700` directory `{run_dir}/{service}.d/`, `chmod 0600` the socket file by path, then `rename()` it to `{run_dir}/{service}.sock` and remove the directory. The endpoint lock guards the directory; a server that finds a leftover directory under the lock removes it. The umask is not changed and the socket is never reachable with a wider mode. +4. Partial receive on a stream (4A, refined 2026-11-01 by user choice A): a receive that consumed any byte of a message but did not return it complete (timeout, abort, limit, protocol or chunk error) shuts down the socket's receive side (`shutdown(SHUT_RD)`), so every later receive on that session fails. Sends stay valid: outgoing messages are always whole, so a partial read cannot misalign them, and the server's LIMIT_EXCEEDED reply after an oversized request header (`netipc_service_posix_server_session.c:150-167`, same in Rust and Go) must still reach the client for its capacity learning. Shutting down both directions (the first wording) would have turned that reply into a plain disconnect on macOS. +5. Coverage (5A): the stream helpers compile on every POSIX system and are unit-tested on Linux with stream socket pairs (including EINTR and partial-transfer injection); a macOS GitHub Actions job runs CTest, `cargo test` and `go test`; sessions use the stream helpers only on macOS. +6. SHM on macOS (6A): baseline only, as the spec states. Long-term-best because Netdata does not build NetIPC on macOS (user decision 3 above); a macOS SHM would also need `os_sync_wait_on_address`, which pure Go cannot call without cgo. + +## Plan + +See Pre-Implementation Gate, Implementation plan. + +## Execution Log + +### 2026-10-29 + +Created from the SOW-0046 platform-scope split. + +### 2026-11-01 + +- Installed cmake 4.4.5, ninja 1.13.2 and Go 1.27.3 on the macOS test host under the host user's `~/opt` (approved, decision 2 of 2026-10-31); checksums verified for Go and CMake (ninja publishes none). +- Probed the build and socket semantics (Analysis); user decisions recorded; gate filled; moved to `current/`. +- C, Rust and Go implemented as decided: + - C `netipc_uds.c` (stream helpers), `netipc_uds_receive.c`, `netipc_uds_handshake.c`, `netipc_uds_lifecycle.c`, `netipc_uds_internal.h`; + - Rust `transport/posix.rs` and a new `transport/posix_stream.rs`; + - Go `transport/posix/uds_stream.go` and darwin/other platform and bind files. +- Rules found while implementing, applied in all three languages: + - a stream's first header is checked (full decode plus limits) before its body is read; + - EOF before a packet is a disconnect, inside a packet a protocol error, inside a continuation a chunk error; + - bytes beyond the receive buffer are not read. +- Go's stale-endpoint probe dialed `unixpacket` on darwin, which fails on every socket and would have unlinked a live server's endpoint. It now dials the platform's socket type (`uds_platform_*.go` `staleDialNetwork`). +- Test adjustments with stream-correct expectations (no skips): a shared `endStreamWrites`/`end_stream_writes` helper for raw short or over-long packets, full first packets in raw chunk tests, and macOS variants of the packet-size and EINTR-count tests. +- Problems found by the macOS runs, outside the transport: + - `tests/interop-lib.sh` used `exec {fd}<` (bash 4.1+). Under macOS bash 3.2 with an EXIT trap, the scripts then exited 0 after the failure (false pass, reproduced). Rewritten with a fixed fd and a `sh -c 'echo $$; exec "$@"'` wrapper, since bash 3.2 sets no `$!` for a process substitution. + - macOS `$TMPDIR` pushes `t.TempDir()` and interop run-directory socket paths past the 104-byte `sun_path`. Go tests got `shortTempDir`; the interop scripts got `interop_mktemp_run_dir` (`/tmp` on Darwin). + - Shared test servers used `backlog 4` with bursts of 5 to 50 clients. macOS and FreeBSD refuse a connect to a full backlog at once, so those tests were nondeterministic there. The Go and Rust shared test configs now use 64; C already sized its burst tests. + - `test_hardening` "session table growth" shrank the session table to 1 slot below `worker_count`, but the server has had no table growth since commit `8e2e43af`. The test itself wrote past the table (heap overflow under ASan on Linux and macOS; a Bus error on macOS). It now shrinks the table only to `worker_count`, the smallest valid size. +- Go receive: the stream branch runs before the SEQPACKET closures are built, and `PropagateRecvError` uses top-level functions, so the Linux receive allocates nothing new. + +## Validation + +Acceptance criteria evidence: + +- macOS test host (arm64, macOS 26.5): CTest 24/24 (`test_uds` 243/243), `cargo test` 324 passed, `go test ./...` ok with `-count=3`; interop `test_uds_interop.sh`, `test_service_interop.sh`, `test_cache_interop.sh`, `test_lookup_scale_interop.sh` and `interop_codec.sh` pass across all C/Rust/Go pairs, with no processes left behind. An ASan build of the C suites (`build-asan` on the host) is clean after the `test_hardening` fix. +- The macOS stream baseline is specified in `docs/level1-posix-uds.md` ("macOS stream baseline") and in the baseline table of `docs/level1-transport.md`. +- Linux x86_64 (after the review fixes): build, CTest 32/32 (1 skipped: `test_shm_full_backing_store`, root-only, unaffected), every POSIX interop script (`interop_codec`, uds, shm, service, service_shm, cache, cache_shm, lookup_scale), `cargo test` with Rust 1.91.0 (393 passed), `cargo fmt --check` with both toolchains, the CI clippy gate (`-D clippy::correctness -D clippy::suspicious`) for Linux and `x86_64-pc-windows-gnu`, `go vet` and `go test ./...`. +- `cargo test` stable on Linux: 392 passed, 1 failed, `test_listen_excludes_starting_server` ("endpoint free after close: AddrInUse"), intermittent. Root cause confirmed with a standalone flock probe: a child forked by a concurrent test inherits a copy of the endpoint lock fd, and the flock stays held until that child exits. This is a regression of SOW-0052's claim, not caused by this SOW (no lock code changed here); it is reopened as a SOW-0052 regression (Followup). +- FreeBSD 14.3: CTest 24/24, `cargo test` 323 passed, `go test ./...` ok, the same five interop scripts pass. +- Windows (after the review fixes): CTest 20/20 (including the interop scripts on the rewritten `interop-lib.sh` under MSYS2), `cargo test` 273 passed, `go test ./...` ok. +- Netdata Linux targets (`tests/run-linux-target.sh `, C, Rust, Go and interop): see the Linux targets entry below. + +Linux targets: + +- All 9 pass with the final code (C, Rust, Go and interop): x86_64-musl, i386-gnu, i386-musl, armv7-gnu-time64 and armv7-musl in the first run; armv7-gnu, armv6-musl, aarch64-gnu and aarch64-musl on a rerun, each started after memory PSI full avg10 stayed under 5% for a minute (peak during each run: 0). +- In the first run those four failed only on readiness and deadline timeouts while memory PSI full avg10 was 50-80% and runs were several times slower than normal (aarch64-gnu 1077 s, against 35 s on the rerun). Which targets failed changed between runs, and the same code then passed at no pressure, so those results were treated as invalid, not as bugs. + +Tests added or changed (all run on every platform; no skips): + +- Stream helpers unit-tested on Linux with stream socket pairs: C `test_uds.c` stream cases, Rust `posix_stream.rs` tests, Go `uds_stream_test.go`. They cover packet length from the header, EOF mapping, first-header checks (over-limit, undecodable, continuation first), and one-byte transfers with EINTR injection. +- macOS-only: partial receive ends reads (Go `uds_stream_darwin_test.go`, the same in C and Rust), rename-bind (`test_listen_bind_dir` in C, Rust and Go: final mode 0600, leftover directory reclaimed, foreign content and a directory at the socket name refused), fixed packet-size limit. + +Reviewer findings (independent review of the full diff, static checks only): + +1. Rust and Go checked less of the first header than C before reading its body, so a bad version could block until timeout: fixed, full decode in both, plus a bad-version test in each. +2. Rust created the bind directory with mode `0777 & ~umask` before the chmod: fixed with `DirBuilder::mode(0o700)`. +3. Go removed an empty directory named `s` inside a leftover bind directory, where C and Rust refuse: fixed with `syscall.Unlink`; foreign-subdirectory case added to all three `test_listen_bind_dir` tests. +4. C did not fail pending requests when the first packet's body read hit a receive error: fixed (`nipc_uds_inflight_fail_all`). +5. Go `streamBodyLen` converted 32-bit header fields to `int` unbounded (32-bit Go panic): fixed, bounded in `uint32` first. +6. Test scripts: `interop_stop_server` did not wait for a server that never printed READY (fixed: the drain process always starts and the wait covers server exit); `test_lookup_scale_interop.sh` expanded an empty array under `set -u` on bash 3.2 (fixed). +7. Small macOS stream buffers (8 KiB default) can deadlock a client that writes many pipelined requests before reading: confirmed as a property of the platform, documented in `docs/level1-posix-uds.md` ("Socket buffers") with pipelining guidance. Not a NetIPC defect: the same client pattern deadlocks on any stream transport once both buffers fill. +- Noted by the reviewer, not new: the Go server handshake maps a mid-HELLO timeout to `ErrRecv` where C and Rust return TIMEOUT (`transport/internal/framing/handshake.go`), on every platform. Confirmed by a probe test; tracked as SOW-0061. + +Same-failure search: + +- `exec {fd}` and other bash 4+ forms in the scripts macOS runs: only `interop-lib.sh` used them; fixed. The remaining scripts with bash 4 features (`run-linux-target.sh`, the Windows scripts) do not run on macOS. +- `backlog` values in shared test servers: Go `service/raw/client_test.go` and Rust `raw_unix_tests.rs` raised to 64; C tests already size their bursts. +- `t.TempDir()` used for socket run directories: replaced with `shortTempDir` in the Go transport, raw and lookup tests; the interop scripts use `interop_mktemp_run_dir`. +- `unixpacket` dials: only the stale probe; fixed. + +Performance assessment: + +- Linux, FreeBSD and Windows keep the same syscalls on the established-session path. +- C: `nipc_uds_wait_for_readable` (`netipc_uds.c`) lost `static` so the stream reader can share it; no call is added on SEQPACKET. Stream code is behind `NIPC_UDS_STREAM` (macOS only). +- Rust: `send_packet`/`send_control` (`transport/posix.rs:942`, `:950`) are cfg-selected one-line dispatchers that inline to `raw_send_iov`/`raw_send` off macOS. +- Go: `sendPacket` (`transport/posix/uds.go:149`) branches on the constant `streamTransport`, which folds away off darwin; the receive closures are built only after the stream dispatch, and `PropagateRecvError` uses top-level functions, so no closure allocation is added. +- No benchmark run for this SOW: the full benchmark run before vendoring (user ordering) covers these files. The macOS data path is new and has no benchmark floors. + +Sensitive data gate: hosts named by role (macOS test host, FreeBSD VM, Windows host); evidence is code paths, test names and command output; no secrets, customer data or private endpoints in the SOW, specs, docs, skills, AGENTS.md or code comments. + +Artifact maintenance gate: + +- AGENTS.md: macOS validation bullet added (CI workflow, commands, bash 3.2 rule for the script paths macOS runs). +- Runtime project skills: `project-netdata-vendoring` unaffected: its preflight is platform-neutral, and `vendor-to-netdata.sh`/`diff-netdata-vendor.sh` copy whole directories, so new files are picked up. +- Specs: `docs/level1-posix-uds.md` and `docs/level1-transport.md` updated; `.agents/sow/specs/` holds no transport rules, so no change there. +- End-user/operator docs: `README.md` platform table (Linux, FreeBSD, macOS rows). +- End-user/operator skills: `docs/netipc-integrator-skill.md` macOS platform note. +- SOW lifecycle: completed, moved to `done/`; follow-ups mapped below. + +Spec update: `docs/level1-posix-uds.md` ("macOS stream baseline", rename-bind under Socket permissions, EINTR note) and `docs/level1-transport.md` (baseline table). + +Project skill update: none needed (see the artifact gate). + +Lessons: see Lessons Extracted. + +Follow-up mapping: see Followup. + +## Outcome + +NetIPC builds and runs on macOS in C, Rust and Go over an AF_UNIX stream baseline with explicit packet framing (same bytes as SEQPACKET), owner-only rename-bind, and the partial-receive rule. All suites and cross-language interop pass on macOS, and a macOS CI workflow runs them. Linux, FreeBSD and Windows paths are unchanged. + +## Lessons Extracted + +- A shell feature missing on an older bash can turn into a false pass when an EXIT trap overrides the status: interop scripts must fail closed, and scripts that run on macOS must stick to bash 3.2. +- macOS `$TMPDIR` is long enough to push socket paths past `sun_path`; socket tests need short run directories there. +- macOS and FreeBSD refuse a connect to a full listen backlog at once, where Linux queues it; burst tests must size the backlog. +- ASan on a second platform found a heap overflow that a test itself caused (`test_hardening`); run the sanitizer build when porting. +- Timing failures on the qemu Linux targets tracked host memory stalls (PSI memory full up to 80%) and moved between targets from run to run; sample PSI before triaging them, as AGENTS.md requires. + +## Followup + +- Fork-held endpoint lock (`test_listen_excludes_starting_server` intermittent): reopen SOW-0052 as a regression; fix design goes to the user. +- Go server handshake maps a HELLO timeout to `ErrRecv` (C and Rust: TIMEOUT), confirmed by a probe on Linux: SOW-0061 (pending). +- FreeBSD SHM: SOW-0059 (pending). FreeBSD 15.1: SOW-0056 (pending). +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0051-20261031-client-call-deadlines.md b/.agents/sow/done/SOW-0051-20261031-client-call-deadlines.md new file mode 100644 index 00000000..c78039d0 --- /dev/null +++ b/.agents/sow/done/SOW-0051-20261031-client-call-deadlines.md @@ -0,0 +1,215 @@ +# SOW-0051 - Client Call Deadlines + +## Status + +Status: completed + +Sub-state: implemented and validated on Linux, the Netdata Linux targets and Windows. + +## Requirements + +### Purpose + +Bound every client connect, handshake and retry by the caller's call deadline and abort signal, and make the C, Rust and Go clients agree on retry and not-ready behaviour. + +### User Request + +From SOW-0040 decisions (2026-10-29): split the fix work into separate SOWs (handshake/timeout bounds and shutdown among them). SOW-0043 was split again on 2026-10-31 (assistant process decision, recorded in SOW-0043) so each SOW lands as one reviewable, bisectable commit; this SOW carries the client-side items. + +### Assistant Understanding + +Facts (line numbers verified 2026-10-31 against commit `b450b40`): + +- K4: client connect and HELLO_ACK read have no deadline. C `nipc_uds_connect` `connect()` `src/libnetdata/netipc/src/transport/posix/netipc_uds_lifecycle.c:325`, `nipc_uds_client_handshake` recv `netipc_uds_handshake.c:162`; Windows `nipc_np_connect` CreateFileW `netipc_named_pipe.c:903`, `client_handshake` `raw_recv` `:585`; Rust `posix.rs:1155`/`:1082` (connect), `:1214` (recv), `windows.rs:733` (CreateFileW), `:1313` (recv); Go `uds_handshake.go:27` (connect), `:54` (recv), `pipe_session.go:159`, `pipe_handshake.go:40`. `refresh()` takes no timeout (C `netipc_service.h:191`, Rust `client.rs:296`, Go `client.go:84`). Reproduced in all languages and platforms by the SOW-0040 tests (`test_client_bounded_when_server_never_accepts`, `test_client_bounded_when_server_never_sends_hello_ack` and their Rust/Go/Windows equivalents). +- N8: non-timeout failures and overflow-resize retries grant a fresh full timeout per attempt (C `netipc_service_common.c:355-435`, Rust `client_call.rs:14-108`, Go `client.go:158-242`), contrary to the deadline text in `docs/level2-typed-api.md:266-271`. Not yet covered by a test. +- Found by SOW-0053: a client that reconnects to a drained or stopped server whose listener is still open blocks forever in the HELLO_ACK read (seen as a C `test_service` drain hang; the test now holds its sessions open across the drain). Same root cause as K4; the deadline must cover it. +- SOW-0053 added a bounded `ERROR_PIPE_BUSY` wait to the Windows connects (`WaitNamedPipeW`, at most 1000 ms, then one more `CreateFileW`; C `open_client_pipe` in `netipc_named_pipe.c`, Rust `open_client_pipe` in `windows.rs`, Go `openClientPipe` in `pipe_session.go`). The call deadline must also bound that wait. +- Client abort is observed only while waiting for a response; connect and HELLO_ACK waits, including the reconnects a call performs internally, ignore it (C `netipc_uds_handshake.c:162`, `netipc_named_pipe.c:585`; Rust `posix.rs:1214`, `windows.rs:1313`; Go `framing/handshake.go:148`). Abort is checked only before an attempt (`netipc_service_common.c:325`, `client_call.rs:118`). +- The 5 s SHM attach retry ignores the caller's timeout (C `netipc_service_posix_client_connect.c:123`, `netipc_service_win_client_connect.c:126`; Rust `client_unix.rs:27-28`; Go `client.go:11`, `client_unix.go:94`). +- C reconnects up to 20 times inside one call with a drain sleep (`netipc_service_posix_client_connect.c:171-185`, constants `netipc_service_posix_internal.h:13-16`, sleep `netipc_service_common.c:386-387`; Windows `netipc_service_win_client_connect.c:179-187`) while Rust and Go reconnect once as the spec says (`docs/level2-typed-api.md:242-244`; `client_call.rs:57`, `client.go:195`). +- A call on a non-READY client returns `NOT_READY` in C (`netipc_service_common.c:358-361`) but `BadLayout` in Rust (`client_call.rs:21-23`) and Go (`client.go:159-163`). +- Resolved by SOW-0043 (2026-10-31): Rust Windows session close called `FlushFileBuffers` for both roles, so a client close blocked on a server that stopped reading. SOW-0043 removed the flush and `DisconnectNamedPipe` from session close in C, Rust and Go (close now only closes the handle), because the server-side flush blocked shutdown in a way no other thread can release. + +Inferences: + +- Netdata's C clients call with a 5 s timeout; a server that accepts but never answers HELLO today blocks them indefinitely. + +Unknowns: + +- None that investigation cannot resolve; design choices are made at activation. + +### Acceptance Criteria + +- The SOW-0040 K4 tests pass in all languages on Linux and Windows. +- New tests prove the whole call (connect, handshake, attach, retries) is bounded by one deadline and observes abort. +- C, Rust and Go agree on reconnect count and the not-ready error. +- `docs/level2-typed-api.md` states the total call deadline semantics. + +## Analysis + +Sources checked: + +- SOW-0040 evidence and tests; SOW-0043 facts (verified 2026-10-31). + +Current state: + +- The K4 reproduction tests run ungated and fail until this SOW lands. + +Risks: + +- Changing retry counts and error codes is a cross-language behaviour change; interop suites and Netdata callers must be checked. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- Only the response wait had a deadline and an abort check. Connect, the HELLO_ACK read, the Windows busy-pipe wait and the SHM attach retry ran with their own fixed bounds or none, and every retry inside a call restarted the timeout. A server that accepts but never answers therefore holds a client forever (K4), and a call can run far past its timeout (N8). C additionally reconnected up to 20 times with 5 ms drain sleeps, unlike the spec and Rust/Go. + +Evidence reviewed: + +- The locations in Facts (re-verified at activation against `2a5888b`), `docs/level2-typed-api.md` section 5, `docs/level1-posix-uds.md`, `docs/level1-windows-np.md`, and the SOW-0040 K4 tests in all languages. +- Netdata consumers (`ktsaou/netdata @ ae9113f2cc`): no Rust or Go NetIPC consumers outside the vendored copies; the C callers (apps.plugin, ebpf.plugin, network-viewer, cgroups tests) ignore the `refresh()` return and set `call_timeout_ms` explicitly, so the new not-ready error and the bounded refresh do not change their behaviour beyond ending the K4 hang. + +Affected contracts and surfaces: + +- L1: new bounded connects (C `nipc_uds_connect_timeout`, `nipc_np_connect_timeout`; Rust `UdsSession::connect_with_timeout`, `NpSession::connect_with_timeout`; Go `posix.ConnectTimeout`, `windows.ConnectTimeout`); existing connects become wrappers with no deadline. +- L2: one deadline per call and per `refresh()`; one reconnect per failed call in C; not-ready error in Rust (`NipcError::NotReady`) and Go (`protocol.ErrNotReady`). +- Windows servers: the reject paths close the pipe without `DisconnectNamedPipe`. +- Docs: `docs/level2-typed-api.md`, `docs/level1-posix-uds.md`, `docs/level1-windows-np.md`, `docs/netipc-integrator-skill.md`. + +Existing patterns to reuse: + +- The receive-with-deadline-and-abort helpers (C `nipc_uds_receive_timeout`/`nipc_np_receive_timeout`, Rust `ReceiveWait`, Go `receiveWait`) for connect and HELLO_ACK waits. + +Risk and blast radius: + +- Every client call and refresh in all languages. Error-code change for calls on a non-READY client in Rust and Go. Cross-language interop is unaffected: the wire format does not change. + +Performance assessment: + +- Per-request code on an established session gains one monotonic clock read when a call starts and one when the attempt computes the time left: C `netipc_service_common.c:351` (`do_raw_call`) and `:407`/`:492` (`begin_op`); Rust `client_call.rs:32` and `:120`; Go `client.go:217` and `:306`, `increment.go:97`. These are vDSO clock reads (tens of nanoseconds) against microsecond-scale round trips, and the Go and Rust receive paths already read the clock to build their deadlines. The change does touch the measured path, so it joins the single benchmark run before vendoring that the user's instruction prescribes (tracked in SOW-0050); no separate run is made here. + +Sensitive data handling plan: + +- No secrets involved. Netdata evidence cites the repository and commit only. Applies to this SOW, the docs, the integrator skill and code comments. + +Implementation plan: + +1. C transport bounded connects and HELLO_ACK waits; L2 op deadline, single reconnect, attach clamp. +2. Rust, then Go, the same. +3. Windows server reject paths: close without disconnect, in C, Rust and Go. +4. Tests in every language and platform: reconnect bounded by the call deadline, abort ends a refresh waiting for HELLO_ACK. +5. Docs and integrator skill. + +Validation plan: + +- Linux full ctest (C, Rust, Go, interop), `cargo test`, `go test`, Windows ctest plus Rust and Go suites, the 9 Netdata Linux targets, rustfmt, CI clippy gate, gofmt, go vet; new tests shown to fail on the previous code. + +Artifact impact plan: + +- AGENTS.md: no workflow change. +- Runtime project skills: the vendoring skill's workflow is unchanged. +- Specs: `docs/` L1 and L2 specs updated. +- End-user/operator docs: same files. +- End-user/operator skills: `docs/netipc-integrator-skill.md`. +- SOW lifecycle: split from SOW-0043; benchmark tracked in SOW-0050. + +Open-source reference evidence: + +- None. + +Open decisions: + +- None; the decisions below were assistant-applied under the user's autonomy instruction, as long-term-best options. + +## Implications And Decisions + +1. One deadline per call or refresh covers connect, handshake, SHM attach, every reconnect and every overflow retry; abort is observed in every wait. `refresh()` uses the resolved context call timeout (no API change) and does not connect while an abort is pending. Reason: the spec already promises a bounded call, and a caller loop must not stall on a silent server. +2. One reconnect per failed call, as the spec says. The C 20-try loop, its 5 ms drain sleeps and the `CLIENT_CALL_RECONNECT_*` constants and ops `sleep_ms`/`reconnect_retry_interval_ms` are removed. Reason: parity with Rust and Go and the spec; the loop only hid server drain races. +3. Not-ready error: Rust `NipcError::NotReady` ("client not ready"), Go `protocol.ErrNotReady`; C keeps `NIPC_ERR_NOT_READY`. Reason: `BadLayout` misreported a lifecycle state as a protocol error; no Rust/Go consumers exist downstream. +4. New L1 bounded connects; the old functions become wrappers with no deadline. Reason: keeps the L1 API compatible. +5. POSIX connect waits in `SO_SNDTIMEO` slices of 100 ms (Linux EAGAIN = full backlog; EINTR/EALREADY retry; EISCONN = success; timeout reset to 0 afterwards); HELLO_ACK uses a readable-wait before recv. +6. Windows busy-pipe wait is bounded by min(1000 ms, deadline) in `WaitNamedPipeW` slices of 100 ms with abort checks; `ERROR_SEM_TIMEOUT` continues the loop. The HELLO_ACK wait treats a disconnected pipe as readable, because a rejection reply may be pending. +7. SHM attach waits at most min(shm attach timeout, time left). When the call deadline ends the wait first, or abort is raised, SHM stays enabled (the connect returns disconnected) instead of being blacklisted. Reason: the deadline, not SHM, failed. +8. Bug found and fixed (zero-known-bug policy): Windows servers called `DisconnectNamedPipe` before `CloseHandle` on handshake and accept failures, which discards a rejection reply the client has not read; the old blocking client read masked the race. Fixed in C (`netipc_named_pipe.c`, three sites), Rust (`windows.rs`: `PendingPipe` drop and the replacement-instance failure path) and Go (`pipe_listener.go`, four sites). A probe on the Windows host showed data written before a server `CloseHandle` stays readable. +9. Remaining time rounds up (Rust `as_micros().div_ceil(1000)`, Go `(µs+999)/1000`), so a transport wait never ends before the call deadline and the call reports the timeout rather than a disconnect. + +## Plan + +As in the Pre-Implementation Gate implementation plan; executed in that order. + +## Execution Log + +### 2026-10-31 + +- C: `netipc_uds_lifecycle.c` (`connect_bounded`, `nipc_uds_connect_timeout`), `netipc_uds_handshake.c` (HELLO_ACK readable-wait), `netipc_named_pipe.c` (bounded busy wait, HELLO_ACK wait, reject without disconnect); service `op_deadline_ms`, `begin_op`, `op_remaining_ms`, single reconnect, attach clamp on POSIX and Windows. +- Rust: `client.rs`, `client_call.rs`, `client_unix.rs`, `client_windows.rs`, `posix.rs`, `windows.rs`; `NipcError::NotReady`. +- Go: `posix/uds_session.go`, `posix/uds_handshake.go`, `windows/pipe.go`, `windows/pipe_session.go`, `windows/pipe_handshake.go`, `windows/pipe_listener.go`, `service/raw/client*.go`, lookups, increment, string reverse, snapshot; `protocol.ErrNotReady`. +- Tests: C `test_robustness.c`, `test_win_robustness.c`, `test_win_service_extra.c`; Rust `raw_robustness_unix_tests.rs`, `raw_robustness_windows_tests.rs`; Go `robustness_unix_test.go`, `robustness_windows_test.go`; not-ready expectations updated across the suites. + +## Validation + +Acceptance criteria evidence: + +- K4 tests pass in all languages on Linux and Windows (C `test_robustness`/`test_win_robustness`, Rust `test_robust_*client_bounded*`, Go `TestRobustClientBounded*`, `TestWinRobustClientBoundedWhenServerNeverSendsHelloAck`). +- New tests: `test_call_reconnect_bounded_by_call_deadline` and `test_abort_ends_refresh_waiting_for_hello_ack` (C POSIX and Windows), Rust `test_robust_call_reconnect_bounded_by_call_deadline`, `test_robust_abort_ends_refresh_waiting_for_hello_ack` and Windows equivalents, Go `TestRobustCallReconnectBoundedByCallDeadline`, `TestRobustAbortEndsRefreshWaitingForHelloAck` and Windows equivalents. The reconnect case returns the timeout error after about 1000 ms; the abort case returns within one 100 ms slice. +- Proof against the old code: the new C, Rust and Go Linux tests, run on the `2a5888b` sources in a scratch copy, all fail: the call blocks past 6 s, and the refresh does not end within 2 s of the abort. With this SOW they pass. +- Reconnect count and not-ready error agree across C, Rust and Go (decisions 2 and 3); `docs/level2-typed-api.md` states the total-call deadline. + +Tests or equivalent validation: + +- Linux: full ctest 32/32 in 4 s (C, Rust, Go, interop); `cargo test --lib` 380 passed; `go test ./...` all packages ok. +- Windows (costa-win11, MSYS2): ctest 20/20; Rust `cargo test --lib` 264 passed; Go all packages ok. +- Netdata Linux targets: see target results below. +- `cargo fmt --check` clean; CI clippy gate (`-D clippy::correctness -D clippy::suspicious`) passes; the remaining clippy warnings are on lines that predate this SOW. gofmt clean; `go vet` clean on Linux; on Windows only the existing `unsafe.Pointer` notes in `transport/windows/shm*.go`. + +Target results: + +- `tests/run-linux-target.sh` with C, Rust, Go and interop: x86_64-musl, i386-gnu, i386-musl, armv7-gnu, armv7-gnu-time64, armv7-musl, armv6-musl, aarch64-gnu and aarch64-musl all pass. +- The first aarch64-gnu run failed time-bounded tests: Rust wedged-peer timeout, abort and lookup-timeout tests; Go crash-detection tests; interop servers not READY in time. While it ran, host memory pressure (`/proc/pressure/memory` some avg60) rose from about 0 to 46-64% and `compact_stall` kept climbing; it fell to 0.23 avg10 afterwards. The single rerun at low pressure (avg60 8.8) passed every suite: Rust 380 passed, Go, C and interop ok. The cause of the pressure was not established, so it is not attributed to any other workload. + +Real-use evidence: + +- The interop suites in ctest drive real C, Rust and Go servers and clients across all language pairs through the changed connect paths. + +Reviewer findings: + +- No external review was requested for this SOW; the cross-language comparison against the Rust reference was done per file during implementation. + +Same-failure search: + +- Searched every client wait for missing deadlines: connect, HELLO_ACK, busy-pipe, SHM attach (all bounded now). Searched every `DisconnectNamedPipe` call in the three languages: the only remaining one is in the Go test helper `pipe_edge_test.go`, which tests the disconnect error path on purpose. Searched every not-ready `BadLayout` expectation in the Rust and Go tests on both platforms. +- Found while validating, outside this SOW's scope: Go `GOOS=darwin` fails on `syscall.MSG_NOSIGNAL` and `GOOS=freebsd` fails on Linux-only SHM types in `service/raw`; both are existing and tracked in SOW-0049 and SOW-0048. + +Sensitive data gate: + +- This SOW, the docs, the integrator skill and the code comments contain no secrets, credentials, tokens, customer or personal data, or private endpoints. Netdata evidence cites the repository and commit only; the Windows test host is named by its lab hostname only, with no address. + +Artifact maintenance gate: + +- AGENTS.md: no change; no workflow or guardrail changed. +- Runtime project skills: no change; `project-netdata-vendoring` steps are unaffected. +- Specs: `docs/level2-typed-api.md` (one deadline per call and refresh, abort during connect, not-ready error), `docs/level1-posix-uds.md` (bounded connect), `docs/level1-windows-np.md` (bounded busy wait, HELLO_ACK after disconnect, reject without disconnect). `.agents/sow/specs/` holds no spec on this behaviour. +- End-user/operator docs: the same `docs/` files are the published docs. +- End-user/operator skills: `docs/netipc-integrator-skill.md` (deadline, bounded refresh, not-ready error, busy wait bound). +- SOW lifecycle: moved pending → current → done in this commit; benchmark run tracked in SOW-0050. + +## Outcome + +Every client connect, handshake, attach and retry is bounded by the caller's call deadline and observes abort, in C, Rust and Go on POSIX and Windows. `refresh()` is bounded by the context call timeout. C reconnects once per failed call like Rust and Go, and all three report the same not-ready error class. A Windows race that discarded server rejection replies is fixed. + +## Lessons Extracted + +- A blocking read can hide a lost-data race: the Windows reject-path `DisconnectNamedPipe` bug only showed once the client waited with a deadline. When changing a wait, re-check what the peer does on its failure paths. +- Rounding the remaining time down made a wait end a fraction of a millisecond early, and the call reported a disconnect instead of a timeout. Deadline helpers must round up. + +## Followup + +- Benchmark run for the per-request clock reads: in the single pre-vendoring benchmark run, tracked in SOW-0050. +- Go darwin and freebsd service-layer builds: SOW-0049 and SOW-0048. + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0052-20261031-transport-hygiene.md b/.agents/sow/done/SOW-0052-20261031-transport-hygiene.md new file mode 100644 index 00000000..dfce0585 --- /dev/null +++ b/.agents/sow/done/SOW-0052-20261031-transport-hygiene.md @@ -0,0 +1,340 @@ +# SOW-0052 - Transport Hygiene + +## Status + +Status: completed + +Sub-state: regression of 2026-11-01 fixed and validated (see Regression - 2026-11-01). Originally completed 2026-10-31; the SHM-timing item moved to SOW-0055 (decision 4). + +## Requirements + +### Purpose + +Fix the remaining transport-level defects found by SOW-0040, SOW-0046 and SOW-0047: EINTR handling, the stale-socket recovery race, failure classification and SHM resource timing. + +### User Request + +From SOW-0040 decisions (2026-10-29): fix all reproduced and review-found defects under the zero-known-bug policy. SOW-0043 was split on 2026-10-31 (assistant process decision, recorded in SOW-0043); this SOW carries the transport-level items. + +### Assistant Understanding + +Facts (line numbers verified 2026-10-31 against commit `b450b40`): + +- N9: EINTR is treated as a disconnect. C `netipc_uds.c:45-47` (send), `:74-76` (sendmsg), `:83` (recv), with handshake callers returning `ERR_RECV` (`netipc_uds_handshake.c:162-164`, `:214-216`); Rust `posix.rs:908-910` (`raw_send_iov`), `:917-927` (`raw_send`), `:932-937` (`raw_recv`); only the poll at `posix.rs:999` handles EINTR. +- Stale-socket recovery can unlink a live server's endpoint (SOW-0046 working theory; user decision: reproduction test first). A server's socket file exists from `bind()` until `listen()`, and connects in that window are refused. C `nipc_uds_check_and_recover_stale` (`netipc_uds_lifecycle.c:181-212`, unlink at `:205-208`; bind `:252`, listen `:258`) and Rust (`posix.rs:1104-1144`, unlink `:1123-1136`) unlink on any non-ENOENT connect error after one probe; Go (`uds_stale.go:27-44`, unlink `:68`) retries ECONNREFUSED 3 times with 50 ms delays, which narrows but does not close the window, and is a cross-language difference. +- Failure classification: after SOW-0044, a send/recv failure reports DISCONNECTED when a peer probe confirms the peer is gone, but SHM receive errors other than timeout still return TRUNCATED (C `netipc_service_posix_client_call.c:120`; Rust `client_call.rs:475`, `:500`, `:517`; Go `client_unix.go:164`, `:232`), and without a probe hit all three fall back to TRUNCATED ("buffer too short") for what is a transport failure. +- SHM regions are created before HELLO is read for every accepted connection (C POSIX `netipc_service_posix_server.c:206-207`, C Windows `netipc_service_win_server.c:107-111`, Rust `server_unix.rs:43`/`:137-143`, `server_windows.rs:28`/`:133`, Go `server_unix.go:123`), so every connection, including rejected and silent ones, costs a region create and destroy. SOW-0043 moves this work into the session worker but keeps it before HELLO. + +Inferences: + +- EINTR during a handshake on a plugin that receives signals (Netdata plugins do) turns a transient signal into a failed connect. + +Unknowns: + +- Whether the stale-socket race is reachable in practice; the reproduction test decides. + +### Acceptance Criteria + +- Tests prove EINTR is retried in C and Rust send/recv paths. +- A reproduction test for the stale-socket race exists; if it reproduces, all three languages close the window with one shared rule. +- Transport failures are not reported as TRUNCATED in any language. +- SHM regions are created only for sessions that negotiate SHM, or the cost is justified with evidence. (Moved to SOW-0055, decision 4.) + +## Analysis + +Sources checked: + +- SOW-0040, SOW-0046, SOW-0047 evidence; SOW-0043 facts (verified 2026-10-31). + +Current state: + +- None of these items has a reproduction test yet. + +Risks: + +- Error-code reclassification is visible to callers; Netdata treats any non-OK as failure, so the impact is in logs and diagnostics. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- EINTR: the raw send/recv helpers return the first failure. A signal that arrives before any data moves makes the call fail, and the session is torn down. SOCK_SEQPACKET sends whole messages or nothing, so a retry is safe. +- Stale socket: a server's socket file exists from `bind()` until `listen()`, and connects in that window are refused, exactly like a dead server's leftover file. A connect probe cannot tell them apart, so a second starter can unlink a live server's endpoint, and both servers then report success while the first is unreachable. +- Classification: the L2 clients have no error for a transport I/O failure, so they borrow `TRUNCATED` ("buffer too short"), and C also borrows `NOT_READY` for send failures. + +Evidence reviewed (re-verified 2026-10-31 against `6609157`): + +- C `netipc_uds.c:43-48` (`nipc_uds_raw_send`), `:50-78` (`nipc_uds_raw_send_iov`), `:80-83` (`nipc_uds_raw_recv`); callers `netipc_uds_handshake.c:77,159,168,220,307`, `netipc_uds_receive.c:174,242`, `netipc_uds_send.c:109,124,155`. Rust `posix.rs` `raw_send_iov`, `raw_send`, `raw_recv` (only the poll at `posix.rs:1050` handles EINTR). Go needs no change: the Go runtime installs its signal handlers with SA_RESTART, and the Go poll loops retry EINTR (`uds_receive.go:79`, `uds_handshake.go:53`). +- Stale recovery: C `netipc_uds_lifecycle.c:188-219`, bind `:259`, listen `:265`; Rust `posix.rs:1155-1195`; Go `uds_stale.go` (3 dials, 50 ms apart). +- Classification: C `netipc_service_posix_client_call.c:58` (SHM send -> `NOT_READY`), `:68` (UDS send -> `NOT_READY`), `:120` (SHM receive -> `TRUNCATED`), `:145` (UDS receive -> `TRUNCATED`); Windows `netipc_service_win_client_call.c:115,139`; Rust `client_call.rs:218,252,258,294,320,334,386-402,478,503,520,536,548,567`; Go `client_unix.go:186,193,213,254,257,270`, `client_windows.go:175,181,204,256,259,271`. `nipc_shm_receive` fails, apart from timeouts, only with `MSG_TOO_LARGE`, `BAD_HEADER` (zero-length message) and `BAD_PARAM`. + +Affected contracts and surfaces: + +- L1 POSIX send/recv (C, Rust); POSIX listener startup (C, Rust, Go) and a new per-service lock file; L2 error codes (new transport error in C, Rust, Go); `docs/level1-posix-uds.md`, `docs/level2-typed-api.md`, `docs/netipc-integrator-skill.md`. + +Existing patterns to reuse: + +- C link-time syscall wrappers (`tests/fixtures/c/shm_fallocate_wrap.c`, `--wrap=fallocate`) for deterministic EINTR injection with no production change. +- Test hooks: C `*_for_tests` setters, Rust `#[cfg(test)]` statics (`CONNECT_BUSY_HOOK`), Go package hooks (`connectBusyHook`). +- SOW-0044's peer probe for `DISCONNECTED`. + +Risk and blast radius: + +- EINTR retry: every UDS send/recv; behaviour changes only when a signal interrupts a call. +- Lock file: every POSIX server start; adds `{service}.lock` in the run directory. A server of an older version takes no lock, so the connect probe stays as a second guard. +- Error codes: callers that log the error see the transport error instead of "buffer too short" or "not ready"; retry behaviour is unchanged, because the new error follows the same reconnect-once path. The C enum gains a value at the end; existing values keep their numbers. + +Performance assessment: + +- EINTR: the raw send/recv helpers run per request on established UDS sessions (`netipc_uds.c:43-83`, Rust `raw_send_iov`/`raw_recv`). The added work is one `errno` comparison, made only after a failed syscall; the success path is unchanged. Not measurable, so no benchmark run. +- Lock file: listener startup only. +- Classification: error paths only. + +Sensitive data handling plan: + +- No secrets involved. Applies to this SOW, the docs, the integrator skill and code comments. + +Implementation plan: + +1. EINTR: retry in C `nipc_uds_raw_send`, `nipc_uds_raw_send_iov` and `nipc_uds_raw_recv`, and in Rust `raw_send_iov`, `raw_send` and `raw_recv`. Tests: a C link wrapper injects one EINTR into `send`/`sendmsg`/`recv`; a Rust `cfg(test)` hook does the same. +2. Stale socket: per-service lock (decision 2) in C, Rust and Go; remove Go's dial retries. Reproduction test in each language: a listener paused between bind and listen (test hook), and a second listener on the same service, which must fail with address-in-use while the first stays reachable. +3. Classification: a new transport error and the mapping in decision 3, in C, Rust and Go, POSIX and Windows. Update the tests that expect the old codes, and add tests for the new mapping. +4. Docs and integrator skill. + +Validation plan: + +- New tests fail on the previous code and pass now; Linux full ctest including interop, `cargo test`, `go test`; Windows ctest, Rust and Go; the 9 Netdata Linux targets; rustfmt, CI clippy gate, gofmt, go vet. + +Artifact impact plan: + +- AGENTS.md: no workflow change expected. +- Runtime project skills: none expected. +- Specs: `docs/level1-posix-uds.md` (EINTR, lock file, stale rule), `docs/level2-typed-api.md` (transport error). +- End-user/operator docs: same files. +- End-user/operator skills: `docs/netipc-integrator-skill.md` (transport error, lock file in the run directory). +- SOW lifecycle: SHM timing moved to SOW-0055. + +Open-source reference evidence: + +- None. + +Open decisions: + +- None; the decisions below are assistant-applied under the user's autonomy instruction (long-term-best). + +## Implications And Decisions + +1. EINTR is retried inside the raw send/recv helpers in C and Rust. Reason: SOCK_SEQPACKET moves whole messages or nothing, so a retry cannot duplicate or split data; fixing it once at the lowest layer covers handshakes and sessions. +2. Stale-socket rule shared by C, Rust and Go: a listener first takes an exclusive, non-blocking `flock` on `{run_dir}/{service}.lock`, created owner-only (0600) with `O_CLOEXEC | O_NOFOLLOW`, and holds it until the listener closes. If the lock is held, it fails with address-in-use. Once it holds the lock, a refused probe means a dead server, so it unlinks the file, binds and listens. The connect probe stays, for servers of older versions that take no lock. The lock file is not deleted at close: deleting it would reopen the race for a starter that already opened it. Go's three dials 50 ms apart are removed. Reason: the lock closes the bind-listen window completely; no probe timing can. `flock` locks are per open file description, so they work between processes and between listeners in one process, and the kernel releases them when the process dies. +3. New L2 error for transport I/O failures: C `NIPC_ERR_TRANSPORT` (appended to `nipc_error_t`), Rust `NipcError::Transport`, Go `protocol.ErrTransport`. Mapping, identical in all three: + - socket or pipe send/recv failure, peer gone per probe -> disconnected (unchanged); + - any other socket or pipe send/recv failure -> transport; + - SHM send failure other than message-too-large -> transport (disconnected when the probe finds the peer gone); + - SHM receive `MSG_TOO_LARGE` -> overflow, as a UDS receive over the limit already is; + - SHM receive of a message shorter than a header, including zero length -> truncated (it is a truncated message); + - SHM receive `BAD_PARAM`, or a READY client with no session -> transport. + Reason: callers and logs see what failed. The retry path is unchanged. +4. The SHM-timing item moves to SOW-0055. Reason: it needs a new step inside the L1 server handshake, in three languages on two platforms, and is best reviewed and bisected on its own. It touches connection setup only. + +## Plan + +As in the Pre-Implementation Gate implementation plan, in that order. + +## Execution Log + +- 2026-10-31, item 1 (EINTR): + - C `netipc_uds.c` `nipc_uds_raw_send`, `nipc_uds_raw_send_iov` and `nipc_uds_raw_recv` loop on EINTR. + - Rust `posix.rs` gains `retry_eintr` with a `#[cfg(test)]` thread-local injector (`eintr_inject`); `raw_send_iov`, `raw_send` and `raw_recv` use it. + - Tests: + - C `test_eintr_retried` (`tests/fixtures/c/test_uds.c`) uses a new link wrapper, `tests/fixtures/c/uds_eintr_wrap.c/.h` (`--wrap=send,sendmsg,recv`, non-Apple builds only), and injects 2, 2 and 4 EINTRs; + - Rust `test_eintr_retried` (`posix_tests.rs`). + - Both fail on the previous code. +- 2026-10-31, item 2 (endpoint lock): + - C `netipc_uds_lifecycle.c` `acquire_endpoint_lock` takes the lock before the stale check; every error path closes it, and `nipc_uds_close_listener` releases it after the unlink. The listener struct gains `has_lock`/`lock_fd`, and the test hook `nipc_uds_set_listen_hook_for_tests` runs between bind and listen. + - Rust: `UdsListener._lock`, `acquire_endpoint_lock`, `#[cfg(test)] LISTEN_HOOK`. + - Go: `acquireEndpointLock` in `uds_stale.go`, `Listener.lock` released last in `Close`, and a `listenHook`. `dialStaleCandidate` dials once (the three dials 50 ms apart are removed). + - Reproduction tests: C `test_listen_excludes_starting_server`, Rust `test_listen_excludes_starting_server`, Go `TestListenExcludesStartingServer`. + - Each pauses a listener between bind and listen and starts a second one. The second must fail with address-in-use while the first stays reachable. + - All three reproduce the race (the second listener unlinks the live endpoint) on the previous code, and pass now. + - `TestUdsListenFailsWhenRunDirMissing` now expects the lock-open failure detail, because the lock is the first file the listener creates. +- 2026-10-31, item 3 (classification): + - New error appended in all three languages: C `NIPC_ERR_TRANSPORT`, Rust `NipcError::Transport` ("transport I/O failed"), Go `protocol.ErrTransport`. + - Mapping applied in C (`netipc_service_posix_client_call.c`, `netipc_service_win_client_call.c`), Rust (`client_call.rs`: `socket_failure`, `session_failure`, `shm_send_failure`, `pipe_failure`, `map_uds_receive_error`, `map_np_receive_error`) and Go (`client_unix.go`, `client_windows.go`: `socketFailure`, `pipeFailure`, `shmSendFailure`): + - socket/pipe send/recv failure: disconnected when the probe finds the peer gone, transport otherwise; + - SHM send failure other than too-large: disconnected when the probe finds the peer gone, transport otherwise; + - SHM receive: too-large is overflow, `BAD_HEADER` (zero-length or corrupt slot) is truncated, anything else is transport; + - no session is transport. + - Other protocol errors (protocol violation, chunk mismatch, unknown message id) keep their previous mapping. + - Parity fix found on the way: Go mapped a baseline receive over the limit to truncated, while C and Rust mapped it to overflow. Go now maps it to overflow, on POSIX and Windows. + - Tests: + - New: C `test_send_failure_with_live_peer_is_transport` (`test_service.c`), Rust `test_send_failure_with_live_peer_is_transport_unix` and `test_transport_without_session_unix`, Go `TestUnixSendFailureWithLivePeerIsTransport`. In each, the client shuts down its own write side while the server keeps the session open and has already removed the endpoint, so the send fails with EPIPE, the probe finds the peer alive, and the reconnect fails. + - Updated to the new codes: C oversize SHM response (overflow); Rust `test_transport_without_session_windows` and `nipc_error_display_all_variants`; Go `TestUnixClientTransportWithoutSession`, `TestUnixClientTransportReceiveShmError`, `TestWinClientTransportWithoutSession` and `TestWinClientTransportReceiveWinShmError`. + - Mutation check: with the previous mapping restored, the C, Rust and Go tests fail (C gets `NOT_READY`, Rust and Go get truncated / "buffer too short"). +- 2026-10-31, item 4 (docs): + - `docs/level1-posix-uds.md`: endpoint lock, interrupted system calls, lifecycle step 1. + - `docs/level2-typed-api.md`: transport error and SHM mapping. + - `docs/netipc-integrator-skill.md`: transport error, lock file. + +## Validation + +Acceptance criteria evidence: + +- EINTR is retried in C and Rust send/recv paths: + - C `test_eintr_retried` injects EINTR into `send`, `sendmsg` and `recv` through the link wrapper and passes; + - Rust `test_eintr_retried` passes; + - both fail on the previous code. +- Stale-socket race: + - The reproduction tests in C, Rust and Go reproduced it on the previous code: the second listener unlinked the paused first listener's endpoint. + - With the shared lock rule, all three pass. The second listener fails with address-in-use and the first stays reachable. +- Transport failures are no longer reported as TRUNCATED: + - the mapping in decision 3 is applied in C, Rust and Go, on POSIX and Windows; + - the new live-peer tests in C, Rust and Go get the transport error, and they fail with the previous mapping (C `NOT_READY`, Rust/Go truncated); + - the remaining truncated returns are genuine short-buffer or short-message cases (same-failure search below). +- SHM resource timing: moved to SOW-0055 (decision 4). + +Tests or equivalent validation: + +- Linux: + - full ctest 32/32 (1 skipped by design: `test_shm_full_backing_store`), including the interop suites; + - `cargo test`: 382 lib tests plus 1 integration test passed; + - `go test ./...` ok; + - `go test -race` on the new Go tests ok. +- Windows (costa-win11, MSYS2): ctest 20/20; Rust lib 264 passed; Go all packages ok. +- Netdata Linux targets: all 9 pass (C, Rust, Go, interop). + - First run: x86_64-musl, i386-musl, armv7-musl, armv6-musl and aarch64-gnu passed. Four failed: + - armv7-gnu-time64 C `test_eintr_retried`: a test-wrapper gap. With `_TIME_BITS=64`, 32-bit glibc redirects `sendmsg` to `__sendmsg64` (`sys/socket.h`), so `--wrap=sendmsg` missed it. Fixed by wrapping `__sendmsg64` too (guarded by `__USE_TIME_BITS64`, the same approach as the existing `fallocate64` wrapper). The library's retry loop was correct. + - armv7-gnu, armv7-gnu-time64 and aarch64-musl Rust (qemu): about 30 tests timed out on the 10 s test-server listening wait (`test server is not listening`), and aarch64-musl C failed one helper start. Memory pressure (`some avg10`) was 20-60% during these runs; the cause was not attributed. The same aarch64-musl Rust binary passed 384/384 in 8 s once pressure dropped to about 0%. + - i386-gnu C live-peer test: two native `test_service` runs (mutation check) overlapped that target's `test_service`, which uses the same fixed `/tmp` fixture paths. This broke the rule against parallel C fixtures. + - Rerun, one target at a time with pressure sampling (max memory `some avg10` under 5%): i386-gnu, armv7-gnu-time64, armv7-gnu and aarch64-musl pass, with C 14/14 and Rust 384 passed on each. +- Linux native after the wrapper fix: ctest 32/32. +- Static checks: + - `cargo fmt --check` clean; + - CI clippy gate (`-D clippy::correctness -D clippy::suspicious`) clean for Linux and `x86_64-pc-windows-gnu`; + - `gofmt -l` clean; + - `go vet` clean on Linux. On `GOOS=windows`, `go vet` reports only the existing `unsafe.Pointer` notes in `transport/windows/shm.go`, a file this SOW does not touch. +- Windows C has no live-peer send-failure test: named pipes have no half-close. The Windows mapping is covered by the Rust and Go no-session tests and the invalid-WinSHM-context tests, and by the shared `pipe_failure` code path. + +Real-use evidence: + +- The interop suites (`test_uds_interop`, `test_shm_interop`, `test_service_interop`, cache interop) run C, Rust and Go servers and clients against each other, so servers that take the endpoint lock still serve clients of every language. +- Exclusion between servers of different languages is not tested directly. It follows from the identical lock path (`{run_dir}/{service}.lock`) and `flock` semantics: one lock per open file description, regardless of the language that opened it. Each language's own test proves the exclusion. + +Reviewer findings: + +- No external review requested; each change carries a reproduction or mutation-checked test. + +Same-failure search: + +- EINTR: `grep -rn "send(\|sendmsg(\|recv("` over the POSIX transports. Every raw call goes through the three C helpers or the three Rust helpers. Go is covered by the runtime's SA_RESTART handlers and its poll loops. +- Stale unlink: the C, Rust and Go stale-check functions are the only unlink sites for the socket path. +- Classification: + - `grep -rn "ErrTruncated\|NipcError::Truncated\|NIPC_ERR_TRUNCATED\|NIPC_ERR_NOT_READY"` over the service layers. + - The remaining truncated returns are encode-buffer checks (`increment`, `string_reverse`, `cgroups_snapshot`), messages shorter than a header, SHM `BAD_HEADER`, receive-side protocol errors, and Rust `response_payload` bounds checks on an already received message. + - The remaining `NOT_READY` returns are calls on a client that is not READY. All of these keep their meaning. + +Performance assessment: + +- Unchanged from the gate. The EINTR check runs only after a failed syscall, the lock is taken at listener start, and the classification changes run only on error paths. No established-session hot path changed (`netipc_uds.c` success paths and Rust `raw_send_iov`/`raw_recv` success returns are unchanged), so no benchmark run. + +Sensitive data gate: + +- This SOW, the docs, the integrator skill and the code comments contain no secrets, credentials, customer or personal data, or private endpoints. The Windows host is named by lab hostname only. + +Artifact maintenance gate: + +- AGENTS.md: no change; no workflow changed. +- Runtime project skills: no change; vendoring steps unaffected. +- Specs: + - `docs/level1-posix-uds.md`: endpoint lock, interrupted system calls, lifecycle step 1; + - `docs/level2-typed-api.md`: transport error and SHM mapping. +- End-user/operator docs: the same `docs/` files; there is no other operator-facing surface. +- End-user/operator skills: `docs/netipc-integrator-skill.md` (transport error, lock file). +- SOW lifecycle: + - pending -> current -> done in this commit; + - SHM timing is split to SOW-0055 (pending), committed together with this SOW. + +## Outcome + +- Signals no longer break UDS sessions or handshakes in C and Rust. +- Two POSIX servers that start at once for one service can no longer both claim the endpoint. +- L2 clients report a distinct transport error instead of "buffer too short" or "not ready" for I/O failures. SHM responses over capacity are reported as overflow in all three languages. + +## Lessons Extracted + +- A connect probe cannot tell a dead server's socket file from a live server between `bind()` and `listen()`. Only an exclusive lock held across both closes that window. A lock file must outlive its holder, or two starters can lock different inodes. +- Self-inflicted `shutdown(SHUT_WR)` on the client fd plus a server that removed its endpoint gives a deterministic "send fails, peer alive, reconnect fails" case, with no fault injection in production code. +- glibc's 64-bit `time_t` mode renames socket calls that carry time-bearing control messages (`sendmsg` -> `__sendmsg64`). Link-time wrappers must cover the redirected names, as `fallocate64` already showed for 64-bit file offsets. +- Never run a native C fixture while a Linux-target run is active: the fixed `/tmp` paths collide and produce false failures. +- Classification work exposes parity drift: Go mapped baseline receive-over-limit to truncated while C and Rust mapped it to overflow. Compare all three mappings side by side when touching one. + +## Followup + +- SHM regions for every accepted connection: SOW-0055. + +## Regression Log + +- 2026-11-01: endpoint lock held after close by a forked child; fixed the same day. See Regression - 2026-11-01. + +## Regression - 2026-11-01 + +What broke: + +- The Outcome claims "two POSIX servers that start at once for one service can no longer both claim the endpoint", and the endpoint is free again once its listener closes. Rust `transport::posix::tests::test_listen_excludes_starting_server` fails intermittently on Linux with "endpoint free after close: AddrInUse" (`src/crates/netipc/src/transport/posix_tests.rs:591`): a listen right after the previous listener closed finds the lock still held. + +Evidence: + +- Seen in SOW-0049 validation: once under Rust 1.91 (6 reruns passed), once under stable (2026-11-01, 392 passed, 1 failed). +- Root-cause model: flock locks belong to the open file description. A child forked by another thread (here `std::process::Command` in a concurrent robustness test, `src/crates/netipc/src/service/raw_robustness_unix_tests.rs:131`) shares that description between fork and exec. `O_CLOEXEC` closes its copy only at exec, so closing the listener's lock fd in that window leaves the lock held until the child execs. +- Probe (2026-11-01, Linux): lock a file, fork a child that keeps the fd, then close it in the parent: a new `flock(LOCK_EX|LOCK_NB)` fails (held). With `flock(LOCK_UN)` before the close, it succeeds (free). +- All three languages close the lock without unlocking: C `nipc_uds_close_listener` (`src/libnetdata/netipc/src/transport/posix/netipc_uds_lifecycle.c:651-655`) and the listen error paths (`:371-404`); Rust drops the `File` (`src/crates/netipc/src/transport/posix.rs:467`); Go `lock.Close()` (`src/go/pkg/netipc/transport/posix/uds_listener.go:49-77`, `:208-211`). +- Production reach: any multi-threaded server process that spawns children while it closes and reopens a listener. The window is fork-to-exec, so the effect is a transient `AddrInUse` on the next listen. + +Why the previous validation missed it: + +- The race needs a concurrent fork inside a microsecond window; SOW-0052's runs did not hit it, and no test forces it. + +Repair plan (user decision 2026-11-01: option A, "4a"): + +- Release the endpoint lock with an explicit `flock(LOCK_UN)` before closing its fd, on every release path, in C, Rust and Go. An unlock releases the lock on the open file description, so a forked child's copy of the fd no longer keeps it. +- Rejected: serializing process spawns in tests (hides the production race) and retrying listen on `AddrInUse` (sleeps and retries in production; delays real conflicts). +- Tests, deterministic in each language: a child process that holds an inherited copy of the lock fd while the listener closes; a new listen must succeed at once. +- Spec: `docs/level1-posix-uds.md` "Endpoint lock" states the explicit unlock. +- Performance assessment: listener close and listen error paths only; nothing on the established-session path, so no benchmark run. + +Implementation: + +- C: `release_endpoint_lock()` (`flock(LOCK_UN)` then `close`) in `src/libnetdata/netipc/src/transport/posix/netipc_uds_lifecycle.c`, used by `nipc_uds_close_listener` and all four listen error paths. +- Rust: `EndpointLock` wraps the lock `File` and unlocks in `Drop` (`src/crates/netipc/src/transport/posix.rs`), so the listener drop and every early return in `UdsListener::bind` release it the same way. +- Go: `releaseEndpointLock()` (`src/go/pkg/netipc/transport/posix/uds_stale.go`), used by `Listener.Close` and all four listen error paths (`uds_listener.go`). +- Spec: `docs/level1-posix-uds.md` "Endpoint lock" states the unlock-before-close rule. + +Validation: + +- Reproduction tests, one per language, each a child process holding a copy of the lock's open file description while the listener closes: + - C `test_listen_close_releases_lock_held_by_child` (`tests/fixtures/c/test_uds.c`, fork without exec, child blocks on a pipe); + - Rust `test_listen_close_releases_lock_held_by_child` (`transport/posix_tests.rs`, same with `libc::fork`, async-signal-safe calls only in the child); + - Go `TestListenCloseReleasesLockHeldByChild` (`transport/posix/uds_more_edge_test.go`, the lock fd handed to `/bin/sh -c 'read x'` through `ExtraFiles`; Go cannot fork without exec). + - Each fails without the unlock ("endpoint free while the child holds the lock fd": C FAIL, Rust `Err(AddrInUse)`, Go "address already in use by live server") and passes with it. No sleeps: the child is released by closing its pipe. +- Linux x86_64: CTest 32/32 (1 skipped, root-only, unaffected), `test_uds` 230/230, every POSIX interop script, `cargo test` stable and 1.91.0 (394 passed each), `cargo fmt --check` with both toolchains, CI clippy gate for Linux and `x86_64-pc-windows-gnu`, `go vet` and `go test ./...`. The full Rust lib suite passed 20 runs in a row; before the fix, `test_listen_excludes_starting_server` failed about once in seven. +- macOS test host: CTest 24/24, `cargo test` 325 passed, `go test ./...` ok, the five macOS interop scripts pass. +- FreeBSD 14.3: CTest 24/24, `cargo test` 324 passed, `go test ./...` ok, the same five interop scripts pass. +- Netdata Linux targets (`tests/run-linux-target.sh `, C, Rust, Go and interop): all 9 pass (x86_64-musl, i386-gnu, i386-musl, armv7-gnu, armv7-gnu-time64, armv7-musl, armv6-musl, aarch64-gnu, aarch64-musl), each started after memory PSI full avg10 stayed under 5% for a minute (peak during the runs: 0.63). +- Windows: not run. The changed code is POSIX-only (`netipc_uds_lifecycle.c`, `transport/posix.rs`, Go `transport/posix`, none built on Windows); the Windows clippy gate compiles the crate for that target. +- Performance assessment: listener close and failed-listen paths only; nothing on the established-session path changes, so no benchmark run. +- Same-failure search: `flock` is used only for the endpoint lock (`grep -rn flock` over `src/`); no other lock fd in C, Rust or Go is closed without an unlock. +- Sensitive data gate: code paths, test names and command output only; hosts named by role. + +Artifact updates: + +- AGENTS.md: no change; no workflow or command changed. +- Runtime project skills: no change; vendoring preflight is unaffected. +- Specs: `docs/level1-posix-uds.md` "Endpoint lock"; `.agents/sow/specs/` holds no lock rule. +- End-user/operator docs: no change; README and the integrator skill do not describe lock release. +- End-user/operator skills: `docs/netipc-integrator-skill.md` unaffected (no lock detail). +- SOW lifecycle: regression fixed; SOW back to `completed` in `done/`. + +Lessons: + +- flock locks follow the open file description, and `O_CLOEXEC` closes only at exec. A lock that must end at close needs an explicit unlock in any process that may fork from another thread. diff --git a/.agents/sow/done/SOW-0053-20261031-deterministic-correctness-suite.md b/.agents/sow/done/SOW-0053-20261031-deterministic-correctness-suite.md new file mode 100644 index 00000000..2851ca5b --- /dev/null +++ b/.agents/sow/done/SOW-0053-20261031-deterministic-correctness-suite.md @@ -0,0 +1,250 @@ +# SOW-0053 - Deterministic Correctness Suite + +## Status + +Status: completed + +Sub-state: completed 2026-10-31; only the K4 tests, owned by SOW-0051, still fail. + +## Requirements + +### Purpose + +Make the correctness suites of C, Rust and Go fast and deterministic, so every fix is validated in seconds, and keep fuzz, stress and benchmark suites out of everyday validation. + +### User Request + +User policy (2026-10-31): + +1. Correctness tests should take seconds to complete. +2. Any test that requires synchronizing threads and processes must be done without long sleeps. +3. Fuzz, stress, benchmarks, etc. must be run only before vendoring and only if needed. + +Earlier in the same discussion: correctness means one go, no flakiness, no sleeps, no retries; correctness tests may include multi-threaded and interop tests, but nothing that is not purely validation that the code works. The user does not want to handle the details; the assistant applies the policy. + +### Assistant Understanding + +Facts (measured 2026-10-31 on the Linux host, branch `sow-0040-robustness-repro-tests`): + +- Linux ctest: 51 entries, 337 s wall with `-j4`. Non-correctness entries: 12 Go fuzz targets at `-fuzztime=20s` (~21 s each, `CMakeLists.txt:246-265`), `fuzz_protocol_30s` (`CMakeLists.txt:241`), `test_stress` (61 s; "Long-running stability (60 seconds)", `tests/fixtures/c/test_stress.c:712`), `test_stress_go` (62 s, `CMakeLists.txt:694`), `test_stress_rust` (31 s, `CMakeLists.txt:709`), `test_lookup_scale_*_interop` (load test, 8192 items), `test_windows_bench_stability_policy` (benchmark tooling). +- Go `service/raw` takes 137 s: `TestStressLongRunning60s` 60 s; lookup tests with 2 s handler sleeps (`TestLookupProactiveRequestSplit` 14.8 s, `TestLookupLogicalLimits` 4.5 s, `TestLookupCallsReconnectForRequestCapacityGrowth` 4.2 s, `TestLookupTimeoutDuringFollowupSubcall` 4.0 s); K4 tests 6 s each; `TestRobustSilentClientReleasedAtHandshakeDeadline` 5.1 s; `TestUnixShmAttachFailureFallsBackToBaseline` 5.0 s (production SHM attach retry). +- Sleep calls in test code: C 208, Rust 138, Go 107. Heaviest: `src/crates/netipc/src/service/raw_unix_tests.rs` (52), `tests/fixtures/c/test_service.c` (41), `src/crates/netipc/src/transport/posix_tests.rs` (31), `tests/fixtures/c/test_chaos.c` (29), `tests/fixtures/c/test_uds.c` (21). +- Sleep patterns: polling for server readiness (`raw_unix_tests.rs:58-78`, `test_service.c:5541`); fixed waits for background completion (`test_service.c:5301`, `raw_unix_tests.rs:3748`, `:4472`); waiting out production timeouts (handshake 5000 ms, SHM attach retry 5000 ms, stop grace 1000 ms, K4 6 s); handlers that sleep to force timeouts (`raw_unix_tests.rs:1396`, `:1454`); retry loops (`servedWithin` / `served_within`); duration-driven stress and chaos loops. +- SOW-0043 already excludes fuzz, stress and scale suites from `tests/run-linux-target.sh` (minimal policy step 3 for its own validation). + +Inferences: + +- The root causes are missing deterministic synchronization points (no listening signal before `run()` blocks, no completion signal for stop/close), production timeouts that tests cannot shorten, and non-correctness suites registered next to correctness suites. + +Unknowns: + +- None that investigation cannot resolve. + +### Acceptance Criteria + +- Correctness suites in every language complete in seconds on the Linux host; Netdata Linux targets under QEMU and Windows stay proportional (no test waits for a fixed duration). +- No correctness test uses a sleep to wait for another thread or process, and no retry loop stands in for an event; every wait is on an explicit signal, with a generous upper bound that only fires on a real bug. +- Timeout behaviour is still proven, with test-only short timeouts instead of production values. +- Fuzz, stress, scale and benchmark suites run only from explicit scripts, before Netdata vendoring when needed; ctest default runs, `run-linux-target.sh` and the Windows validation scripts run correctness only. +- No assertion is weakened: each rewritten test proves the same behaviour. +- AGENTS.md Test Cadence and the vendoring skill state the policy. + +## Analysis + +Sources checked: + +- Linux ctest timing log, Go `-v` per-test timings, sleep counts per file (2026-10-31). + +Current state: + +- See Facts. + +Risks: + +- Rewriting hundreds of tests can silently drop coverage; mitigated by keeping each test's assertions and by the coverage scripts. +- Test-only timeout knobs must not change production defaults or the public API. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- Tests wait because the code gives them nothing to wait on: Rust and Go `run()` bind the listener internally and signal nothing, so tests poll the socket path or connect in retry loops; C `nipc_server_init` already listens, so its post-start sleeps are pure waste. +- Tests that prove timeout behaviour wait out production values (handshake 5000 ms, SHM attach retry 5000 ms, stop grace 1000 ms, slot wait 200 ms, 100 ms stop-poll slice per teardown) because those values are compile-time constants. +- Handlers sleep to hold a request in flight instead of waiting on a release signal. +- Non-correctness suites (fuzz, stress, chaos, scale, benchmark policy) are registered in the default ctest set and in the Rust/Go default test runs. +- One hidden bug: Go `waitUnixServerReady` (`src/go/pkg/netipc/service/raw/client_test.go:40-53`) probes with a 4096-byte request limit that small-limit servers reject, so it silently spends its full 2 s and returns as if ready. + +Evidence reviewed (baseline 2026-10-31, Linux host, commit `2ac2011`): + +- Correctness ctest set (`-j8`, fuzz/stress/scale/bench excluded): `test_service_rust` 45.9 s (serial `--test-threads=1`), `test_robustness` 24.8 s, `test_service` 23.0 s, `test_shm_rust` 10.8 s, `test_chaos` 6.5 s, service interop 4.9 s each; everything else under 3 s. +- Go `go test ./...` without stress: `service/raw` 76 s, other packages under 0.5 s. Top tests: `TestLookupProactiveRequestSplit` 14.8 s, K4 tests 6 s each (SOW-0051), `TestRobustSilentClientReleasedAtHandshakeDeadline` 5.1 s, `TestUnixShmAttachFailureFallsBackToBaseline` 5.0 s, `TestLookupLogicalLimits` 4.5 s, `TestLookupCallsReconnectForRequestCapacityGrowth` 4.2 s, `TestLookupTimeoutDuringFollowupSubcall` 4.0 s; about 200 tests at 0.2-0.3 s each (100 ms stop-poll slices at teardown). +- Per-language inventories (sleep sites and causes) summarized in Facts; C: 260 sleep calls in 27 fixtures and 11 scripts; Rust: 140; Go: about 110. +- Existing patterns: `NIPC_INTERNAL_TESTING` `*_for_tests` entrypoints (`netipc_service.h:418-440`), Windows C tests' `ready_event` (`test_win_service.c` `start_server_named`), `tests/run-extended-fuzz.sh` for long fuzz runs. + +Affected contracts and surfaces: + +- No wire change. One public addition: a listening signal on Go and Rust managed servers (decision 4). Internal per-instance timing values on servers and clients (defaults equal today's constants), settable only through test entrypoints: C `*_for_tests` functions under `NIPC_INTERNAL_TESTING`, Rust `pub(crate)`/`#[doc(hidden)]` setters, Go unexported fields. +- Internal ready signal in Rust and Go managed servers (listener bound and accept loop running). +- Test code in C, Rust, Go (POSIX and Windows); interop scripts and fixtures (READY printed after bind); `CMakeLists.txt` registration; `tests/run-linux-target.sh`; Windows validation scripts; new `tests/run-extended-stress.sh`. + +Existing patterns to reuse: + +- `NIPC_INTERNAL_TESTING` entrypoints; Windows `ready_event` and entered/release events in `test_win_service_guards.c`; `tests/run-extended-fuzz.sh` structure and `run-low-priority.sh`. + +Risk and blast radius: + +- Rewriting many tests can drop coverage silently. Mitigation: each rewrite keeps its assertions; line coverage per language is compared before and after (`tests/run-coverage-{c,rust,go}.sh`). +- Per-instance timing fields touch production code paths (read where the constants were read). Defaults are the existing constants, so behaviour is unchanged; interop and all suites rerun. +- Shorter test timeouts can make tests flaky under load. Mitigation: shortened values stay well above scheduling noise (handshake deadline, attach window, grace: 200-300 ms; poll slice 10 ms), and every wait that only fires on a bug keeps a generous upper bound. +- Performance assessment: the per-session receive loop reads its poll slice from the server instance instead of a constant (C `netipc_service_posix_server_session.c`, Rust `server_session_unix.rs:42,65`, Go `server_unix.go:257,279`, Windows equivalents). That is one field load per poll wakeup, not per message encode/decode, and the poll timeout value is unchanged in production. No benchmark run needed; recorded again at close with the final diff. + +Sensitive data handling plan: + +- No secrets involved. Applies to this SOW, AGENTS.md, the vendoring skill, docs and code comments. + +Implementation plan: + +1. Separation: CMake option `NETIPC_EXTENDED_TESTS` (default OFF) registers fuzz, stress, chaos, scale and benchmark-policy ctest entries; executables still build. Rust stress/scale tests `#[ignore]` with a reason naming the extended script; Go stress/scale tests behind build tag `netipc_extended`. New `tests/run-extended-stress.sh` runs them; `tests/run-extended-fuzz.sh` keeps fuzz. Drop the exclusion lists from `tests/run-linux-target.sh` and the Windows scripts. +2. Go: per-instance server timings (handshake, stop grace, slot wait, poll slice) and client SHM attach window; internal listening channel; test helpers wait on it and fail loudly; single connects; handler release channels; fix `waitUnixServerReady`. +3. Rust: same mechanisms; parallel-safe tests so ctest entries drop `--test-threads=1` where paths are unique. +4. C POSIX and Windows: `nipc_server_set_timings_for_tests` / `nipc_client_set_timings_for_tests`; delete post-start/post-join sleeps; handler entered/release; blocking EOF waits instead of sleeps. +5. Interop: fixtures print READY after bind; scripts block on READY without sleeps. +6. AGENTS.md Test Cadence and the vendoring skill state the policy; integrator skill validation commands checked. + +Validation plan: + +- Per milestone: the touched language's suites on Linux (Windows for Windows parts). +- At close: full correctness ctest, Rust, Go (`-race`), interop on Linux; all Netdata Linux targets; Windows C/Rust/Go; coverage before/after per language; each suite run twice to show stable results; extended scripts run once to prove they still work. + +Artifact impact plan: + +- AGENTS.md: Test Cadence gains the correctness/extended split and the commands. +- Runtime project skills: `project-netdata-vendoring` preflight runs `tests/run-extended-fuzz.sh` and `tests/run-extended-stress.sh` when the vendored change can affect them. +- Specs: none (tests and internal hooks only); confirmed at close. +- End-user/operator docs: none expected; checked at close. +- End-user/operator skills: `docs/netipc-integrator-skill.md` validation commands checked. +- SOW lifecycle: runs before SOW-0051; K4 tests stay at their current duration until SOW-0051 gives clients deadlines. + +Open-source reference evidence: + +- None. + +Open decisions: + +- None; the user set the policy and delegated the details. Mechanism choices are recorded under Implications And Decisions. + +## Implications And Decisions + +Assistant-applied decisions (2026-10-31, under the user's delegation of test details): + +1. Timing overrides are per instance, not global, so tests running in parallel cannot affect each other, and production defaults stay the existing constants. Classification: long-term-best. +2. Non-correctness suites stay in the repository and run from explicit extended scripts (`tests/run-extended-fuzz.sh`, new `tests/run-extended-stress.sh`); default ctest, cargo and go test runs contain correctness only. Classification: long-term-best. +3. Fuzz is not correctness (user): Go fuzz files build only with the `netipc_extended` tag and Rust proptests are `#[ignore]`d; `tests/run-extended-fuzz.sh` runs them. Go stress tests move behind the same tag, Rust stress tests are `#[ignore]`d, C `test_chaos` runs its deterministic malformed-input cases by default and its randomized attacks with `--extended`. +4. Ready signal (revises the gate's "internal" plan): the Go wrapper packages and the interop fixtures (a separate Go module and separate Rust binaries) cannot reach unexported state, so the Go and Rust managed servers gain a small public listening signal (Go `Server.Listening() <-chan struct{}` on raw and the typed wrappers; Rust an equivalent on `ManagedServer` and wrappers). Rationale: C already guarantees this, because `nipc_server_init` listens before it returns; Rust and Go had no way to know when clients can connect, which also affects real callers that start a server and then spawn clients. Alternatives rejected: polling the socket from fixtures (a retry loop, the pattern this SOW removes); a public bind/run split (larger API change). Timing overrides stay internal (decision 1). Classification: long-term-best. Docs (`docs/level2-typed-api.md`) document the signal. + +5. Busy named pipe (bug found while removing client retry loops): a Windows server has no free pipe instance between accepting one client and creating the next, and connects in that window failed at once with `ERROR_PIPE_BUSY` in all three clients (C `nipc_np_connect`, Rust `NpSession::connect`, Go `Connect`). The test retry loops hid it. Fix: on `ERROR_PIPE_BUSY`, wait once with `WaitNamedPipeW` for at most 1000 ms (`NIPC_NP_CONNECT_BUSY_WAIT_MS`, Rust `CONNECT_BUSY_WAIT_MS`, Go `ConnectBusyWaitMs`), then try once more. A bounded single wait keeps a refresh from blocking indefinitely; client call deadlines in general belong to SOW-0051. Reproduction tests drive the window deterministically through a test-only busy hook (C `nipc_np_set_connect_busy_hook_for_tests` under `NIPC_INTERNAL_TESTING`, Rust `#[cfg(test)] CONNECT_BUSY_HOOK`, Go unexported `connectBusyHook`): the first client holds the only instance, the server accepts only after the second client reported busy. Classification: long-term-best. +6. C reconnect drain removed: the C-only 150 ms `reconnect_drain_ms` sleep before a reconnect (ops field and its uses) is gone. Go and Rust never had it; it was a downstream port that SOW-0006 flagged as possibly hiding a race, and SOW-0043's two-poll-slice worker-slot wait now covers that race. Classification: long-term-best. +7. C listening signal: none needed. `nipc_server_init` binds the listener (POSIX `nipc_uds_listen`; Windows `nipc_np_listen` creates the first instance), so a C caller may connect as soon as init returns. Go and Rust bind inside `Run`, hence decision 4. +8. MSYS test threads (bug found while removing retries): under load, a thread that a C fixture started with `CreateThread` on the MSYS2 runtime sometimes never ran, and the process then died silently with exit status 0 (no `atexit`, no crash event). The interop retry loops had masked it as a slow server. Evidence (costa-win11, `ctest -j8` background load): with `CreateThread`, 1-5 silent server deaths per 120 starts across about 8 runs; with that one thread started by `pthread_create`, 0 in 240. Cygwin does not support threads it did not start (cygwin mailing list, 2009-10, "Thread related crash"). Fix here: `test_create_thread()` in `tests/fixtures/c/test_sync.h` (same arguments as `CreateThread`) starts test threads with `pthread_create` under `__MSYS__` and returns an event that is signaled when the thread function returns, so callers keep `WaitForSingleObject`/`CloseHandle`; every C fixture uses it. The library's own `CreateThread` use under `__MSYS__` and the C benchmark driver are tracked by SOW-0054, because the library needs real Win32 thread handles for `CancelSynchronousIo`. +9. Interop server stop is identity-checked: `tests/interop-lib.sh` used to send `taskkill //T //F` to the server's Windows PID even after the server had exited; a recorded run showed kills aimed at PIDs that no longer existed, and a reused PID would have killed an unrelated process (and `/T` its children). `interop_kill` now kills only when `/proc/PID/exename` (MSYS) or `/proc/PID/exe` (Linux) still names the started binary, and no longer uses `/T`. Found in validation: under QEMU binfmt (ARM Netdata targets) `/proc/PID/exe` names the interpreter, so the check never matched, the server was never killed and the script blocked in `wait` (armv7-gnu stalled 30 min after the first pair). The check now also accepts `argv[1]` from `/proc/PID/cmdline`, where binfmt puts the program path. + +## Plan + +Written at activation. + +## Execution Log + +2026-10-31: + +- Separation (milestone 1): `NETIPC_EXTENDED_TESTS` CMake option (default OFF) gates fuzz, stress, randomized chaos, lookup-scale and benchmark-policy ctest entries; `test_chaos` runs its deterministic cases by default and its randomized attacks with `--extended`; Go fuzz files and stress tests build only with the `netipc_extended` tag (stress helpers used elsewhere moved to `large_helpers_unix_test.go`); Rust proptests and `test_stress_*` are `#[ignore]`d; `tests/run-extended-fuzz.sh` passes the tag and `--ignored`; new `tests/run-extended-stress.sh`; `tests/run-linux-target.sh` drops its exclusion lists. +- Go (milestone 2): per-instance `serverTimings` (poll slice, stop grace, handshake deadline; slot wait derived) and `Client.shmAttachTimeout`, defaults equal to the old constants; public `Listening()` on `raw.Server` and the typed wrappers (decision 4); nil-by-default test hooks `sessionExited` and `handshakeStarted`. Tests: `runServerForTest` (test timings, waits for `Listening`, fails loudly), single connects instead of retry helpers, release channels instead of handler sleeps, session-exit waits instead of post-close sleeps, SHM attach waits on a created signal, the malformed-batch SHM tests read the server's error reply, the robustness helper process reports when its handler holds a request. Hidden test defects fixed: `waitUnixServerReady` probe rejected by small-limit servers (silent 2 s waits); `TestClientRefreshFromBroken` always took its silent early return (a failed reconnect leaves NOT_FOUND, not BROKEN), so its refresh-from-BROKEN half never ran; it now reaches BROKEN through a call timeout. +- Go results (Linux host): `service/raw` 76 s -> 17 s (12 s are the two K4 tests owned by SOW-0051); `-race` clean apart from K4. Windows (`costa-win11`): `service/raw` 52 s -> 12 s (6 s K4). +- Rust (milestone 3): `ServerTimings` on `ManagedServer` (defaults equal the old constants), `RawClient::shm_attach_timeout`, public `ListeningSignal` from `listening_signal()` on the raw and typed servers (re-exported as `netipc::service::ListeningSignal`), `#[cfg(test)]` hooks for session exit and handshake start. A detached `StopHandle` backs `stop()` so a test can stop a server whose `run()` owns it (Windows needs the listener wake). Test helpers `spawn_for_test` / `RunningTestServer` / `TestEvents` replace ready-flag polling, `/proc/net/unix` polling, fixed post-start sleeps and the manual Windows wake connection. Raw listeners bind before their thread starts. Handler sleeps became release channels; post-close sleeps became session-exit waits; retry loops became single attempts. The attach-refused robustness tests keep the production 5 s attach budget, which is what they guard. +- Rust CTest gap found and fixed: the `test_service_typed_rust` filter `service::cgroups::tests::` matched no test, so the typed facade tests never ran under CTest; filters are now disjoint (`protocol::`, `transport::posix::` (all of `transport::` on Windows), `transport::shm::`, the three facade modules, `service::raw::`) and run in parallel without `--test-threads=1`. +- Rust results: Linux CTest Rust entries 46 s serial -> 6.3 s parallel, twice, only K4 failing; `cargo test --lib` 6.0 s; Windows `cargo test --lib` 6.0 s, only Windows K4 failing; Rust 1.91.0 builds; clippy CI gates clean on Linux and Windows targets. +- C (milestone 4): per-instance server `poll_ms`, `stop_grace_ms`, `handshake_ms` and client `shm_attach_timeout_ms` (defaults equal the old constants, set through `nipc_server_set_timings_for_tests`, `nipc_client_set_shm_attach_timeout_for_tests` under `NIPC_INTERNAL_TESTING`), a server test event hook (handshake started, session exited), and `tests/fixtures/c/test_sync.h` (condition-variable flags and counters, server event waits, test timings). POSIX suites converted: `test_service` 23 s -> 3.4 s, `test_robustness` 24.8 s -> 1.5 s plus K4, `test_chaos` 4.6 s -> 0.1 s, `test_multi_server` 2.0 s -> 0.22 s; the rest were already fast. Linux ctest 13.5 s total, only K4 failing. Windows suites converted the same way; Windows ctest about 75 s wall at `-j8`, only K4 failing, three runs in a row after a clean rebuild. +- Interop (milestone 4): new `tests/interop-lib.sh` (`interop_start_server` blocks on the server's READY line through process substitution; `interop_stop_server`/`interop_wait_server`); all POSIX and Windows interop scripts use it, with single client attempts. C fixtures print READY after init (decision 7); Go fixtures after `Listening()`; Rust fixtures after `listening_signal().wait()`. Windows interop costs about 0.5 s per pair, all of it process creation (per-pair timing recorded on costa-win11). +- Hidden test defects fixed in milestone 4: `test_win_service_extra.c` still referenced the removed drain field; the interop retry loops masked decisions 5 and 8. +- Accepted stimulus sleeps (they set up the condition under test, the outcome does not depend on their length): C `test_robustness` N1 300 ms handler, `test_shm_timeout` 50 ms child delay and its 1100 ms timeout measurement (it measures timeouts), `test_win_shm` 25 ms delayed send, `test_cache` 100 us reader contention, interop `HANDLER_DELAY_MS=300` slow pass (it must outlast the 100 ms client abort-poll slice), the 10 s/120 s fixture self-stop timers (safety bound only, never reached in a passing run); randomized `test_chaos --extended` keeps its sleeps (stress, not correctness); Rust N1 300 ms handler (Linux, Windows), `test_poll_fd_eintr_returns_timeout` 50 ms signal delay, transport SHM/WinSHM 20-50 ms sender delays, `test_receive_abort_after_poll_slice_reports_aborted` 300 ms and `send_response_after` delays (they must outlast the 100 ms production abort poll slice), the 2 ms cache epoch sleep, 100 us reader contention; `transport::shm::tests::test_receive_timeout` keeps its 1.1 s case because it covers the timespec seconds field. + +## Validation + +Acceptance criteria evidence: + +- Correctness suites in seconds (Linux host): ctest 13.5-16 s (was 337 s), `cargo test --lib` 6.0 s, Go `service/raw` 17 s (was 137 s), other Go packages about 1 s each. Most of what is left is the K4 tests (6 s each, owned by SOW-0051). Netdata Linux targets: ctest about 20 s natively (x86_64-musl, i386-gnu, i386-musl), 23.5 s on armv7-gnu under QEMU. Windows: ctest about 75 s wall at `-j8` (process creation), `cargo test --lib` 6 s, Go `service/raw` 11 s. +- No sleep-based synchronization or retry loop in correctness tests: same-failure search over C, Rust and Go test code (`usleep|nanosleep|sleep|Sleep(`, `thread::sleep`, `time.Sleep(`, `servedWithin|served_within|retry`). What remains is the accepted stimulus sleeps listed in the Execution Log, sleeps inside `#[ignore]`d or `netipc_extended` stress tests, and these Go Windows stimulus sleeps: `transport/windows/shm_test.go:498,653` and `pipe_edge_test.go:201` (let a receive/accept block before close; the result is the same if close comes first), `shm_test.go:759` (spurious-wake stimulus), `pipe_integration_test.go:1061` (connect delay; the listener already exists), `service/raw/shm_windows_test.go:109` (stays idle across server poll slices), `cache_test.go:32`, `more_windows_test.go:911`, `uds_more_edge_test.go:250` (clock advance). Accepted exception: `shm_test.go:745-753` polls the receiver's shared-memory waiting flag, which no event exposes; the bound only expires on a bug. +- Timeout behaviour still proven with per-instance test timings (decision 1); assertions were kept per rewritten test. +- Non-correctness suites run only from `tests/run-extended-fuzz.sh` and `tests/run-extended-stress.sh` (decisions 2-3). The fuzz script ran once with a 2 s fuzz time: 15 passed, 0 failed. It was not run at full length, because this SOW changes no decoder; per Test Cadence it runs before vendoring when the vendored changes touch decoding. +- `tests/run-extended-stress.sh` ran once to prove the extended split: 7/7 passed (`test_stress`, `test_stress_go`, `test_stress_rust`, `test_chaos_extended`, `test_lookup_scale_interop`, `test_lookup_scale_shm_interop`, `test_windows_bench_stability_policy`), 158 s. +- AGENTS.md Test Cadence and the vendoring skill state the policy. + +Tests: + +- Linux host: ctest twice, only K4 failing (`test_robustness`, `test_service_rust`); `cargo test --lib` twice, 376 passed, 2 K4 failed, 20 ignored (fuzz/stress); Go twice, only the 2 K4 tests failing; POSIX interop ctests pass; `test_shm_full_backing_store` passed with `NIPC_REQUIRE_TMPFS_TEST=1` under sudo (local runs skip it without privileged namespaces; CI enforces it). +- Netdata Linux targets (`tests/run-linux-target.sh`, all 9: x86_64-musl, i386-gnu, i386-musl, armv7-gnu, armv7-gnu-time64, armv7-musl, armv6-musl, aarch64-gnu, aarch64-musl): C, Rust, Go (gnu targets; musl targets skip pure Go by design) and interop; only the K4 tests fail. ctest 20-24 s per target. The ARM targets ran after the QEMU identity-check fix (decision 9). +- Windows (costa-win11): ctest 3 runs after a clean rebuild, each 19/20 with only K4 (`test_win_robustness`); `cargo test --lib` twice, 261 passed, 1 K4 failed, 14 ignored; Go twice, only `TestWinRobustClientBoundedWhenServerNeverSendsHelloAck` (K4). +- Busy-pipe reproduction tests (decision 5) pass in C, Rust and Go on Windows. +- Rust 1.91.0 builds on Linux and Windows (msvc); `cargo fmt --check` and the clippy CI gates are clean; gofmt and go vet clean apart from the existing `unsafe.Pointer` notes in `shm.go`. +- Coverage: Go 89.7% statements (baseline 89.8%; the uncovered new lines are the accept-retry sleep and the slot-wait timer branch, both error paths); Rust 92.21% lines (baseline 92.46% included the 20 fuzz/stress tests now `#[ignore]`d); C 91.8% lines (baseline 91.8%), every file at or above the 90% gate. + +Real-use evidence: the interop scripts start real C, Rust and Go servers and clients across UDS, SHM, named pipes and WinSHM through `tests/interop-lib.sh`, on the Linux host, Netdata Linux targets and Windows. + +Reviewer findings: none beyond the issues found during validation, all fixed here (decisions 5, 8, 9 and the QEMU identity check). + +Performance assessment: no benchmark run needed. The only per-request change is the server idle poll timeout read from a per-instance field instead of a constant, production value unchanged (100 ms): C `netipc_service_posix_server_session.c:124,144`, `netipc_service_win_server_session.c:46,72`; Rust `server_session_unix.rs:43,66,83`, `server_session_windows.rs:38,61`; Go `server_unix.go:264,286`, `server_windows.go:374,396`. The busy-pipe wait, drain removal and listening signal run only on connect, reconnect or startup. + +Artifact maintenance gate: + +- `AGENTS.md`: Test Cadence rewritten into three classes (deterministic correctness, extended, performance), plus the extended scripts and `interop-lib.sh` command lines. +- Runtime project skills: `.agents/skills/project-netdata-vendoring/SKILL.md` gained the extended-suites preflight step and record line. +- Specs: no `.agents/sow/specs/` change; the readiness contract and busy-pipe wait are public behaviour and are specified in `docs/level2-typed-api.md` and `docs/level1-windows-np.md`, which are authoritative. +- End-user/operator docs: `docs/level2-typed-api.md` (Readiness section, deterministic-testing requirement), `docs/level1-windows-np.md` (busy-pipe wait). +- End-user/operator skills: `docs/netipc-integrator-skill.md` (readiness, busy wait, test guidance). +- CI: `.github/workflows/runtime-safety.yml` no longer names `test_win_stress` in the Windows ctest regex (its entry is extended-only now). +- SOW lifecycle: SOW-0054 created for the library and benchmark MSYS threads; SOW-0051 facts updated (drained-server reconnect hang in the HELLO_ACK read; the busy wait must sit inside the call deadline). + +Same-failure search: see the sleep/retry search above; identity checks other than `interop-lib.sh` (`/proc/.*exe` in `tests/*.sh`): none. + +## Outcome + +Correctness suites in C, Rust and Go are event-synchronized and run in seconds. Fuzz, stress, randomized chaos, lookup-scale and benchmark-policy suites run only from the extended scripts. Removing the retry loops exposed and fixed three product or fixture bugs: Windows clients failing at once on a busy pipe, MSYS fixture threads started by `CreateThread` dying silently, and an interop stop that could kill an unrelated reused PID. The only correctness failures left are the K4 client-deadline tests, owned by SOW-0051. + +## Lessons Extracted + +- Retry loops in tests hide product bugs: the busy-pipe failure and the MSYS thread deaths were both masked as "slow server". Recorded as the Test Cadence rule against retries. +- An identity check must account for how the program is launched: under QEMU binfmt `/proc/PID/exe` names the interpreter. Recorded in the `interop_pid_runs` comment. +- A long validation run needs a stall check, not a passive wait; a 2-minute target sat hung for 30 minutes. + +## Followup + +- SOW-0051 (pending): client call deadlines, fixes the K4 tests. +- SOW-0054 (open): MSYS-safe thread creation in the Windows C library and the C benchmark driver. +- Deferred-items search (`defer|later|follow-up|future|TODO|pending`): every hit maps to SOW-0051 or SOW-0054 above, or to "before vendoring" extended runs, which the vendoring skill now requires. + +## Regression Log + +## Regression - 2026-11-07 + +What broke: + +- The default-CTest suites `test_service_payload_limits` and `test_service_method_limits` still synchronized with sleeps: `tests/fixtures/c/test_service_limit_helpers.h` polled server readiness with `usleep(500)` up to 2000 times and connected clients through `refresh_until_ready` (up to 200 refreshes with 10 ms sleeps). This contradicts the acceptance criterion that correctness tests synchronize on events (reported on netdata/plugin-ipc#18, review comment 4496633289). + +Why validation missed it: + +- The same-failure search listed the sleep sites of the files the SOW rewrote; this shared helper header was not in that set. + +Repair: + +- The server thread publishes `ready`, `init_ok` and `done` under the `test_sync.h` lock and broadcasts; `start_server_named` blocks on the condition variable with the `TEST_WAIT_LIMIT_MS` bound that only expires on a bug. +- `refresh_until_ready` is replaced by `refresh_ready`: the server listens before `start_server_named` returns, so one refresh connects (the handshake waits until the run loop accepts). + +Validation: + +- `ctest -R 'service_(payload|method)_limits'`: 2/2 passed in 0.83 s. +- Same-failure search over `tests/fixtures/c` for `usleep|Sleep(`: the remaining sites are the accepted stimulus sleeps listed in this SOW's Execution Log (`interop_service*.c` handler delay, `test_win_shm.c` delayed send, `test_shm_timeout`, `test_cache`, `test_robustness`), `Sleep(INFINITE)` in the Windows robustness child processes (killed by the test), and the stress and chaos suites. + +Artifact updates: + +- None beyond this section: the rule is already in AGENTS.md (Test Cadence) and `tests/fixtures/c/test_sync.h`. diff --git a/.agents/sow/done/SOW-0054-20261031-msys-thread-creation.md b/.agents/sow/done/SOW-0054-20261031-msys-thread-creation.md new file mode 100644 index 00000000..39301dab --- /dev/null +++ b/.agents/sow/done/SOW-0054-20261031-msys-thread-creation.md @@ -0,0 +1,193 @@ +# SOW-0054 - MSYS-safe thread creation in the Windows C library + +## Status + +Status: completed + +Sub-state: library and benchmark driver start MSYS threads through pthread_create; validated on Windows. + +## Requirements + +### Purpose + +NetIPC ships in Netdata's Windows build, which runs on the MSYS2 (Cygwin) runtime. Threads that the C library starts must be ones that runtime supports, so servers never stall or die under load. + +### User Request + +Zero-known-bug policy (AGENTS.md): confirmed bugs are fixed, not documented. Every Netdata-supported platform must build and test. + +### Assistant Understanding + +Facts: + +- Under `__MSYS__`, `nipc_service_win_beginthreadex()` (`src/libnetdata/netipc/src/service/netipc_service_win.c:96-119`) starts session threads with raw `CreateThread`. +- SOW-0053 reproduced, on costa-win11 under `ctest -j8` load, a C fixture whose accept thread was started with `CreateThread`: the thread never ran (a raw `WriteFile` at its entry never appeared within 100-170 ms while the main thread kept looping), then the process died silently with exit status 0, no `atexit` handler run and no crash event. Clients saw `ERROR_BROKEN_PIPE` during the handshake. Rate: 1-5 failures per 120 runs across about 8 runs. +- The same fixture with that one thread started by `pthread_create` had 0 failures in 240 runs under the same load. +- Cygwin maintainers state that threads not started through Cygwin's own mechanisms are not supported (cygwin mailing list, 2009-10, "Thread related crash"); Cygwin finds per-thread TLS through the TIB, which such threads lack. +- The library passes session thread handles to `CancelSynchronousIo` and `CloseHandle` (`netipc_service_win_server_session.c:432,483`, `netipc_service_win_server.c:248`), so a replacement must still expose a real Win32 thread handle. +- `bench/drivers/c/bench_windows.c` also starts threads with `CreateThread`. + +Inferences: + +- Library session threads are started from an already running thread, which may be why 240 runs showed no session-thread failure; the runtime still does not support them. + +Unknowns: + +- None that investigation cannot resolve. + +### Acceptance Criteria + +- Under `__MSYS__` the library starts every thread with `pthread_create` and still hands back a real Win32 thread handle for cancellation and joins; native (non-MSYS) Windows builds keep `_beginthreadex`. (Revised at activation: the MSYS runtime has no `pthread_getw32threadhandle_np`; see decision 1.) +- The C benchmark driver uses the same safe path. +- A deterministic test proves the library's threads are runtime threads under MSYS, and fails on the previous code. +- A Windows load run (repeated `ctest -j8`) shows no silent server death. +- Full Windows ctest, Rust and Go suites pass. + +## Analysis + +Sources checked: + +- `src/libnetdata/netipc/src/service/netipc_service_win.c` +- `src/libnetdata/netipc/src/service/netipc_service_win_server.c` +- `src/libnetdata/netipc/src/service/netipc_service_win_server_session.c` +- `tests/fixtures/c/test_sync.h` (`test_create_thread`, added by SOW-0053) + +Risks: + +- Handle ownership: a handle from `pthread_getw32threadhandle_np` belongs to the pthread; the code must not close it the way it closes a `CreateThread` handle. Joins must use `pthread_join` or a completion event. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- Under `__MSYS__`, `nipc_service_win_beginthreadex()` starts threads with raw `CreateThread`, which the Cygwin/MSYS runtime does not support (no runtime TLS for the thread). SOW-0053 saw such a thread never run and the process die silently under load. + +Evidence reviewed: + +- `src/libnetdata/netipc/src/service/netipc_service_win.c:93-115` (the only library thread creation; caller `netipc_service_win_server.c:206`), handle uses `netipc_service_win_server_session.c:431-432` (`CancelSynchronousIo`) and `:482-483`, `netipc_service_win_server.c:247-248` (wait and close). +- `bench/drivers/c/bench_windows.c:538,669,755` (`CreateThread`), joined with `WaitForSingleObject` and `CloseHandle` at `:550-553,679-680,704-705,766-767,791-792`. +- Probe on costa-win11 (MSYS2 runtime `msys-2.0.dll`): `pthread_getw32threadhandle_np` is not declared in `/usr/include/pthread.h` nor exported. A handle the thread duplicates from `GetCurrentThread()` is signaled when the detached pthread returns (109 ms for a 100 ms sleeper), `CancelSynchronousIo` on it ends a blocked `ReadFile` (error 995), and 2000 consecutive starts never failed. In a `CreateThread` thread `pthread_getattr_np(pthread_self())` returns ESRCH (3); in pthreads and the main thread it returns 0. + +Affected contracts and surfaces: + +- Internal only: `nipc_service_win_beginthreadex()` (not public), the Windows C benchmark driver, one new test. No public API, wire format or docs change. + +Existing patterns to reuse: + +- `tests/fixtures/c/test_sync.h` `test_create_thread` (SOW-0053) for the trampoline shape. + +Risk and blast radius: + +- Every managed-server session thread in MSYS builds (Netdata's Windows build). Native builds unchanged. Creation now waits until the new thread has duplicated its handle, a few microseconds per accepted session; that wait cannot hang because `pthread_create` success means the thread runs. + +Performance assessment: + +- Thread creation happens once per accepted connection (`netipc_service_win_server.c:206`), outside established-session request handling. No per-request code changes; no benchmark run is needed. The benchmark driver change affects only how its threads start, not what it measures. + +Sensitive data handling plan: + +- No secrets involved; the Windows host is named by lab hostname only. Applies to this SOW and code comments; no docs or skills change. + +Implementation plan: + +1. Library: under `__MSYS__`, start the thread with `pthread_create` through a trampoline that duplicates its own thread handle, reports it with its thread id, then runs the start routine; the creator waits for the report and detaches the pthread. +2. Benchmark driver: same pattern in a local helper (it uses public headers only). +3. Test in `test_win_service_extra.c`: a thread started by the helper is a runtime thread (`pthread_getattr_np` succeeds, MSYS only), reports the id the helper returned, and its handle is signaled when it returns. + +Validation plan: + +- New test fails on the previous code and passes now; full Windows ctest, Rust and Go suites; repeated `ctest -j8` load run on Windows; Linux build unaffected (the code is Windows-only), checked by a Linux build. + +Artifact impact plan: + +- AGENTS.md: no change expected. +- Runtime project skills: no change expected. +- Specs: none; internal threading only. +- End-user/operator docs: none; no behaviour or API change. +- End-user/operator skills: none. +- SOW lifecycle: pending -> current -> done in one commit. + +Open-source reference evidence: + +- None; evidence is the local runtime probe. + +Open decisions: + +- None; decisions below were assistant-applied under the user's autonomy instruction. + +## Implications And Decisions + +1. Real handle from the thread itself (long-term-best): the new pthread duplicates `GetCurrentThread()` and hands the handle to the creator before running the routine. Reason: MSYS has no `pthread_getw32threadhandle_np`; this keeps every caller's `CancelSynchronousIo`, `WaitForSingleObject` and `CloseHandle` unchanged. Alternative rejected: an event signaled on return (the test fixture approach) cannot serve `CancelSynchronousIo`. +2. The benchmark driver gets a local helper with the same pattern, because it builds against public headers only. +3. The test fixture helper in `test_sync.h` stays as it is: test threads only need join semantics, which its event provides. + +## Plan + +As in the Pre-Implementation Gate implementation plan. + +## Execution Log + +### 2026-10-31 + +- `src/libnetdata/netipc/src/service/netipc_service_win.c`: `nipc_service_win_beginthreadex()` under `__MSYS__` starts a pthread through `service_win_thread_trampoline`, which duplicates its own handle and reports it with its thread id before running the routine; the creator detaches the pthread and returns that handle. +- `bench/drivers/c/bench_windows.c`: `bench_create_thread()` with the same pattern under `__MSYS__` (`CreateThread` otherwise) for the timer and server threads. +- `tests/fixtures/c/test_win_service_extra.c`: `test_library_thread_creation`. + +## Validation + +Acceptance criteria evidence: + +- `nipc_service_win_beginthreadex()` under `__MSYS__` uses `pthread_create` and returns a real thread handle; native builds keep `_beginthreadex` (unchanged `#else` branch). +- The benchmark driver starts its timer and server threads through `bench_create_thread()`. +- `test_library_thread_creation` (in `test_win_service_extra`) passes: the thread is a runtime thread, its handle is signaled on return, and the reported thread id and `GetThreadId(handle)` match the thread that ran. Built against the previous `netipc_service_win.c` (scratch tree on costa-win11) it fails exactly "library thread is a runtime thread" (791 passed, 1 failed). +- Load: the full Windows ctest followed by 5 back-to-back `ctest -j8` runs, each 20/20, with no server death. + +Tests or equivalent validation: + +- Windows (costa-win11, MSYS2): `test_win_service_extra` 792 passed; full ctest 20/20 six times. +- Rust and Go suites were not rerun: no Rust or Go file changed, and they do not link the C library; the C/Rust/Go interop pairs run inside the Windows ctest. +- Linux: `cmake --build build` clean; the changed code is Windows-only. + +Real-use evidence: + +- The Windows C benchmark driver ran a 1 s `np-ping-pong` smoke (client about 1000 req/s, server stopped by its timer thread, exit 0), exercising both helper-started threads. Not a benchmark measurement. + +Reviewer findings: + +- No external review requested; the change mirrors the SOW-0053 fixture helper and the runtime probe. + +Same-failure search: + +- `grep -rn "CreateThread\|_beginthreadex"` over `src/`, `bench/` and `tests/`: the library and driver now go through the helpers; the remaining `CreateThread` uses are the native-build branch of `bench_create_thread()` and `test_sync.h`'s native branch. Rust and Go create threads through their own runtimes. + +Sensitive data gate: + +- This SOW and the code comments contain no secrets, credentials, customer or personal data, or private endpoints; the Windows host is named by lab hostname only. + +Artifact maintenance gate: + +- AGENTS.md: no change; no workflow changed. +- Runtime project skills: no change; vendoring steps unaffected. +- Specs: no change; thread creation is internal and not part of any `docs/` contract. +- End-user/operator docs: no change; no API, behaviour or command changed. +- End-user/operator skills: `docs/netipc-integrator-skill.md` unaffected (no public surface changed). +- SOW lifecycle: pending -> current -> done in this commit; the acceptance criterion naming `pthread_getw32threadhandle_np` was revised with evidence (decision 1). + +## Outcome + +Under MSYS, every thread the Windows C library and the C benchmark driver start is a runtime pthread with a real Win32 handle, so the runtime-unsupported `CreateThread` path that let a server die silently under load is gone. + +## Lessons Extracted + +- Check that a platform API exists on the actual runtime before writing it into acceptance criteria: `pthread_getw32threadhandle_np` belongs to pthreads-win32, not Cygwin/MSYS. +- `pthread_getattr_np(pthread_self())` returning ESRCH is a deterministic test for "thread not started by the Cygwin runtime", turning a load-dependent failure into a one-shot test. + +## Followup + +- None: no deferred items remain. + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0055-20261031-shm-after-negotiation.md b/.agents/sow/done/SOW-0055-20261031-shm-after-negotiation.md new file mode 100644 index 00000000..0a453fa3 --- /dev/null +++ b/.agents/sow/done/SOW-0055-20261031-shm-after-negotiation.md @@ -0,0 +1,257 @@ +# SOW-0055 - Create SHM Regions Only For Sessions That Negotiate SHM + +## Status + +Status: completed + +Sub-state: activated 2026-10-31 after SOW-0052 (`e38ffa7`) completed. + +## Requirements + +### Purpose + +Stop managed servers from creating and destroying a per-session SHM region for every accepted connection, including rejected, silent and baseline-only clients. + +### User Request + +Zero-known-bug policy (AGENTS.md); the SOW-0040 decisions to fix every review-found defect. Split from SOW-0052 (assistant process decision, recorded there) because the fix needs a new step inside the L1 server handshake in C, Rust and Go on POSIX and Windows, and lands best as its own reviewable commit. + +### Assistant Understanding + +Facts (from SOW-0052, verified 2026-10-31 against `6609157`): + +- C POSIX creates the region before HELLO is read (`netipc_service_posix_server_session.c` `server_prepare_accept_config`, called from `server_session_handshake` before `nipc_uds_accept_handshake`); it already waits for HELLO to be readable first, so silent clients no longer cost a region, but rejected and baseline-only clients still do. +- C Windows, Rust (`server_unix.rs`, `server_windows.rs`) and Go (`server_unix.go`, Windows server) follow the same create-before-negotiation order; exact lines to re-verify at activation. +- The SHM profile guarantee (`docs/level1-posix-uds.md`, `docs/level1-windows-np.md`): a successful HELLO_ACK that selects SHM means the region already exists, so creation must happen after negotiation but before HELLO_ACK is sent. + +Inferences: + +- Creating after negotiation also lets the region use the agreed payload sizes instead of the server maximums. + +Unknowns: + +- None that investigation cannot resolve. + +### Acceptance Criteria + +- Servers in C, Rust and Go, POSIX and Windows, create a region only when negotiation selects an SHM profile, before HELLO_ACK; a failed creation falls back to baseline in the same handshake when the client supports it. +- Tests prove a rejected or baseline-only client creates no region, and the SHM interop suites pass. + +## Analysis + +Sources checked: + +- SOW-0052 facts. + +Current state: + +- No test covers region creation for non-SHM sessions. + +Risks: + +- Changes the L1 server handshake API shape in three languages; interop must stay identical on the wire. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- The managed servers create SHM regions before the L1 handshake has read HELLO, because the L1 handshake decides the profile and sends HELLO_ACK in one step. The SHM profile guarantee requires the region to exist before an ACK that selects SHM. With no step between negotiation and ACK, the service layer can only create regions up front for every connection and destroy the ones it did not need. + +Evidence reviewed (2026-10-31, `e38ffa7`): + +- Up-front creation: + - C POSIX: `netipc_service_posix_server_session.c` `server_prepare_accept_config` (`nipc_shm_server_create` at :321), called from `server_session_handshake` before `server_wait_hello` and `nipc_uds_accept_handshake`. + - C Windows: `netipc_service_win_server_session.c` `server_prepare_accept_config` creates a hybrid region and a busywait region (`nipc_win_shm_server_create` at :287) for every pipe. + - Rust: `server_unix.rs` `prepare_shm` (:277) and the Windows `AcceptContext` path (`server_windows.rs:238`). + - Go: `server_unix.go` `prepareAcceptConfig` (`ShmServerCreate` at :88) and `server_windows.go` (`WinShmServerCreate` at :131, both profiles). +- L1 negotiation: C `netipc_uds_handshake.c` `nipc_uds_server_handshake` (:205-311) and `netipc_named_pipe.c` `server_handshake` (:651); Rust `posix.rs` `server_handshake` (:1540) and `windows.rs` `server_handshake` (:1563); Go `uds_handshake.go` `serverHandshakeWait` (:153) and `pipe_handshake.go` `serverHandshakeWait` (:120). Each computes the intersection, checks auth, selects the profile, checks the limits, then encodes and sends HELLO_ACK. +- Test hooks: C server test events (`netipc_service.h:349-350`, `tests/fixtures/c/test_sync.h:223`), Rust `TestHooks` (`server.rs:48`), Go `handshakeStarted` channels (`server_unix.go:39`, `server_windows.go:41`). + +Affected contracts and surfaces: + +- L1 server handshake APIs in C, Rust and Go, POSIX and Windows: a new optional prepare step. The wire format and the existing entry points are unchanged. +- Managed servers in all three languages, on both platforms. +- `docs/level1-posix-uds.md`, `docs/level1-windows-np.md`, and the SHM docs where they describe when regions exist. + +Existing patterns to reuse: + +- The split accept (`accept_pending` / `accept_handshake`) from SOW-0043. +- The server test-event hooks, for counting region creations. +- The current profile-drop fallback: when creation fails, the profile is removed from the server's supported and preferred sets. + +Risk and blast radius: + +- Every SHM session setup in all three languages. A wrong order would break the SHM profile guarantee (ACK selects SHM, client cannot attach). The interop suites run every client against every server. +- Wire: HELLO_ACK content changes only when a region creation fails. The failed profile is then removed from the server-supported and intersection fields, just as the up-front fallback does today. +- Request regions shrink from the server's learned maximum to the agreed request size. Clients never send more than the agreed size, which the L2 senders already check. + +Performance assessment: + +- Connection setup only (handshake and region creation). No code on an established session's request path changes, so no benchmark run. + +Sensitive data handling plan: + +- No secrets involved. This covers this SOW, the docs and the code comments. + +Implementation plan: + +1. L1, all three languages and both platforms: an optional prepare callback, called after negotiation and the limit checks and before HELLO_ACK (decision 1). +2. Managed servers: create the selected profile's region inside the callback, sized from the agreed limits, and drop the up-front creation. +3. Test event "SHM region created", in C, Rust and Go. Tests: a baseline-only client and a rejected client create no region; an SHM client creates exactly one; a failed creation falls back to baseline in the same handshake. +4. Docs. + +Validation plan: + +- The new tests fail on the previous code and pass now. +- Linux: full ctest, including the SHM interop suites, plus `cargo test` and `go test`. +- Windows: ctest, Rust and Go. +- The 9 Netdata Linux targets, run one at a time. +- rustfmt, the CI clippy gate, gofmt and go vet. + +Artifact impact plan: + +- AGENTS.md: no workflow change expected. +- Runtime project skills: none. +- Specs: `docs/level1-posix-uds.md`, `docs/level1-windows-np.md` (prepare step, region timing). +- End-user/operator docs: the same files. +- End-user/operator skills: `docs/netipc-integrator-skill.md` only if it states when regions are created. Checked at close. +- SOW lifecycle: split from SOW-0052. + +Open-source reference evidence: + +- None. + +Open decisions: + +- None; decisions 1-3 below are assistant-applied under the user's autonomy instruction (long-term-best). + +## Implications And Decisions + +1. The L1 server handshake gains an optional prepare callback, rather than a two-phase negotiate/finish API. + - Signatures: C a function pointer plus user pointer, Rust `&mut dyn FnMut(profile, request_payload, response_payload) -> bool`, Go `func(profile, requestPayload, responsePayload uint32) bool`. + - When it runs: after the intersection, auth, selection and limit checks, with the selected profile and the agreed payload limits. + - If it returns false, the server removes that profile from its supported set and from the intersection, and selects again. When no profile is left, it rejects with UNSUPPORTED, as today's up-front fallback does. + - The existing entry points keep their signatures and pass no callback. + - Reason: one hook point keeps the ACK path in a single function per language. A two-phase API would split it and duplicate the state across calls. +2. Regions are sized from the agreed limits (request: agreed request payload + header; response: agreed response payload + header). Reason: they are known at that point, and the request region no longer needs to cover any proposal the client might make. +3. Windows creates only the selected profile's region, not both. Reason: the selection is known, and one region per session is the contract. + +## Plan + +As in the Pre-Implementation Gate implementation plan, in that order. + +## Execution Log + +2026-10-31: + +- L1 prepare step, with the same reselect loop in every implementation (on false: drop the profile from the server set and the intersection, select again; nothing left: UNSUPPORTED / `NO_PROFILE`): + - C POSIX: `nipc_uds_profile_prepare_fn` and `nipc_uds_accept_handshake_prepared()` (`netipc_uds.h`); `nipc_uds_server_handshake(..., prepare, prepare_user, session)` (`netipc_uds_handshake.c`); `nipc_uds_accept_handshake()` passes NULL (`netipc_uds_lifecycle.c`). + - C Windows: `nipc_np_profile_prepare_fn` and `nipc_np_accept_handshake_prepared()` (`netipc_named_pipe.h/.c`). + - Rust: `PendingConnection::handshake_prepared()` (`transport/posix.rs`) and `PendingPipe::handshake_prepared()` (`transport/windows.rs`); `handshake()` passes `None`. + - Go: `ServerHandshakeConfig.Prepare` (`transport/internal/framing/handshake.go`); `ProfilePrepare` and `PendingConn.HandshakePrepared()` in `transport/posix` and `transport/windows`. +- Managed servers create the region inside the callback, only for an SHM profile, sized from the agreed limits plus the message header, using overflow-checked addition: + - C: `server_prepare_profile()` (POSIX and Windows session files); + - Rust: `create_shm()` (`server_unix.rs`, `server_windows.rs`); + - Go: `createShm()` and the `headerPayloadLen()` helper (`server.go`, `server_unix.go`, `server_windows.go`). + - `server_prepare_accept_config()` / `prepareAcceptConfig()` now only copy the learned limits and allocate the session id. + - Removed: the up-front creation, Windows' two-profile `prepared_win_shm_t` / `PreparedWinShm` / `preparedWinShm`, and Rust's `prepare_shm`. +- Test hooks: + - C: `NIPC_SERVER_TEST_EVENT_SHM_CREATED` (`netipc_service.h`), the `shm_created` counter (`tests/fixtures/c/test_sync.h`); + - Rust: `TestHooks.shm_created` and `fail_shm_create` (`server.rs`); + - Go: `shmCreated` and `failShmCreate` fields, plus `shmCreatedCount()` (`server_helpers_test.go`). +- Tests, each with the same scenario (baseline client 0 regions, wrong-auth client 0, SHM client 1, forced creation failure means baseline in one handshake and 0 regions): + - C `test_shm_region_only_for_shm_sessions` (`test_service_extra.c`, `test_win_service_extra.c`); + - Rust `test_shm_region_only_for_shm_sessions_unix` / `_windows`; + - Go `TestUnixShmRegionOnlyForShmSessions` / `TestWinShmRegionOnlyForShmSessions`. + - `server_windows_unit_test.go`: the `preparedWinShm` tests are replaced by `TestPrepareAcceptConfigUsesLearnedCapacities` (2 values now) and `TestCreateShmFailsForInvalidWinShmService`. An invalid service name now fails at SHM creation inside the handshake, not before it. +- Docs: + - `docs/level1-wire-envelope.md`: the handshake rules allocate `session_id` before SHM preparation (the region name needs it, which matches the code), and the prepare/reselect step is stated. + - `docs/level1-transport.md`: the SHM sequence diagram follows the same order. + - `docs/level1-posix-uds.md`, `docs/level1-windows-np.md`: the prepared accept APIs. + - `docs/level1-posix-shm.md`, `docs/level1-windows-shm.md`: when a region is created and how it is sized. + - `docs/netipc-integrator-skill.md`: SHM preparation failure. + +## Validation + +Acceptance criteria evidence: + +- Region only for SHM sessions, before HELLO_ACK: the prepare callback runs inside each L1 server handshake, after the auth, selection and limit checks and before the ACK is encoded (for example C `netipc_uds_handshake.c`, the loop after the limit checks). Managed servers create regions only there, and only for an SHM profile. +- Same-handshake fallback: the forced-failure step of every new test gets a ready baseline session (`SelectedProfile == BASELINE`) from one refresh/handshake. +- Tests prove that baseline and rejected clients create no region: the new tests in C, Rust and Go, on POSIX and Windows, count `SHM_CREATED` events per step (0 / 0 / 1 / 0). + +Tests or equivalent validation: + +- Mutation and old-code checks on Linux: + - C: the new test fails on the previous code (scratch tree with the old server session file); + - Rust and Go: making the prepare callback create a region for every profile fails the new test at its baseline step ("baseline session ... left: 1 right: 0"; "baseline session created 1 SHM regions, want 0"). Both files were restored afterwards. +- Linux: + - `ctest`: 32/32. `test_shm_full_backing_store` skips here because unprivileged namespaces cannot mount tmpfs (a pre-existing environment limit), so it was run as root with `NIPC_REQUIRE_TMPFS_TEST=1`: PASS, "C, Rust, and Go returned normal errors on a full tmpfs; no SIGBUS or leaked region". + - `cargo test`: 385 passed. + - `go test ./...`: ok. + - The SHM and service interop suites run inside ctest. +- Windows (costa-win11, MSYS): + - ctest 20/20; + - Rust 265 passed (+1, the new test); + - Go ok, with the three new or rewritten tests confirmed by `-v`; + - `test_win_service_extra.exe`: 808 passed, including the new test. +- Static checks: + - `cargo fmt`, `gofmt -l` clean; `go vet` clean for GOOS=linux and GOOS=windows. + - CI clippy gate (`-D clippy::correctness -D clippy::suspicious`) passes. Strict `-D warnings` fails only on pre-existing style lints in files this SOW does not touch or on lines outside its diff (checked line by line). +- Netdata Linux targets: all 9 pass (x86_64-musl, i386-gnu, i386-musl, armv7-gnu, armv7-gnu-time64, armv7-musl, armv6-musl, aarch64-gnu, aarch64-musl), run one at a time with no native C fixtures running alongside. + +Real-use evidence: + +- The interop suites run real C, Rust and Go clients against real servers over UDS+SHM, and the Windows suites do the same over pipes+SHM. + +Reviewer findings: + +- No external review was run for this SOW. The mutation checks stand in for the review of the new tests. + +Same-failure search: + +- `nipc_shm_server_create`, `nipc_win_shm_server_create`, `ShmServerCreate`, `WinShmServerCreate` and the Rust `ShmContext::server_create` / `WinShmContext::server_create` callers: the only service-layer callers left are the prepare callbacks. Test helpers create regions directly on purpose. + +Performance assessment: + +- Changed code runs at connection setup only: the handshake and region creation. The established-session request path (send/receive, SHM signalling, dispatch) is unchanged, so no benchmark run, per Test Cadence. +- Request regions now follow the agreed request size instead of the server's learned maximum. Clients never send more than the agreed size; the L2 senders already check it. + +Artifact maintenance gate: + +- AGENTS.md: no workflow change. +- Runtime project skills: none affected (the vendoring skill does not describe handshake internals). +- Specs: `docs/` is the authoritative spec surface; updated as listed in the Execution Log. No `.agents/sow/specs/` file covers SHM timing. +- End-user/operator docs: the same `docs/` files. +- End-user/operator skills: `docs/netipc-integrator-skill.md`, the SHM preparation failure section. +- SOW lifecycle: completed and moved to `done/` in the same commit as the code. + +Sensitive data gate: + +- No credentials, customer data, private endpoints or personal data were written to this SOW, the docs or the code comments. Auth tokens in the tests are synthetic constants. + +Spec update: yes, `docs/level1-wire-envelope.md`, `docs/level1-transport.md`, `docs/level1-posix-uds.md`, `docs/level1-windows-np.md`, `docs/level1-posix-shm.md`, `docs/level1-windows-shm.md`. + +Project skill update: none needed; no project workflow changed. + +Lessons: see below. + +Follow-up mapping: see below. + +## Outcome + +Managed servers in C, Rust and Go, on POSIX and Windows, create a session's SHM region only when the handshake selects an SHM profile. The region is created after the auth and limit checks and before HELLO_ACK, and is sized from the agreed limits. Windows creates only the selected profile's objects. A failed creation falls back to the next profile in the same handshake. Rejected and baseline clients no longer cost a region. + +## Lessons Extracted + +- The docs listed `session_id` allocation after SHM preparation, but every implementation needs the id first because it names the region. When a doc lists steps in order, check that order against the code whenever the code moves. +- `test_shm_full_backing_store` skips on this workstation without root. Run it as root with `NIPC_REQUIRE_TMPFS_TEST=1` whenever SHM creation changes. + +## Followup + +- None. Search of this SOW for defer/later/follow-up/future/TODO/pending: the only matches are API names (`accept_pending`, `PendingConnection`, `PendingPipe`, `PendingConn`) and the follow-up references themselves. + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0056-20261031-freebsd-15-validation.md b/.agents/sow/done/SOW-0056-20261031-freebsd-15-validation.md new file mode 100644 index 00000000..2c40cf89 --- /dev/null +++ b/.agents/sow/done/SOW-0056-20261031-freebsd-15-validation.md @@ -0,0 +1,207 @@ +# SOW-0056 - FreeBSD 15 Validation + +## Status + +Status: completed + +Sub-state: delivered 2026-11-04; FreeBSD (14 and 15) runs the stream baseline. + +## Requirements + +### Purpose + +Every Netdata-supported FreeBSD release must build and pass the NetIPC C, Rust and Go suites, with interop. + +### User Request + +2026-10-29: "netipc should build and run on all these platforms" (Linux on all architectures, Windows, FreeBSD and macOS). Standing rule: a Netdata-supported configuration that cannot be tested is a blocker. + +### Assistant Understanding + +Facts: + +- SOW-0048 validated FreeBSD on a 14.3 test VM only. +- FreeBSD 13 reached end of life on 2026-04-30; the supported releases are 14.4, 14.5 and 15.1 (FreeBSD security information page, `https://www.freebsd.org/security/`, read 2026-10-31). +- Netdata's distro matrix lists only FreeBSD 13-STABLE (`netdata/netdata`, `.github/data/distros.yml:504`, as read during SOW-0048). +- FreeBSD 15 reworked AF_UNIX sockets (review D44151). NetIPC's packet-size rule depends on SEQPACKET semantics (`docs/level1-posix-uds.md`, "Packet size"), so 14.x results do not carry over. +- Netdata builds NetIPC only on Linux and Windows today, so this does not block vendoring the current Linux/Windows consumers. + +- FreeBSD 15.1 test VM provided by the user on 2026-10-31 (named here by role only): 15.1-RELEASE-p6 amd64, 8 CPUs, 16 GiB; has `cmake`, `bash`, `git`; lacks `ninja` and Rust; Go 1.27.3 at `/usr/local/go`. Root and `/tmp` are UFS. `net.local.seqpacket.maxseqpacket = 65536` (14.3: 8192). + +Inferences: + +- 14.4/14.5 share the 14.3 unix-socket code; 15.1 is the real gap. + +Unknowns: + +- Which FreeBSD releases Netdata will support once it enables NetIPC there (a Netdata decision). + +### Acceptance Criteria + +- On FreeBSD 15.1: configure, build, ctest, `cargo test`, `go test ./...` and `tests/interop_codec.sh` pass (the AGENTS.md FreeBSD procedure). +- The maximum SEQPACKET datagram versus `SO_SNDBUF` is measured on 15.1 and the per-platform overhead in C, Rust and Go matches it. +- SHM (SOW-0059) on 15.1: the SHM suites and SHM interop scripts pass, Go also with `GOARCH=386`; the run directory and `/tmp` used by the tests are on UFS or tmpfs (ZFS rejects the region reservation), and the filesystem type is recorded. + +## Analysis + +Baseline run on the FreeBSD 15 test VM (HEAD 0a6aed3): configure and build pass; CTest 26/31, failing `test_uds` (chunking and pipelining checks), `test_uds_rust` (timeout), `test_service_rust` (chunked response), `test_uds_go` (timeout), `test_uds_interop`. The run was stopped there; the cause below explains every failure. + +Root cause: FreeBSD 15 `AF_UNIX` `SOCK_SEQPACKET` keeps record boundaries only where the sender sets `MSG_EOR` (FreeBSD 15.1 `socket(2)`, "SEQUENCED PACKET SOCKET TYPE": "Record boundaries are set by the sender with the MSG_EOR flag of send(2) or sendmsg(2) functions"; man page refactor `freebsd/freebsd-src @ 86d17239233e`). NetIPC never sets `MSG_EOR`, so consecutive packets merge into one record and a receive returns several packets. + +Probe evidence (small C programs on the 15.1 VM, the 14.3 VM and Linux; socketpair and bind/connect sockets give the same results): + +| Behaviour | FreeBSD 14.3 | FreeBSD 15.1 | Linux | +|---|---|---|---| +| 3 sends without `MSG_EOR`, one receive | one record each | all merged (`recv` returns 150 or 216 bytes) | one record each | +| sends with `MSG_EOR` | one record each | one record each | one record each (flag accepted) | +| receive into a buffer shorter than the record | rest dropped, `MSG_TRUNC` | rest delivered by the next receives; `MSG_EOR` only on the last part | rest dropped, `MSG_TRUNC` | +| largest single send | `SO_SNDBUF` (any size set) | the receiver's free space (default `recvspace` 65536), whatever `SO_SNDBUF` or `maxseqpacket` is | about `SO_SNDBUF` | +| non-blocking send of 10000 bytes into a receiver holding 60000 | `EMSGSIZE` (buffer 8192) | partial: returns 5536, then `EAGAIN` | whole | + +Consequences on 15: packets merge (the observed failures); a non-blocking pipelined send can write part of a packet (SEQPACKET code treats a short send as an error); a packet can arrive in parts; the packet-size rule `min(SO_SNDBUF, 262144)` does not bound what one send accepts. FreeBSD 15 `SOCK_SEQPACKET` is a byte stream with record marks, close to the macOS stream baseline (`docs/level1-posix-uds.md`, "macOS stream baseline"), which already handles partial writes and finds packet boundaries from the headers. SHM passes no descriptors over the socket (no `SCM_RIGHTS` in `src/`), so SHM does not depend on the socket type. + +Scope: Netdata builds NetIPC only on Linux and Windows today, so no shipped consumer is affected; under the zero-known-bug policy the FreeBSD defect is fixed here. + +## Pre-Implementation Gate + +Status: ready (2026-11-04, decisions 4A and 5A) + +Problem / root-cause model: + +- Unvalidated platform release; no known defect. + +Evidence reviewed: + +- SOW-0048 Validation (FreeBSD 14.3 results and the SEQPACKET measurement). +- SOW-0059 (SHM on FreeBSD, UFS/tmpfs requirement) and the AGENTS.md FreeBSD procedure. +- The 15.1 VM: `net.local.seqpacket.maxseqpacket = 65536`, `recvspace = 65536`; root and `/tmp` on UFS. + +Affected contracts and surfaces: + +- POSIX UDS packet-size derivation (`SNDBUF_OVERHEAD` in Rust `transport/posix.rs`, Go `uds_sndbuf_*.go`, C `netipc_uds_*`), only if 15.1 differs. +- After the Analysis (decision 4A): the FreeBSD socket type and every stream-baseline gate in C, Rust and Go; the stream partial-receive rule (session flag instead of `shutdown(SHUT_RD)`, which also changes macOS); the session struct (`recv_failed`, C public header); has-pending; specs `docs/level1-posix-uds.md`, `docs/level1-transport.md`; README, AGENTS.md, `docs/netipc-integrator-skill.md`. + +Existing patterns to reuse: + +- SOW-0048 per-platform overhead constants and the AGENTS.md FreeBSD procedure. + +Risk and blast radius: + +- Limited to FreeBSD builds; Linux and Windows are unaffected by FreeBSD-only constants. + +Performance assessment: + +- Validation only; a constant change would affect packet size proposal at connection setup only. No benchmark run. +- After decision 4A: FreeBSD sessions move to the stream code (per-request send and receive), but no benchmark suite runs on FreeBSD (`tests/run-posix-bench.sh` is the Linux suite). On Linux the established-session path gains one boolean read: `nipc_uds_session_has_pending` (`netipc_uds.h`), Rust `has_pending`, Go `HasPending`, and the initial `eof` of the pipelined send (C `netipc_uds_send.c` `send_with_progress`, Rust `send_with_progress_loop`, Go `sendWithProgress`); the receive flag check sits in stream-only code. Assessment: no measurable effect on the Linux rows; no benchmark run. + +Sensitive data handling plan: + +- Name the host only as "the FreeBSD 15 test VM"; record command results, not host details. Covers this SOW, specs, docs, skills and code comments. + +Implementation plan: + +1. Run the AGENTS.md FreeBSD procedure on 15.1. +2. Measure the SEQPACKET maximum; fix any mismatch in all three languages with tests. + +Validation plan: + +- The acceptance criteria commands on 15.1, then the Linux and 14.x suites again if code changed. + +Artifact impact plan: + +- `docs/level1-posix-uds.md` packet-size notes if 15.1 differs; otherwise none, recorded at close. +- After decision 4A: `docs/level1-posix-uds.md` ("Transport type", "Packet size", "Stream baseline", "Pipelined sends", EINTR note), `docs/level1-transport.md` (baseline table), README platform table, AGENTS.md macOS line, `docs/netipc-integrator-skill.md` platform notes. + +Open decisions: + +- None (decisions 4A, 5A below). + +## Implications And Decisions + +User decision (2026-10-31, user): the user will provide a FreeBSD 15.1 VM; validate there when it is available. +- Ordering (2026-10-31, user, supersedes earlier ordering): finish all code changes first — SOW-0045, SOW-0058, SOW-0057, SOW-0049, SOW-0059, and SOW-0056 when the FreeBSD 15.1 VM arrives — then run the full benchmarks and the SOW-0050 vendoring together; benchmark regressions are fixed and re-vendored. + +- Toolchains (2026-11-04, user: "3a"): install `rust` and `ninja` from FreeBSD packages on the 15.1 VM, like the 14.3 VM (rust 1.98.1, ninja 1.13.2). Rejected: B, rustup with 1.91 (MSRV stays validated on Linux and Windows). + +- FreeBSD baseline transport (2026-11-04, user: "4a"): FreeBSD, every version, uses the stream baseline (`SOCK_STREAM`, packet boundaries from the headers) already used on macOS, in C, Rust and Go; SHM stays available on FreeBSD. Rejected: B, SEQPACKET with `MSG_EOR`, partial-send resume and record reassembly (a third mode re-implementing the stream logic, tied to semantics FreeBSD already changed once); C, runtime choice by kernel version (two modes on one OS). +- Scope (2026-11-04, user: "5a"): the fix is part of this SOW. Rejected: B, a separate transport SOW. +- Stream partial-receive rule (2026-11-04, assistant decision under the autonomous-execution instruction, within 4A, for user review): a session flag replaces `shutdown(SHUT_RD)` on every stream platform, macOS included; later receives fail without reading the socket, a pipelined send no longer queues inbound bytes, and has-pending is false. Evidence: Execution Log (FreeBSD 15.1 `EPIPE` on the peer's sends, FreeBSD 14.3 still returning queued bytes). Rejected: per-OS `shutdown` handling. + +## Plan + +See the gate. + +## Execution Log + +### 2026-10-31 + +Created from SOW-0048 follow-up mapping. + +### 2026-11-04 + +- Toolchains installed on the 15.1 VM (decision above); HEAD synced; baseline run and probes as recorded under Analysis. Probe processes and the stopped run were cleaned up by PID. +- A temporary `net.local.seqpacket.maxseqpacket=8192` on the test VM (restored to 65536 in the same command) lowered the default `SO_SNDBUF` to 8192 while a 65536-byte send still succeeded. +- Implementation (decision 4A): + - C: `NIPC_UDS_STREAM` for `__APPLE__ || __FreeBSD__` (`netipc_uds_internal.h`); the FreeBSD `SO_SNDBUF` overhead branch removed (stream ignores `SO_SNDBUF`). Tests: `uds_test_socket.h` stream for FreeBSD; `test_stream_failed_receive_drops_pending` runs on every stream platform (`TEST_UDS_STREAM`); the bind-directory test stays macOS. + - Rust: every stream gate in `transport/posix.rs` and `posix_tests.rs` is `any(target_os = "macos", target_os = "freebsd")`; `set_nosigpipe`, `bind_owner_only` and `test_listen_bind_dir` stay macOS; `SNDBUF_OVERHEAD` is Linux-only. + - Go: `uds_transport_stream.go` (`darwin || freebsd`) and `uds_transport_seqpacket.go` (Linux and other SEQPACKET systems) hold the socket type and packet limit; `uds_platform_darwin.go` / `uds_platform_other.go` keep only the SIGPIPE handling; `uds_sndbuf_freebsd.go` removed; tests renamed out of `_darwin` (`uds_stream_baseline_test.go`, `socktype_stream_test.go`; the bind-directory test moved to `uds_bind_darwin_test.go`), because a `_darwin` filename restricts a file to macOS whatever its build tag. +- First run of the stream baseline on both FreeBSD VMs: every interop suite passed; one test failed in all three languages, `test_stream_partial_receive_ends_reads`: after `shutdown(SHUT_RD)` FreeBSD 15.1 failed the peer's sends with `EPIPE`, and FreeBSD 14.3 still returned the queued bytes (a truncated packet instead of a receive failure). The stream baseline relied on macOS `SHUT_RD` semantics. + - Fix (within 4A, assistant decision under the autonomous-execution instruction, for user review): the session records the failed receive (C `recv_failed` in `nipc_uds_session_t`, Rust `recv_failed`, Go `recvFailed`) and every later receive returns the receive error (C `NIPC_UDS_ERR_RECV`, Rust `UdsError::Recv(0)`, Go `ErrRecv`) without reading the socket; `shutdown(SHUT_RD)` is no longer called. Same behaviour on macOS, FreeBSD 14 and FreeBSD 15. Rejected: per-OS shutdown handling (still differs by release). +- Second run: one C failure on both VMs, `test_eintr_retried` ("every injected EINTR was hit"): on a stream the handshake packets go through `sendmsg`, not `send`. The test now injects (send 0, sendmsg 4, recv 4) on stream platforms, as the Rust test already did. + +## Validation + +Acceptance criteria: + +- FreeBSD 15.1 (UFS `/tmp` and build directory): configure, build, CTest 31/31, `cargo test` (lib 398 passed), `go test ./...` (all packages ok) and `GOARCH=386 go test ./...` (all ok), `tests/interop_codec.sh`, UDS, SHM, service, service SHM, cache, cache SHM and lookup-scale interop: all pass (final run, 2026-11-04). +- SEQPACKET maximum versus `SO_SNDBUF` on 15.1: measured (Analysis table). The result is that 15.1 SEQPACKET does not keep packets apart, so the per-platform overhead question is replaced by decision 4A; FreeBSD no longer consults `SO_SNDBUF` (stream rule: configured size or 262144). +- SHM on 15.1: the SHM suites in CTest and the SHM interop scripts pass, Go also with `GOARCH=386`; filesystem UFS, recorded by the run script. + +Tests or equivalent validation: + +- Before the fix (HEAD 0a6aed3) on 15.1: CTest 26/31 (Analysis). After: as above. +- FreeBSD 14.3: the same procedure, all pass (CTest 31/31, cargo 398, Go and Go 386 ok, all interop). +- macOS test host: CTest 24/24, `cargo test` 330, `go test ./...`, UDS, service, service SHM, cache, cache SHM, lookup-scale and codec interop pass (`test_shm_interop.sh` does not apply on macOS, as in SOW-0049). +- Linux: CTest 32/32, `cargo test`, `go test ./...`, UDS, service and SHM interop; `cargo fmt --check` with stable and 1.91.0; `cargo check --all-targets` for `x86_64-unknown-linux-gnu`, `x86_64-unknown-freebsd`, `aarch64-apple-darwin` without warnings; `go vet` for linux (amd64, 386, arm, arm64), freebsd (amd64, 386) and darwin; `gofmt -l` empty. The Netdata Linux target suites were not rerun: the Linux C and Rust code is unchanged (all edits are inside FreeBSD/macOS gates except the one-boolean `recv_failed` reads), and the Go Linux build is the same constants moved between files, checked by `go vet` on 386, arm and arm64. + +Real-use evidence: the interop scripts run real C, Rust and Go servers and clients across all 9 pairs over the FreeBSD stream baseline, including SHM sessions negotiated over it. + +Reviewer findings (independent read-only review of the diff) and handling: + +1. Gates, parity, constructors, SHM gating, macOS-only items: no defect found. +2. After a failed stream receive, a pipelined send still drained the abandoned stream into the pending queue, so has-pending could report data that the next receive refuses (the has-pending contract says the next receive can return it): fixed in C, Rust and Go; the send starts as if the peer had closed (`eof` from the flag) and has-pending is false once the flag is set (on macOS this matches what `SHUT_RD` gave before: EOF). +3. Docs: `SO_NOSIGPIPE` described as set on every stream session (macOS only); "Pipelined sends" and the EINTR note said "macOS stream": fixed. +4. Stale comments (stream code "only macOS", `O_NONBLOCK` reason, `SHUT_RD` in test comments, FreeBSD 8 KiB defaults): fixed. The pipeline tests set 4096-byte buffers themselves, so they exceed the buffers on FreeBSD 15 too. +5. Risk: FreeBSD binaries before and after this change cannot connect to each other (`EPROTOTYPE`), and a new server's stale-socket probe treats an old, lockless server as stale. Accepted as no-impact: Netdata does not build NetIPC on FreeBSD today (Analysis, Scope), and when it does, the C, Rust and Go copies ship together from one vendoring. + +Same-failure search: every `send`/`sendmsg`/`recv` site and every `__APPLE__`, `__FreeBSD__`, `target_os = "macos"`/`"freebsd"`, darwin/freebsd build tag and `_darwin`/`_freebsd` filename in `src/`, `tests/fixtures/` and `bench/drivers/` reviewed; no other code assumes SEQPACKET on FreeBSD; no other `shutdown(SHUT_RD)` exists (the remaining `SHUT_RDWR` calls end a session on purpose). + +SOW status/directory: completed, moved to `done/` in the same commit. + +## Outcome + +FreeBSD 15.1 is validated. It exposed a real defect: FreeBSD 15 `SOCK_SEQPACKET` keeps record boundaries only with `MSG_EOR` and may write or deliver part of a record, so NetIPC packets merged. FreeBSD (every release) now runs the stream baseline already used on macOS, in C, Rust and Go, with SHM kept. The stream baseline's partial-receive rule no longer depends on `shutdown(SHUT_RD)` semantics, which differ between macOS, FreeBSD 14 and FreeBSD 15. All suites pass on FreeBSD 15.1, FreeBSD 14.3, macOS and Linux. + +Artifact maintenance gate: + +- AGENTS.md: macOS line names the renamed spec section and that FreeBSD uses the stream baseline; the FreeBSD procedure itself is unchanged. +- Runtime project skills: no update; the vendoring workflow is unchanged (Netdata does not build NetIPC on FreeBSD). +- Specs: `docs/level1-posix-uds.md`, `docs/level1-transport.md` updated (the public specs are the authoritative spec surface); `.agents/sow/specs/` holds no transport rule, so it needs no change. +- End-user/operator docs: README platform table; the `docs/` specs above. +- End-user/operator skills: `docs/netipc-integrator-skill.md` platform notes (FreeBSD stream baseline, SHM kept, packet size, backlog note). +- SOW lifecycle: completed in `done/`; follow-ups mapped below. + +## Lessons Extracted + +- A socket type's semantics can change between OS releases (FreeBSD 15 `SOCK_SEQPACKET`): validate every supported release, and probe the primitive directly (small C programs) before blaming the library. +- Kernel side effects such as `shutdown(SHUT_RD)` differ by OS; state that a transport needs (a failed receive) belongs in the session, not in the socket. +- A Go `_darwin` / `_freebsd` filename suffix restricts a file to that OS regardless of its build tag; renaming is required when sharing code across OSes. + +## Followup + +- FreeBSD binaries from before this change cannot talk to newer ones: no work; nothing ships on FreeBSD yet (Validation, reviewer finding 5). +- Deferral search (`defer|later|follow-up|future|TODO|pending`): remaining hits are the pending queue, has-pending API names and historical status lines, not deferred work. + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0057-20261031-production-duplication-reduction.md b/.agents/sow/done/SOW-0057-20261031-production-duplication-reduction.md new file mode 100644 index 00000000..bd44117a --- /dev/null +++ b/.agents/sow/done/SOW-0057-20261031-production-duplication-reduction.md @@ -0,0 +1,210 @@ +# SOW-0057 - Production Duplication Reduction + +## Status + +Status: completed + +Sub-state: delivered; benchmarked with the pre-vendoring run (user ordering). + +## Requirements + +### Purpose + +Continue SOW-0015's file-by-file maintainability work on production sources: reduce real duplication without hiding scanner rules. + +### User Request + +SOW-0015 (approved 2026-06-05): exclude tests and benchmarks from Codacy metrics, then "continue file-by-file on remaining production files". + +### Assistant Understanding + +Facts: + +- Codacy Cloud on `main` `63919ecb7d` (2026-09-27, public API): 32% complex files, 32% duplicated files, goals 10% / 10% (`fileDuplicationBlockThreshold 1`: a file counts as duplicated with any one duplicated block). +- Local `jscpd --min-tokens 50` over `src/` without tests (2026-10-31, branch tip `dbfa64a`): 4653 of 48542 production lines duplicated (9.6%). +- Largest contributors (duplicated lines per file): Rust `transport/windows.rs` 745 and `transport/posix.rs` 577 (handshake and chunking logic repeated per platform); apps/cgroups lookup codecs in C (`netipc_protocol_apps_lookup.c` 417, `netipc_protocol_cgroups_lookup.c` 339), Rust (`service/apps_lookup.rs` 428, `service/cgroups_lookup.rs` 314, `protocol/lookup/cgroups_lookup.rs` 299) and Go (`protocol/apps_lookup.go` 286, `protocol/cgroups_lookup.go` 214); C Windows transports (`netipc_named_pipe.c` 346, `netipc_win_shm.c` 326). + +Inferences: + +- The first target is the Rust POSIX/Windows transport pair: Go already shares this logic in `transport/internal/framing`, so a Rust shared module follows an existing project pattern. + +Unknowns: + +- Whether the user wants this before the next Netdata vendoring (it touches the per-request chunking path, so it needs the benchmark run that precedes vendoring). + +### Acceptance Criteria + +- Each refactored file pair loses its duplicated blocks with no behavior change (existing suites, interop). +- Codacy duplicated-files percentage drops, measured after push. + +## Analysis + +Rust transport pair, measured 2026-10-31 at `891faa1` (`jscpd --min-tokens 50` on `transport/posix.rs` 1794 lines and `transport/windows.rs` 2784 lines, tests included): 49 clones, 587 duplicated lines (12.8%). The production clones fall in four groups: + +- pure helpers: `header_version_incompatible`, `hello_layout_incompatible`, `hello_ack_layout_incompatible` (posix.rs:119-145 vs windows.rs:223-249), `highest_bit`, `apply_default`; +- config types: `ClientConfig`/`ServerConfig` and their `Default` (posix.rs:160-225 vs windows.rs:264-330). They differ: POSIX `ServerConfig` has `backlog`, and the packet-size doc differs, so they stay per transport; +- per-request paths: `send`/`send_inner` (posix.rs:314-438 vs windows.rs:783-906) and `receive_with_control` chunk reassembly (posix.rs:448-638 vs windows.rs:913-1088); +- handshake: `connect_and_handshake`/`server_handshake` (posix.rs:1434-1790) vs `client_handshake`/`server_handshake` (windows.rs:1457-1800). + +Go already shares the last three groups in `src/go/pkg/netipc/transport/internal/framing` (`handshake.go` `ClientHandshake`/`ServerHandshake`/`NegotiateHello`, `send.go` `SessionSend`, `receive.go` `Receiver`), with each transport passing its I/O as functions. + +Netdata's Rust code does not use `transport::posix`/`transport::windows` config types directly (`ktsaou/netdata @ ae9113f2cc`, grep outside `src/crates/netipc/`: no hits). + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- `transport/posix.rs` and `transport/windows.rs` each carry their own copy of the message framing (send with chunking, receive with reassembly and validation), the HELLO/HELLO_ACK encoding and negotiation, and small helpers. Only the I/O and the error enum differ (Analysis). + +Evidence reviewed: + +- jscpd measurement and the four clone groups (Analysis); line-by-line diffs of `send`/`send_inner`, `receive_with_control`, the client and server handshakes (2026-10-31). +- Go's shared `transport/internal/framing` (`handshake.go`, `send.go`, `receive.go`). +- C and Go post-HELLO_ACK packet-size checks are per transport (Go `transport/posix/uds_handshake.go:115`, C `netipc_named_pipe.c` client handshake returns `NIPC_NP_ERR_HANDSHAKE` for a too-small size). + +Behavior differences found between the two Rust copies, and how the shared code handles them: + +- Receive: POSIX rejects `HEADER_SIZE + payload_len > u32::MAX` before chunk validation; Windows does not, so its `total_message_len` comparison can truncate-match. The shared receive keeps the check for both (zero-known-bug policy; Go and C check it). +- Send failure: POSIX fails all in-flight ids on `Send`, Windows on `Send` or `Disconnected`. Kept per transport through the error trait. +- Client HELLO_ACK packet size: POSIX rejects `<= HEADER_SIZE` and `> proposal` as `Protocol`; Windows rejects `<= HEADER_SIZE` as `Handshake`. Kept per transport, after the shared ack parsing, as in Go. +- Client ack wait (POSIX `wait_fd_readable`; Windows tolerates a disconnected pipe that still holds the reply), packet-size derivation, and the POSIX platform profile mask stay per transport; the shared code receives the masked and defaulted values. + +Affected contracts and surfaces: + +- Internal Rust code organization only. Public types (`UdsSession`, `NpSession`, configs, `Role`, error enums) and their variants stay as they are; no wire change. The Windows receive gains the `u32::MAX` total-length rejection. + +Existing patterns to reuse: + +- Go `transport/internal/framing`; Rust `transport/mod.rs` already holds a shared helper (`selected_profile_offered`). + +Risk and blast radius: + +- Every Rust session's send and receive runs through the new code; a mistake breaks all Rust traffic on both platforms. Mitigated by the existing unit, robustness and interop suites on Linux, FreeBSD and Windows, plus the Linux targets. + +Performance assessment: + +- Changes the established-session per-request path: Rust send/receive on UDS and Named Pipe (`posix.rs:314-638`, `windows.rs:783-1088`). The engine is generic over the I/O closures, so it monomorphizes like today's code; the pre-vendoring benchmark run (user ordering) measures it with the other SOWs. + +Sensitive data handling plan: + +- Code and tool output only; no hosts or data in artifacts. + +Implementation plan: + +1. Add `src/crates/netipc/src/transport/framing.rs` (`pub(crate)`, unix and windows): header/layout helpers, `highest_bit`, `apply_default`, a `FramingError` trait implemented by `UdsError` and `NpError`, `send_message()` and `receive_message()` taking the session's role, limits, packet size, in-flight set, buffers and I/O closures, `hello_packet()`, `parse_hello_ack()` and `server_negotiate()` (HELLO parse, negotiation, prepare callback, rejections, HELLO_ACK). +2. Switch `posix.rs` and `windows.rs` to it, deleting their copies. +3. Move unit tests of moved helpers if they referenced the old private items. + +Validation plan: + +- `cargo test` (Linux, FreeBSD, Windows), clippy gate (Linux and Windows target), fmt on stable and 1.91; ctest with interop on Linux, FreeBSD and Windows; Linux targets (Rust part); jscpd re-measure. + +Artifact impact plan: + +- `docs/code-organization.md`: add the Rust shared framing module next to Go's, if it lists per-language transport modules. No public docs otherwise (no behavior change visible to users). + +Open decisions: + +- None (user decisions 1-2 below). + +## Implications And Decisions + +User decisions (2026-10-31, user): + +1. Scope: the Rust transport pair (`transport/posix.rs`, `transport/windows.rs`) only. Each further pair (C/Rust/Go lookup codecs, C Windows transports) gets its own SOW, picked by re-measuring. +2. Depth: full, like Go's `transport/internal/framing`: one shared Rust module holds the helpers, the hello negotiation and both handshakes, and the send/receive chunking engine; each transport supplies its I/O. This touches the per-request path, so the pre-vendoring benchmark run covers it. + +User decision (2026-10-31, user): do this after the SOW-0050 Netdata vendoring; benchmark it in the following cycle. +- Ordering (2026-10-31, user, supersedes earlier ordering): finish all code changes first — SOW-0045, SOW-0058, SOW-0057, SOW-0049, SOW-0059, and SOW-0056 when the FreeBSD 15.1 VM arrives — then run the full benchmarks and the SOW-0050 vendoring together; benchmark regressions are fixed and re-vendored. + +## Plan + +See the gate. + +## Execution Log + +### 2026-10-31 + +- Created from SOW-0015. +- Added `src/crates/netipc/src/transport/framing.rs`: `FramingError` (implemented by `UdsError` and `NpError`), `Framing` (one direction's limits and packet size), `send_message`, `receive_message`, `hello_packet`, `parse_hello_ack`, `ServerOffer` and `server_negotiate`, plus the moved helpers and HELLO size constants. +- `posix.rs` and `windows.rs` now call it; each keeps its I/O, connect/accept, ack wait, post-ack packet-size checks, packet-size derivation, POSIX platform profile mask, and session construction. A `framing(sending)` session method picks the direction's limits. +- Moved `test_highest_bit`, `test_apply_default` and `test_incompatible_classifiers` from the transport tests into `framing.rs`; added `test_receive_rejects_unrepresentable_total_length`. +- The i386 target run exposed a 32-bit bug that the new test reached: `HEADER_SIZE + payload_len as usize` overflows a 32-bit `usize` (debug builds panic on a peer header, release builds wrap past the `u32::MAX` guard). The old POSIX code had the same expression. Fixed with `checked_add`; the chunk bound check became `chunk_data > needed - assembled`. Go (`uint64` sum, `transport/internal/framing/receive.go:138`) and C (`SIZE_MAX` check, `netipc_uds.c:14`) were already correct. + +## Validation + +Acceptance criteria evidence: + +- Duplication: jscpd `--min-tokens 50` on the Rust transport files (tests included) went from 49 clones, 587 duplicated lines (12.8%, `posix.rs` + `windows.rs`, 4578 lines) to 29 clones, 338 duplicated lines (7.9%, `posix.rs` + `windows.rs` + `framing.rs`, 4289 lines). Correction (2026-11-07, SOW-0070): re-running jscpd 5.3.1 `--min-tokens 50` on those three files at commit `7b73adc` gives 29 clones, 309 duplicated lines (7.2%), the figure SOW-0060 uses. Remaining clones are the per-transport parts the design keeps (config types, session fields and accessors, handshake wrappers, `Hello` literal, session construction) and Windows test fixtures. +- No behavior change except the two recorded fixes (Windows receive gains the `u32::MAX` total check; 32-bit overflow fixed on both transports). Error variants and messages are unchanged. +- Codacy duplicated-files percentage: measured after push (tracked in SOW-0050's post-push Codacy re-check). + +Tests and equivalent validation: + +- Linux: `cargo test` 389 passed (stable and 1.91.0); CTest 32/32 (`test_shm_full_backing_store` skipped without root); `interop_codec.sh`, `test_uds_interop.sh` 18/18, `test_shm_interop.sh` 9/9, `test_service_interop.sh` 18/18. +- Linux targets (`run-linux-target.sh rust interop`): x86_64-musl, i386-gnu, i386-musl, armv7-gnu, armv7-gnu-time64, armv7-musl, armv6-musl, aarch64-gnu, aarch64-musl all pass after the overflow fix (i386-gnu and i386-musl failed before it). +- FreeBSD 14.3: CTest 24/24, `cargo test` 319 passed. +- Windows: `cargo test` 273 passed (1.97.1 and MSRV 1.91.0-msvc), Go ok, CTest 20/20 (both suites). +- Gates: `cargo fmt --check` with stable and 1.91.0; clippy `-D clippy::correctness -D clippy::suspicious` on Linux (stable and 1.91.0) and `--target x86_64-pc-windows-gnu`, no new warnings in the changed files. +- Mutant: disabling the total-length guard fails `test_receive_rejects_unrepresentable_total_length` (it then reserved the ~4 GiB declared payload). + +Real-use evidence: + +- The interop suites run real Rust clients and servers against C and Go over UDS, SHM and Named Pipes on Linux and Windows. + +Performance assessment: + +- Per-request path changed: Rust send/receive on UDS and Named Pipe now run in `framing::send_message`/`receive_message`, generic over the I/O closures (monomorphized as before). Benchmarked in the pre-vendoring run together with the other SOWs (user ordering). + +Reviewer findings: + +- No external review requested for this SOW. The self-review found the 32-bit overflow (via the i386 target) and fixed it. + +Same-failure search: + +- Searched the Rust crate for `+ as usize` sums over peer data. The codecs check bounds with `u64` sums before slicing (`protocol/mod.rs:412`, `protocol/cgroups_snapshot.rs:214,225`); the `service/raw/server_session_*.rs` buffer sizes are capped by the server's own config. No other instance. + +Sensitive data gate: + +- Code, docs and SOW text only; hosts are named by role. + +Artifact maintenance gate: + +- `AGENTS.md`: no workflow change. +- Runtime project skills: none affected (vendoring skill unchanged; the new file vendors with the crate directory). +- Specs: no protocol, wire or API change; `.agents/sow/specs/` does not describe Rust module layout. +- End-user/operator docs: `docs/code-organization.md` lists `transport/framing.rs`. +- End-user/operator skills: `docs/netipc-integrator-skill.md` covers public APIs only, unchanged. +- SOW lifecycle: completed and moved to `done/`; follow-up SOW-0060 created. + +Spec update: none needed (internal reorganization; the two fixes match the documented C and Go behavior). + +Project skill update: none needed (no new workflow knowledge beyond the targets run already documented). + +Lessons: + +- Running a new boundary-value test on 32-bit targets exposed overflow in an expression that 64-bit runs never trip. Unit tests at `u32::MAX` boundaries belong in the Linux-target runs, which already include them. + +Follow-up mapping: + +- Next duplication pair: SOW-0060 (pending). +- Codacy re-measure after push: SOW-0050 post-push check. +- Benchmark: the pre-vendoring run (SOW-0050 / SOW-0021 ordering). + +## Outcome + +The Rust UDS and Named Pipe transports share one framing module, like Go's `transport/internal/framing`; duplicated lines in the pair fell from 587 to 338. Windows receive now rejects unrepresentable total lengths, and a 32-bit overflow in the receive path is fixed on both transports. + +## Lessons Extracted + +See Validation, Lessons. + +## Followup + +- SOW-0060: next duplication pair. + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0058-20261031-go-1-27-alignment.md b/.agents/sow/done/SOW-0058-20261031-go-1-27-alignment.md new file mode 100644 index 00000000..1d381027 --- /dev/null +++ b/.agents/sow/done/SOW-0058-20261031-go-1-27-alignment.md @@ -0,0 +1,171 @@ +# SOW-0058 - Align Go Modules With Netdata's Go 1.27 + +## Status + +Status: completed + +Sub-state: completed 2026-10-31. + +## Requirements + +### Purpose + +Keep plugin-ipc's Go modules on the same Go version as Netdata, the project rule recorded in SOW-0015, so vendored Go code builds the same in both repositories and scanners stop reporting standard-library CVEs fixed in newer Go. + +### User Request + +SOW-0015 recorded the rule that plugin-ipc Go modules match Netdata's Go version (`go 1.26.0` at the time). + +### Assistant Understanding + +Facts: + +- plugin-ipc: `go 1.26.0` in `src/go/go.mod`, `bench/drivers/go/go.mod`, `tests/fixtures/go/go.mod`; `.github/workflows/codacy-analysis.yml:75` pins `go-version: "1.26.x"`. +- Netdata: `go 1.27.0` in `src/go/go.mod` (`netdata/netdata` default branch and `ktsaou/netdata @ ae9113f2cc`, read 2026-10-31). +- Codacy Cloud on `main` `63919ecb7d`: all 33 open issues are Trivy standard-library findings on `go.mod` (22 high, 9 medium, 2 minor). +- Toolchains: workstation go 1.27.2; Windows test host go 1.26.5; FreeBSD test VM go 1.26.10. + +Inferences: + +- Moving to `go 1.27.0` clears findings fixed by 1.27.0; findings fixed only in later 1.27.x patches need a newer `toolchain` line or remain until Netdata moves. + +Unknowns: + +- Which of the 33 findings 1.27.0 clears (re-scan after push). + +### Acceptance Criteria + +- All three `go.mod` files and the Codacy workflow use Go 1.27; Linux, FreeBSD and Windows Go suites pass with Go 1.27; Codacy findings re-checked and recorded. + +## Analysis + +See facts above. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- Netdata moved to Go 1.27; plugin-ipc did not follow. + +Evidence reviewed: + +- The files above. + +Affected contracts and surfaces: + +- Minimum Go version for the Go packages (vendored into Netdata, which already requires 1.27). + +Existing patterns to reuse: + +- SOW-0015's Go version alignment. + +Risk and blast radius: + +- Builders with Go < 1.27 can no longer build plugin-ipc's Go code; Netdata already requires 1.27. + +Performance assessment: + +- Toolchain change; Go 1.27 code generation could shift Go benchmark numbers. The pre-vendoring benchmark run covers it if this lands first. + +Sensitive data handling plan: + +- Versions and paths only. + +Implementation plan: + +1. Install Go 1.27 on the Windows and FreeBSD test hosts (needs approval). +2. Bump the `go` lines and the Codacy workflow; run `go mod tidy` and `go fix`. +3. Validate on Linux, FreeBSD and Windows. + +Validation plan: + +- `go test ./...`, `go vet` for linux/freebsd/windows, interop scripts, Linux targets (Go parts). + +Artifact impact plan: + +- README/AGENTS.md Go version notes if present. + +Open decisions: + +- None: the user approved the Go 1.27 install and the bump (Implications And Decisions). + +Plan revision (2026-10-31, before editing): latest stable is go1.27.3 (go.dev JSON). Windows: official zip, SHA-256 verified, extracted side by side to `C:\go127\go`; `C:\Program Files\Go` (1.26.5) stays for rollback. FreeBSD: `/usr/local/go` already holds go1.27.3 (`/usr/local/go126` was only used to match the old directive). `go fix` is previewed, not applied: its rewrites are optional modernizations, not part of the alignment (see Validation). + +## Implications And Decisions + +User decision (2026-10-31, user): install the latest Go 1.27 on the Windows test host (and the FreeBSD VM), bump plugin-ipc to `go 1.27.0`. Runs after SOW-0045; the pre-vendoring benchmark run follows both. +- Ordering (2026-10-31, user, supersedes earlier ordering): finish all code changes first — SOW-0045, SOW-0058, SOW-0057, SOW-0049, SOW-0059, and SOW-0056 when the FreeBSD 15.1 VM arrives — then run the full benchmarks and the SOW-0050 vendoring together; benchmark regressions are fixed and re-vendored. + +## Plan + +See the gate. + +## Execution Log + +### 2026-10-31 + +- Created from SOW-0015. +- Installed go1.27.3 on the Windows test host (`C:\go127\go`, zip SHA-256 `f26081f4...7885fff` matched go.dev); helper scripts and the `build-msys-validation` CMake cache (`GO_EXECUTABLE`) point there. The FreeBSD VM already had go1.27.3 at `/usr/local/go`; its CMake cache was switched from `/usr/local/go126` to it. The workstation build cache now uses `~/.local/bin/go` (1.27.2) instead of `/usr/local/go` (1.24.6, which had been auto-switching to the cached go1.27.0 toolchain). +- `go 1.26.0` -> `go 1.27.0` in `src/go/go.mod`, `bench/drivers/go/go.mod`, `tests/fixtures/go/go.mod`; `go mod tidy` changed nothing. +- Workflows `codacy-analysis.yml`, `supply-chain-security.yml`, `static-analysis.yml`: `go-version: "1.26.x"` -> `"1.27.x"` (and the static-analysis comment). The other workflows already use `go-version-file: src/go/go.mod`. + +## Validation + +Acceptance criteria evidence: + +- All three `go.mod` files say `go 1.27.0`; Codacy (and the two other pinned workflows) use `1.27.x`. +- Linux (go1.27.2): `go test ./...` in `src/go` all ok; full ctest 32/32 (1 skipped: `test_shm_full_backing_store`, root-only, unaffected) including the Go interop pairs. +- FreeBSD 14.3 (go1.27.3): `go test ./...` all ok; `go vet` of the fixtures module and `go build` of the bench driver ok; ctest 24/24. +- Windows (go1.27.3): `go test ./...` all packages ok; Rust 271 passed; ctest 20/20 (C/Rust/Go interop over pipes and SHM). +- Netdata Linux targets, Go part (`tests/run-linux-target.sh go`): all 9 passed; Go tests ran on i386-gnu, armv6-musl, armv7-gnu, armv7-gnu-time64 (cached: same GOARCH/GOARM test binary as armv7-gnu) and aarch64-gnu; the musl targets skip Go by design (pure Go does not link libc). +- `go vet ./...` for linux, windows and freebsd in all three modules: clean apart from the pre-existing `possible misuse of unsafe.Pointer` notes in `transport/windows/shm*.go`. +- Codacy findings re-check: needs a push of this branch, which the user has not approved yet; tracked in SOW-0050, whose preflight already requires source CI and scanner evidence before vendoring. + +`go fix` (Go 1.27) preview, rejected for this SOW: + +- `go fix -diff ./...` proposes optional modernizations: `for i := range n`, `slices.Backward`, and flattening embedded-struct composite literals with the Go 1.27 promoted-field literal syntax (for example `transport/posix/uds_handshake.go` around line 160). They do not change behavior and are not needed to build with 1.27. Applying them would widen the vendored Netdata diff for style only. Reported to the user as an opportunity. + +Performance assessment: + +- Toolchain only; no source change. Go code generation may shift Go benchmark rows; the full pre-vendoring benchmark run (after all code SOWs, user ordering) measures with Go 1.27. + +Reviewer findings: no external review requested. + +Same-failure search: `grep -rn 1.26` over workflows, go.mod files, docs, scripts: no other Go 1.26 pin remains (README `1.26M` is a benchmark number). + +Sensitive data gate: versions, file paths and public checksums only; hosts named by role. + +Artifact maintenance gate: + +- AGENTS.md: no Go version stated; no change. +- Runtime project skills: none mention the Go version; no change. +- Specs: no Go version in `docs/` or `.agents/sow/specs/`; no change. +- End-user/operator docs: README states no Go version; no change. +- End-user/operator skills: `docs/netipc-integrator-skill.md` states no Go version; no change. +- SOW lifecycle: completed, moved to `done/`; Codacy re-check mapped to SOW-0050. + +Spec update: none needed (no spec states the Go version). + +Project skill update: none needed (see above). + +Lessons: a CMake cache keeps the `GO_EXECUTABLE` it first found, and `GOTOOLCHAIN=auto` silently switches an old `go` to the `go.mod` version, so a toolchain bump must check which binary each build directory really runs. + +Follow-up mapping: Codacy re-scan after push -> SOW-0050 preflight. `go fix` modernizations -> rejected here (style only), reported to the user. + +## Outcome + +plugin-ipc Go modules and the pinned CI workflows are on Go 1.27, matching Netdata; all suites pass with Go 1.27 on Linux, FreeBSD and Windows. + +## Lessons Extracted + +- Check the real toolchain per build directory (CMake cache, `GOTOOLCHAIN=auto`) when bumping Go. + +## Followup + +- Codacy findings after push: SOW-0050. + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0059-20261031-freebsd-shm-transport.md b/.agents/sow/done/SOW-0059-20261031-freebsd-shm-transport.md new file mode 100644 index 00000000..6c5a3645 --- /dev/null +++ b/.agents/sow/done/SOW-0059-20261031-freebsd-shm-transport.md @@ -0,0 +1,230 @@ +# SOW-0059 - FreeBSD SHM Transport + +## Status + +Status: completed + +Sub-state: delivered (2026-11-01). + +## Requirements + +### Purpose + +Give FreeBSD the shared-memory fast path that Linux and Windows have, so FreeBSD sessions are not limited to UDS throughput. + +### User Request + +2026-10-31: "I want SHM on freebsd, not high priority." + +### Assistant Understanding + +Facts: + +- FreeBSD sessions use the UDS SEQPACKET baseline only. Since SOW-0048 the POSIX handshake masks profiles to baseline off Linux (`NIPC_UDS_PLATFORM_PROFILES` in `netipc_uds_internal.h`, Rust `PLATFORM_PROFILES` in `transport/posix.rs`, Go `platformProfiles` in `transport/posix/shm_other.go`). +- The POSIX SHM transport waits and wakes with Linux futexes (`netipc_shm.c`, ``); its region layout and synchronization protocol are specified in `docs/level1-posix-shm.md`. +- Region files are created with Linux `fallocate` (`docs/level1-posix-shm.md`, region creation). +- FreeBSD's default SEQPACKET buffer is 8192 bytes (SOW-0048), so large messages chunk often there; SHM avoids that. + +Inferences: + +- FreeBSD's `_umtx_op(2)` with `UMTX_OP_WAIT_UINT` / `UMTX_OP_WAKE` (or the `_PRIVATE` variants for process-private use; shared regions need the non-private ones) is the likely futex equivalent. Unverified for cross-process use on a shared file mapping. +- `posix_fallocate(2)` is the likely portable replacement for Linux `fallocate`. Unverified on the filesystems Netdata uses on FreeBSD. + +Unknowns: + +- Whether the existing region layout and wait/wake protocol map onto `_umtx_op` without a wire or layout change (if the layout must change, both peers must agree, which affects the SHM spec). +- Go: pure Go must call `_umtx_op` through `syscall.Syscall6`; whether Go's `syscall` package exposes the needed constant numbers on FreeBSD. + +### Acceptance Criteria + +- C, Rust and Go negotiate and use SHM on FreeBSD, interoperate in every language pair, and fall back to baseline when SHM preparation fails, exactly as on Linux. +- Linux behavior and the SHM spec stay compatible (or the spec change is decided by the user first). +- Benchmarks on FreeBSD show the SHM rows. + +## Analysis + +Probes on the FreeBSD 14.3 test VM (amd64, UFS root, 2026-11-01; C programs, no root): + +- `_umtx_op(UMTX_OP_WAIT_UINT)` + `UMTX_OP_WAKE` on a `MAP_SHARED` file mapping: a waiter in one process is woken by a store + wake from another process that mapped the same file separately (woke after 106 ms, value 1). The `_PRIVATE` variants do not cross processes (the waiter timed out at 5000 ms). So the shared variants are the futex equivalent. +- Timeout: `struct _umtx_time {timespec _timeout; u32 _flags; u32 _clockid}` passed as `uaddr2` with its size in `uaddr1`; relative CLOCK_MONOTONIC timeout returns `ETIMEDOUT` after 50.1 ms for 50 ms. +- Value mismatch: `WAIT_UINT` with a stale expected value returns 0 at once (Linux futex returns `EAGAIN`). Callers already re-check the sequence after every wake, so the difference is harmless but must be handled as "re-check", not as an error. +- Constants (`/usr/include/sys/umtx.h`): `UMTX_OP_WAKE` 3, `UMTX_OP_WAIT_UINT` 11, `_PRIVATE` variants 15 and 16. Go's `syscall` package defines `SYS__UMTX_OP` (454) for every FreeBSD architecture, so pure Go can call it through `syscall.Syscall6`; the Go runtime itself uses `_umtx_op`. +- `posix_fallocate` on a UFS file: ok. +- Named POSIX shm objects (`shm_open`, mode 0600 honored): `posix_fallocate` returns ok even for 4 TiB on a host with 16 GiB RAM and 1 GiB swap, so on FreeBSD it reserves nothing for shm objects. A shm object therefore does not meet the spec's reservation guarantee (`docs/level1-posix-shm.md`, region creation step 3). + +Where Netdata puts the run directory (Netdata `src/libnetdata/os/run_dir.c` `detect_run_dir`): `$NETDATA_RUN_DIR`, else `/var/run/netdata` on FreeBSD, else `/tmp/netdata`. On a FreeBSD install with a ZFS root, `/var/run` is on ZFS. + +Root probes (2026-11-01, approved; temporary file-backed ZFS pool and a 64 MiB tmpfs under `/tmp/costa-*`, all removed and the zfs module unloaded afterwards; ZFS 2.2.7 on FreeBSD 14.3): + +| Filesystem | 8 MiB `posix_fallocate` | 64 GiB (over capacity) | Free space after the 8 MiB call | +|---|---|---|---| +| ZFS | `EINVAL` | `EINVAL` | unchanged | +| tmpfs | ok | `ENOSPC` | dropped 8 MiB | +| UFS | ok | ok (the disk had the room) | dropped 8 MiB (64 GiB call: dropped 64 GiB) | + +- ZFS rejects `posix_fallocate`, so with the current spec (step 3: no sparse fallback) a FreeBSD server whose run directory is on ZFS never offers SHM and serves baseline. That includes Netdata's default `/var/run/netdata` on a ZFS-root install. +- tmpfs and UFS really reserve the space and report exhaustion as an error, which is the spec's guarantee. +- ZFS is copy-on-write, so no allocation call can promise that later writes to a mapped region find space; a sparse or zero-filled region on ZFS can still fail a write when the pool fills, which shows up as `SIGBUS` in the mapped access the spec rules out. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- Feature gap: the POSIX SHM data plane is Linux-only by construction. Only two primitives are OS-specific: the futex wait/wake and the reserving allocation (see Analysis); the region layout, sequence protocol and handshake are portable. + +Evidence reviewed: + +- Probes and code paths in Analysis; SOW-0048 platform split; `docs/level1-posix-shm.md`; Netdata `src/libnetdata/os/run_dir.c`; Netdata `.github/data/distros.yml:504-508` (FreeBSD: community support, no architecture list); Rust `libc` 0.2.186 (`_umtx_op`, `_umtx_time`, `UMTX_OP_*`, `posix_fallocate`, `CLOCK_MONOTONIC` = 4 on FreeBSD); Go `syscall` (`SYS__UMTX_OP` 454, `SYS_POSIX_FALLOCATE` 530 on every FreeBSD architecture, no wrappers). + +Affected contracts and surfaces: + +- C `src/libnetdata/netipc/src/transport/posix/netipc_shm.c` (platform guard, wait/wake, allocation), `netipc_uds_internal.h` (`NIPC_UDS_PLATFORM_PROFILES`), `CMakeLists.txt` (`NETIPC_POSIX_SHM` and SHM test targets), `tests/fixtures/c/shm_fallocate_wrap.c` (FreeBSD wraps `posix_fallocate`), C tests with SHM gates. +- Rust `transport/shm.rs`, `transport/mod.rs`, `transport/posix.rs` (`PLATFORM_PROFILES`) and the `target_os = "linux"` SHM gates in `service/raw/*` and the tests. +- Go `transport/posix/shm_linux.go` (made portable to `linux || (freebsd && (amd64 || 386))`, with the wait/wake, allocation and clock id moved to per-OS files), `shm_other.go` (the complement), SHM tests. +- Wire format and region layout: unchanged. +- Specs: `docs/level1-posix-shm.md` (FreeBSD wait/wake and allocation, ZFS note), `docs/level1-transport.md` (platform table), `docs/netipc-integrator-skill.md`, README platform table, AGENTS.md FreeBSD command line (the SHM suites now build there). + +Existing patterns to reuse: + +- C platform `#if` blocks (`netipc_uds_lifecycle.c`), Rust `cfg(any(target_os = "linux", target_os = "freebsd"))` as at `posix.rs:742` (decision 8A), Go build-tagged files (`uds_cloexec_*.go`). +- Existing SHM tests and fault injection (`shm_fallocate_wrap.c`, Rust and Go allocation hooks) run unchanged on FreeBSD. + +Risk and blast radius: + +- Linux: the wait/wake and allocation calls move behind per-OS helpers; Linux keeps the same syscalls and arguments. Covered by the Linux suites and all 9 Netdata Linux targets. +- FreeBSD: new SHM path. `UMTX_OP_WAIT_UINT` returns 0 on a value mismatch (Linux: `EAGAIN`); the wait loops already re-check the sequence after every return, so 0 is handled as "re-check". `posix_fallocate` returns the error number instead of setting `errno`; the C helper converts it. +- Go raw syscalls are hand-written per architecture; limited to amd64 and 386, both testable on the FreeBSD VM (decision 9A). +- Region files on ZFS: allocation fails with `EINVAL`, SHM preparation fails, baseline is used (decision 7A); covered by the existing allocation-failure tests. + +Sensitive data handling plan: + +- Source paths, man-page citations and command output only, in the SOW, specs, docs, skills, AGENTS.md and code comments; hosts named by role. + +Implementation plan: + +1. C: per-OS `shm_wait`/`shm_wake`/`shm_allocate` helpers; FreeBSD guard; CMake builds SHM and its tests on FreeBSD; the test wrapper wraps `posix_fallocate` there; C test gates per the classification. +2. Lift the FreeBSD profile mask in C, Rust and Go (Go only on amd64 and 386). +3. Rust: the same helpers in `shm.rs` (`libc::_umtx_op`, `libc::posix_fallocate`), cfg gates extended in place (8A). +4. Go: portable `shm_posix.go` plus `shm_futex_linux.go` and `shm_umtx_freebsd.go` (wait, wake, allocation, clock id) and per-architecture `posix_fallocate` argument layout for amd64 and 386. +5. Tests: the existing SHM suites run on FreeBSD; add a FreeBSD check that a ZFS-style `EINVAL` from the allocation falls back to baseline through the existing fault hooks. +6. Docs and specs; FreeBSD benchmark run (decision 10A). + +Validation plan: + +- FreeBSD 14.3 (amd64): CTest including the SHM suites, `cargo test`, `go test ./...`, Go with `GOARCH=386`, all POSIX interop scripts including the SHM ones. +- Linux: CTest, `cargo test` (stable and 1.91), `go test`, all interop scripts, clippy and fmt; all 9 Netdata Linux targets. +- macOS: build and suites (SHM stays off; stubs must still build). +- Windows: Rust clippy for the Windows target (shared crate). + +Artifact impact plan: + +- See Affected contracts and surfaces (specs, docs, integrator skill, README, AGENTS.md). + +Performance assessment: + +- Linux established-session path: the wait/wake and allocation move behind static helpers with the same syscalls (C inlines them; Rust `#[inline]`; Go per-OS functions), so no Linux change is expected. The full pre-vendoring benchmark run measures it. +- FreeBSD: new path; one benchmark run on the VM, SHM against baseline, recorded here without floors (decision 10A). + +Open decisions: + +- None (decisions 7A, 8A, 9A, 10A recorded). + +## Implications And Decisions + +- Priority (2026-10-31, user): low; after SOW-0050, SOW-0049 and SOW-0057. +- Backing store (2026-11-01, user: "7A"): keep the spec's reservation rule unchanged. FreeBSD regions use `posix_fallocate` in the run directory; where it fails (ZFS returns `EINVAL`), SHM preparation fails as specified and the session uses baseline. Document that a FreeBSD operator can point the run directory at tmpfs or UFS to get SHM. Rejected: a sparse or zero-filled region on ZFS (option B, `SIGBUS` on a full pool) and `shm_open` memory objects (option C, no reservation on FreeBSD either, OOM kill instead of an error). +- Rust platform gates (2026-11-01, user: agreed to the recommendations, decision 8A): extend each `target_os = "linux"` SHM gate in place to `any(target_os = "linux", target_os = "freebsd")`; no build script. +- FreeBSD architectures (decision 9A): C and Rust SHM on every FreeBSD architecture (they go through libc); Go SHM only on FreeBSD amd64 and 386, whose raw-syscall argument layouts can be tested on the amd64 VM (386 through FreeBSD's 32-bit compatibility); other Go FreeBSD builds negotiate baseline. +- FreeBSD benchmarks (decision 10A): one benchmark run on the FreeBSD VM comparing SHM with baseline, recorded in this SOW as evidence, no floors. +- Root probes on the FreeBSD test VM (2026-11-01, user: "a"): temporary ZFS file-backed pool and tmpfs under `/tmp/costa-*`, removed after the probe. +- Ordering (2026-10-31, user, supersedes earlier ordering): finish all code changes first — SOW-0045, SOW-0058, SOW-0057, SOW-0049, SOW-0059, and SOW-0056 when the FreeBSD 15.1 VM arrives — then run the full benchmarks and the SOW-0050 vendoring together; benchmark regressions are fixed and re-vendored. + +## Plan + +See the gate. + +## Execution Log + +### 2026-10-31 + +Created at the user's request. + +### 2026-11-01 + +- C: FreeBSD `futex_wake`/`futex_wait` over `_umtx_op(UMTX_OP_WAKE / UMTX_OP_WAIT_UINT)` with a relative `struct _umtx_time` on `CLOCK_MONOTONIC`; `shm_allocate()` (Linux `fallocate`, FreeBSD `posix_fallocate` with its return value moved to `errno`); `NIPC_UDS_PLATFORM_PROFILES` covers FreeBSD. CMake builds SHM and its tests on FreeBSD (`NETIPC_POSIX_SHM`), wraps `posix_fallocate` there for fault injection, and gates the Go SHM tests on `NETIPC_GO_POSIX_SHM` (Linux, FreeBSD amd64/i386). The C test SHM gates cover FreeBSD; `test_shm_full_backing_store` stays Linux-only (it needs a Linux tmpfs mount). +- Rust: the same split in `transport/shm.rs` (`libc::_umtx_op`, `libc::posix_fallocate`); `PLATFORM_PROFILES` covers FreeBSD; the SHM `target_os = "linux"` gates in `service/raw/*`, the tests, the interop fixture and the bench driver extended in place (8A). The POLLRDHUP gates stay Linux-only (FreeBSD has no POLLRDHUP). +- Go: `shm_linux.go` became `shm_posix.go` (`linux || (freebsd && (amd64 || 386))`); per-OS `shm_futex_linux.go` and `shm_umtx_freebsd.go` (wait, wake, clock id, allocation); `posix_fallocate` raw syscall per architecture (`shm_fallocate_freebsd_amd64.go`, `shm_fallocate_freebsd_386.go`, 64-bit offsets split into two words on 386; the error comes back in the return value); `shm_other.go` and the pause files retagged as complements; SHM tests renamed `*_posix_test.go` and retagged. +- FreeBSD found two `EISDIR`-only directory checks. FreeBSD and macOS fail `unlink` on a directory with `EPERM`: + - SHM stale region at a directory (C `unlink_stale_name`, Rust `unlink_stale_path`): found by `test_shm` "create reclaims endpoint directory" on FreeBSD; both now accept `EISDIR` or `EPERM`. + - UDS stale endpoint at an empty directory (C `netipc_uds_lifecycle.c` `unlink_stale_socket_path`, Rust `transport/posix.rs` stale check): found by the same-failure search. New tests in C (`test_stale_recovery_reclaims_empty_directory`), Rust (`test_empty_directory_at_socket_path_is_reclaimed`) and Go (`TestStaleRecoveryReclaimsEmptyDirectory`) failed for C and Rust on macOS before the fix (address-in-use) and pass after; Go (`os.Remove`) already reclaimed it. Spec note in `docs/level1-posix-uds.md`, "Socket file". +- New tests: ZFS-style `EINVAL` allocation in C (`test_shm.c`), Rust (`test_server_create_does_not_fallback_when_fallocate_is_unsupported`, now over `EOPNOTSUPP` and `EINVAL`) and Go (`TestShmServerCreatePreallocation`, same two causes); Go FreeBSD raw-syscall checks `TestPosixFallocateRawSyscall` (offset above 4 GiB so both 386 words matter; a pipe returns `ESPIPE`) and `TestUmtxWaitStaleValueAndTimeout`. + +## Validation + +Acceptance criteria evidence: + +- C, Rust and Go negotiate and use SHM on FreeBSD and interoperate in every pair: FreeBSD 14.3 `test_shm_interop` 9/9, `test_service_shm_interop` 18/18, `test_cache_shm_interop` 9/9, `test_lookup_scale_interop` 36/36 (0 skipped). +- Fallback to baseline on SHM preparation failure, exactly as on Linux: the existing allocation-failure tests run on FreeBSD (C wraps `posix_fallocate`; Rust and Go hooks), plus the new `EINVAL` cases. +- Linux behavior and the SHM spec stay compatible: no layout or wire change; Linux keeps the same syscalls behind the helpers; all Linux suites and the 9 Netdata Linux targets pass. +- Benchmarks on FreeBSD show the SHM rows: see Performance below. + +Tests (final code, 2026-11-01): + +- FreeBSD 14.3 amd64 (UFS): build, CTest 31/31, `cargo test` 394, `go test ./...`, `GOARCH=386 go test ./...`, all 8 interop scripts with 0 skipped. +- Linux: build, CTest, `cargo test` stable and 1.91, `cargo fmt --check`, `go vet` and `go test`, all 8 interop scripts; CI clippy gate for Linux, `x86_64-pc-windows-gnu` and `x86_64-unknown-freebsd`; `go vet` for linux/amd64, linux/386, freebsd/amd64, freebsd/386, freebsd/arm64, darwin/arm64, windows/amd64. +- Netdata Linux targets: all 9 (x86_64-musl, i386-gnu, i386-musl, armv7-gnu, armv7-gnu-time64, armv7-musl, armv6-musl, aarch64-gnu, aarch64-musl) pass, peak PSI full avg10 at most 4.59%. +- macOS: CTest 24/24, `cargo test` 326, `go test`, and the macOS interop list (SHM stays off; the stubs build). +- Windows: `cargo test` 273, `go test`, CTest 20/20. + +Real-use evidence: the SHM interop scripts and the benchmark run real C, Rust and Go servers and clients over SHM on FreeBSD. + +Reviewer findings: no external review was requested for this SOW. + +Same-failure search: + +- `EISDIR` across `src/` and `tests/`: four sites. Two were SHM (fixed), two were UDS endpoint recovery in C and Rust (fixed, see Execution Log). Go uses `os.Remove`, which handles both errors. +- Other Linux-only SHM gates: `grep target_os = "linux"` in the crate leaves only the POLLRDHUP gates and `test_poll_fd_eintr`, which are not SHM. + +Performance assessment: + +- Established-session hot path: SHM wait/wake (C `netipc_shm.c` `futex_wait`/`futex_wake`, Rust `transport/shm.rs`, Go `shm_posix.go:424` and `:537` calling `futexWakeCall`/`futexWaitCall`). On Linux these keep the same syscalls and arguments behind per-OS helpers; allocation runs only at session setup. The full pre-vendoring benchmark run (with SOW-0050) measures Linux; no Linux run in this SOW. +- FreeBSD (decision 10A, no floors): one `tests/run-posix-bench.sh` run on the FreeBSD 14.3 VM (8 vCPU, shared; `NIPC_BENCH_FLOOR_RETRY_SAMPLES=0`), 336 rows. Max-rate throughput, baseline -> SHM, over the 9 language pairs: + - ping-pong: 89k-136k -> 526k-1.81M req/s (5.4x-15.2x; Go clients 5.4x-6.8x, C and Rust clients 10.7x-15.2x); p50 5-8 us -> under 1 us. + - batch ping-pong: 12.9M-18.3M -> 15.6M-23.1M items/s (1.19x-1.51x); p50 22-30 us -> 15-23 us. + - snapshot refresh: 66k-106k -> 260k-727k/s (3.6x-7.4x). + - At fixed 1000/s rates SHM lowers p50 (ping-pong 44-69 us -> 40-55 us; batch 98-124 us -> 56-75 us) at equal or lower CPU. + - `uds-pipeline-batch` (baseline only, no SHM) failed for all 9 pairs: a write-write deadlock in the benchmark client on FreeBSD's 8 KiB SEQPACKET buffers (see Followup). Every other row completed. + +Artifact maintenance gate: + +- `AGENTS.md`: FreeBSD command line updated (SHM runs there; Go 386 run; tests need UFS or tmpfs). +- Runtime project skills: `project-netdata-vendoring` unaffected (no vendoring workflow change; Netdata does not build NetIPC on FreeBSD yet). +- Specs: `docs/level1-posix-shm.md` (FreeBSD syscalls table, umtx wait/wake and stale-value return, `posix_fallocate`, ZFS `EINVAL` and run-directory guidance), `docs/level1-transport.md` (SHM platform table and fallback text), `docs/level1-posix-uds.md` (empty directory at the endpoint). No `.agents/sow/specs/` file states FreeBSD behavior. +- End-user/operator docs: README platform table; `docs/netipc-integrator-skill.md` platform notes. +- End-user/operator skills: `docs/netipc-integrator-skill.md` updated as above. +- SOW lifecycle: SOW-0056 acceptance criteria now include SHM on FreeBSD 15.1 (filesystem type recorded); this SOW moves to `done/` as completed in its commit. + +Sensitive data gate: hosts named by role; only source paths, command names and result counts recorded in the SOW, specs, docs, AGENTS.md and code comments. + +## Outcome + +FreeBSD has the POSIX SHM fast path in C and Rust on every architecture and in Go on amd64 and 386, interoperating across all language pairs. Region files need a run directory on tmpfs or UFS; on ZFS SHM preparation fails and sessions use baseline, as the spec requires. Directory leftovers at SHM and UDS endpoint paths are now reclaimed on FreeBSD and macOS as on Linux. + +## Lessons Extracted + +- `unlink` on a directory fails with `EISDIR` on Linux but `EPERM` on FreeBSD and macOS (POSIX allows both). Any "remove a directory if unlink says it is one" path must accept both. +- FreeBSD `posix_fallocate` returns its error number and does not set `errno`, and ZFS rejects it with `EINVAL`; reservation-based designs must treat ZFS as "no reservation". +- Recorded in the specs and code comments above; no project skill change needed. + +## Followup + +- FreeBSD 15.1 SHM validation: tracked in SOW-0056 (acceptance criteria updated). +- FreeBSD `uds-pipeline-batch` benchmark deadlock (found by this SOW's benchmark run, baseline transport, predates this SOW): tracked in SOW-0062, which needs a user decision on the fix. +- Linux benchmark coverage of the moved wait/wake helpers: the full pre-vendoring benchmark run with SOW-0050. +- Go SHM on FreeBSD architectures other than amd64 and 386: rejected for now by decision 9A (raw-syscall layouts cannot be tested here); those builds negotiate baseline. + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0061-20261101-go-server-hello-timeout-class.md b/.agents/sow/done/SOW-0061-20261101-go-server-hello-timeout-class.md new file mode 100644 index 00000000..4165eb60 --- /dev/null +++ b/.agents/sow/done/SOW-0061-20261101-go-server-hello-timeout-class.md @@ -0,0 +1,172 @@ +# SOW-0061 - Go Server HELLO Timeout Error Class + +## Status + +Status: completed + +Sub-state: delivered 2026-11-01; Go servers on POSIX and Windows report HELLO timeout and abort as C and Rust do. + +## Requirements + +### Purpose + +The Go server handshake must report a HELLO timeout with the same error class as C and Rust, so callers of the public transport APIs can tell a silent client from a failed receive. + +### User Request + +Found during the SOW-0049 review (2026-11-01). Zero-known-bug policy: confirmed bugs are fixed. + +### Assistant Understanding + +Facts: + +- `src/go/pkg/netipc/transport/internal/framing/handshake.go` `ServerHandshake` wraps every `Recv` error as `config.ErrRecv("hello recv: ...")`. The message keeps only the text, so `errors.Is(err, posix.ErrTimeout)` is false. +- Probe (2026-11-01, Linux, Go 1.27): a client connects and sends nothing; `PendingConn.Handshake(1, cfg, 50, nil)` returns `recv failed or peer disconnected: hello recv: receive deadline expired`, with `errors.Is(err, ErrTimeout) == false` and `errors.Is(err, ErrRecv) == true`. +- The Go client handshake already keeps the wait error (`transport/posix/uds_handshake.go`, `waitErr`), so only the server side is affected. +- `service/raw` servers drop the handshake error (`service/raw/server_unix.go` `serveConnection`), so the visible effect is on direct users of `posix.Listener`/`PendingConn` (and the Windows equivalents). + +- The Windows Go server goes through the same `framing.ServerHandshake` (`transport/windows/pipe_handshake.go:126`); its `Recv` is `rawRecvWithTimeout` (`pipe_receive.go:109`), whose wait returns `ErrTimeout`/`ErrAborted` (`pipe_receive.go:78`, `:90`), so it has the same gap. +- C and Rust return the HELLO wait's error unchanged: C `nipc_uds_wait_for_readable` before `nipc_uds_server_handshake` (`netipc_uds_lifecycle.c`, "One deadline covers the wait for HELLO"); Rust `handshake_inner` (`transport/posix.rs`, `wait_fd_readable(...)?`; `transport/windows.rs:1115`, `wait.wait_slice_ms()?` and `Err(NpError::Aborted)`). + +Inferences: + +- None. + +Unknowns: + +- None. + +### Acceptance Criteria + +- A Go server HELLO timeout (POSIX and Windows) satisfies `errors.Is(err, ErrTimeout)`; an abort satisfies `errors.Is(err, ErrAborted)`; matching C and Rust; covered by tests on both platforms. + +## Analysis + +Sources checked: the files cited in Facts, and the Go client handshakes, which already keep the wait error in a `waitErr` captured by their `Recv` closure (`transport/posix/uds_handshake.go`, `transport/windows/pipe_handshake.go:46-61`). + +Current state: Go servers on POSIX and Windows report a HELLO timeout or abort as `ErrRecv`; C and Rust report TIMEOUT or ABORTED. + +Risks: low. Only the error value returned by a failed server handshake changes; `service/raw` servers drop it (`service/raw/server_unix.go` `serveConnection`, same on Windows). + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- `framing.ServerHandshake` turns every `Recv` error into a new `ErrRecv` with only the text, so the wait's `ErrTimeout`/`ErrAborted` is lost. See Facts. + +Evidence reviewed: + +- Code paths in Facts and Analysis; the 2026-11-01 probe. + +Affected contracts and surfaces: + +- Go `posix.PendingConn.Handshake`/`HandshakePrepared` and the Windows equivalents: error class only. Wire format, C and Rust: unchanged. + +Existing patterns to reuse: + +- The Go client handshake's `waitErr` capture; the posix server's existing `streamErr` capture (macOS), which this generalizes. + +Risk and blast radius: + +- Go only; handshake error class only; no hot path. + +Sensitive data handling plan: + +- Code paths and command output only, in the SOW, specs, docs, skills and code comments; hosts named by role. + +Implementation plan: + +1. POSIX `serverHandshakeWait`: keep the `Recv` error when it is `ErrTimeout` or `ErrAborted` (and, on a stream, `ErrProtocol`, as now) and return it. +2. Windows `serverHandshakeWait`: keep `ErrTimeout` or `ErrAborted` the same way. +3. Tests: call `serverHandshakeWait` on a connected pair whose client never sends HELLO, with a short deadline (expect `ErrTimeout`) and with a closed abort channel (expect `ErrAborted`); POSIX in `transport/posix`, Windows in `transport/windows`. + +Validation plan: + +- Each new test fails before the fix and passes after it; `go test ./...` and `go vet` on Linux, macOS, FreeBSD and Windows; Go part of the Netdata Linux targets is not needed (no architecture-specific code). + +Artifact impact plan: + +- Specs: check `docs/level1-posix-uds.md` and the Windows transport doc for a server HELLO timeout statement; update if they describe the error class. +- AGENTS.md, project skills, README, integrator skill: expected unaffected; confirm at close. + +Performance assessment: + +- Handshake only; nothing on the established-session path; no benchmark run. + +Open decisions: + +- None: the fix restores the documented parity with C and Rust and has no design fork. + +## Implications And Decisions + +- Scheduling (2026-11-01, user: "5A"): right after the SOW-0052 regression fix, before SOW-0059. + +## Plan + +See Pre-Implementation Gate, Implementation plan. + +## Execution Log + +### 2026-11-01 + +Created from the SOW-0049 review follow-up. + +- Gate filled; moved to `current/`. +- `transport/posix/uds_handshake.go` `serverHandshakeWait`: the existing macOS-only `streamErr` capture became `waitErr`, which also keeps `ErrTimeout` and `ErrAborted` on every POSIX platform. +- `transport/windows/pipe_handshake.go` `serverHandshakeWait`: the same `waitErr` capture for `ErrTimeout` and `ErrAborted`. +- Tests: `TestServerHandshakeWaitKeepsTimeoutAndAbort` in `transport/posix/uds_more_edge_test.go` (socket pair) and `transport/windows/pipe_edge_test.go` (raw pipe pair). The client end never sends HELLO; the cases are a 20 ms deadline and an already-closed abort channel, so the outcome does not depend on timing. +- Specs: `docs/level1-posix-uds.md` and `docs/level1-windows-np.md` now state the server's expiry and abort error classes, as they already did for the client. + +## Validation + +Acceptance criteria evidence: + +- Both new tests fail before the fix with "recv failed or peer disconnected: hello recv: receive deadline expired" (and "receive aborted"), on Linux and on Windows (pre-fix `pipe_handshake.go` copied onto the Windows host's tree), and pass after it. +- Linux: `go vet ./...` (linux, freebsd, darwin, windows), `go test ./...` all ok; `test_uds_interop.sh`, `test_service_interop.sh`, `test_service_shm_interop.sh` 18/18 each. +- macOS test host: `go vet`, `go test ./...` ok; `test_service_interop.sh` 18/18. +- FreeBSD 14.3: `go vet`, `go test ./...` ok; `test_service_interop.sh` 18/18. +- Windows: `go test ./...` all packages ok; CTest 20/20 (C/Rust/Go interop over pipes and SHM); `cargo test` 273 passed. +- Netdata Linux targets: not run; the change is Go source with no architecture- or libc-specific code, and the Go handshake paths are already covered by the native runs above. + +Reviewer findings: none requested; the defect itself came from the SOW-0049 review. + +Same-failure search: the only other place where a `Recv` error is wrapped as `ErrRecv` in the Go handshakes is the client side, which already keeps the wait error (`waitErr`) on both platforms. `framing.ServerHandshake` has no other callers. + +Performance assessment: handshake only; nothing on the established-session path; no benchmark run. + +Sensitive data gate: code paths, test names and command output only; hosts named by role. + +Artifact maintenance gate: + +- AGENTS.md: no change; no workflow or command changed. +- Runtime project skills: no change; the vendoring preflight is unaffected. +- Specs: `docs/level1-posix-uds.md` and `docs/level1-windows-np.md` (server expiry/abort error classes); `.agents/sow/specs/` holds no handshake error rules. +- End-user/operator docs: README does not describe handshake errors; no change. +- End-user/operator skills: `docs/netipc-integrator-skill.md` does not list handshake error classes; no change. +- SOW lifecycle: completed, moved to `done/`. + +Spec update: see the artifact gate. + +Project skill update: none needed (see above). + +Lessons: see Lessons Extracted. + +Follow-up mapping: none left. + +## Outcome + +Go servers on POSIX and Windows now return `ErrTimeout` for a HELLO that misses the deadline and `ErrAborted` for an aborted wait, matching C and Rust. + +## Lessons Extracted + +- A shared framing layer that rewraps errors as text loses their class; the transport layer must capture the errors it needs to keep, as the client already did. + +## Followup + +None. + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0062-20261101-pipeline-batch-bench-small-buffers.md b/.agents/sow/done/SOW-0062-20261101-pipeline-batch-bench-small-buffers.md new file mode 100644 index 00000000..3aab27f9 --- /dev/null +++ b/.agents/sow/done/SOW-0062-20261101-pipeline-batch-bench-small-buffers.md @@ -0,0 +1,264 @@ +# SOW-0062 - Pipeline+Batch Benchmark Deadlock On Small Socket Buffers + +## Status + +Status: completed + +Sub-state: completed 2026-11-03; implemented and validated on Linux, the 9 Netdata Linux targets, FreeBSD and macOS. + +## Requirements + +### Purpose + +Every benchmark row must run on every POSIX platform that runs the benchmark suite, so FreeBSD (and macOS) benchmark runs are complete evidence. + +### User Request + +Standing rules: zero known bugs; a Netdata-supported configuration that cannot be tested is a blocker. SOW-0059 decision 10A asked for a FreeBSD benchmark run. + +### Assistant Understanding + +Facts: + +- On the FreeBSD 14.3 test VM, `tests/run-posix-bench.sh` row `uds-pipeline-batch` (depth 16) fails for all 9 C/Rust/Go pairs; every other row completes, including `uds-pipeline-d16` (single-item pipelining). +- Reproduction (C client, C batch server): the client blocks for the server's whole lifetime (34 s with a 30 s server) and then fails with `EPIPE` when the server exits. Rust and Go clients fail the same way. +- `truss` of the server: it receives request 1 (8192 + 1440 bytes), sends the first 8192-byte chunk of its response, then blocks sending the rest. The client is still sending requests 2..16 and reads nothing, so both sides block in send: a write-write deadlock. +- The client sends `depth` batches of 2-1000 random items before reading any response (`bench/drivers/c/bench_posix.c` `uds-pipeline-batch-client`, Rust `bench/drivers/rust/src/main.rs` `run_pipeline_batch_client`, Go `bench/drivers/go/main.go` `runPipelineBatchClient`). +- FreeBSD's default SEQPACKET `SO_SNDBUF` is 8192 bytes (SOW-0048); Linux's is about 208 KiB, which holds the bytes in flight, so the row passes there. +- The spec already tells callers about this on macOS (`docs/level1-posix-uds.md`, macOS stream baseline, "Socket buffers": read responses while sending, or keep the bytes in flight small). FreeBSD has no such note. +- No session send timeout exists (`SO_SNDTIMEO` is set only while connecting, `netipc_uds_lifecycle.c`), so the blocking is unbounded until a peer exits. + +Inferences: + +- The library behaves as specified (blocking sends, ordinary socket backpressure); the defect is the benchmark client's send-everything-then-read pattern on small-buffer platforms. +- macOS (8 KiB stream buffers) most likely deadlocks the same way; not yet run there. + +Unknowns: + +- None that investigation cannot resolve; the fix direction is a user decision. + +### Acceptance Criteria + +- `uds-pipeline-batch` completes for all 9 pairs on Linux, FreeBSD and macOS. +- Pipelining a client over the POSIX baseline cannot deadlock on small buffers in C, Rust or Go (reproduction tests on shrunken socket buffers pass); one-in-flight calls keep today's blocking send. +- Docs describe the POSIX behavior and the interim Windows caller rule. + +## Analysis + +Fix options (presented 2026-11-01; decided, see Implications And Decisions): + +1. Benchmark clients read responses while sending (send one, drain whatever responses are ready, keep at most `depth` in flight). Same scenario on every platform; Linux numbers may shift, so the Linux floor for this row needs re-evidence. +2. Benchmark clients cap the bytes in flight at what the socket buffers hold (reduce the effective depth on small-buffer platforms, record the effective depth in the row). Linux unchanged; FreeBSD/macOS rows measure a smaller depth. +3. Library: raise session `SO_SNDBUF`/`SO_RCVBUF` on FreeBSD/macOS toward Linux defaults, in C, Rust and Go. On FreeBSD this also raises the SEQPACKET packet size (fewer chunks). Moves the threshold; does not remove the caller constraint; affects packet-size negotiation and needs cross-language interop validation. + +Production session concurrency (checked 2026-11-01, part of decision 3a): + +- Sessions are single-threaded in every language, so a caller cannot read on one thread while sending on another: + - C: send adds to the in-flight message-id set, growing it with `realloc` (`netipc_uds_send.c:42`, `netipc_uds_inflight.c`); receive removes from it (`netipc_uds_receive.c:153`). No lock: concurrent use is a data race. + - Rust: `UdsSession::send(&mut self)` and `receive(&mut self)` (`transport/posix.rs:353`) cannot run at the same time; the compiler enforces it. + - Go: `Session.Send` and `Session.Receive` both use the plain map `inflightIDs` (`uds_session.go:56`) with no mutex; concurrent use risks a "concurrent map write" panic. +- A chunked message is sent by one blocking call (the `sendmsg` loop in the trace above). Once inside it, the caller cannot read until the whole message is out. So single-thread "read while sending" (option 1 as decided) cannot be built on the current API: with more than one message in flight and the bytes beyond the buffers, client and server can still both block in send. +- Deadlock-free pipelining therefore needs one of: a sender thread and a receiver thread on the same session (library change in C, Rust and Go), or the caller keeping unread bytes in flight within the real buffer capacity (no library change). This reopens the fix decision. + +Netdata consumers (checked 2026-11-02, `netdata/netdata` master @ 5f67470fc2, read-only): + +- 30 files outside the vendored copy use NetIPC, all C (apps.plugin, cgroups.plugin, ebpf.plugin, network-viewer.plugin, common-cgroups and their tests); no Go or Rust consumers. +- None calls the transport directly (`nipc_uds_send`/`receive`, `nipc_shm_*`); all use the typed API (`nipc_client_call_cgroups_lookup`, `nipc_client_call_apps_lookup`, `nipc_client_call_cgroups_snapshot_timeout`, `nipc_server_*`). +- A typed client call is one send then one receive (`netipc_service_common.c:365-370`), so at most one request is in flight; the server session loop is receive, handle, send. Neither can hit this deadlock, so no Netdata caller changes are needed. Netdata receives the library change through normal vendoring (SOW-0050 preflight). +- Design point for the gate: the deadlock needs both peers blocked in send; progress on the client side (draining responses while its send waits) is enough. A server that queued requests during a stalled send would need its own bound, so server-side progress must be justified before adding it. + +Windows named pipes (checked 2026-11-02): + +- Same deadlock class. All three languages use synchronous (non-overlapped), message-mode pipes with 64 KiB buffers per direction (C `netipc_named_pipe.c:218-222` `CreateNamedPipeW(... PIPE_WAIT ..., buf_size, buf_size ...)`, `buf_size = max(packet_size, 64 KiB)` at `:58-63`; client `CreateFileW` at `:986-990` without `FILE_FLAG_OVERLAPPED`; Rust `windows.rs:1164-1176`; Go `pipe_listener.go:268-276`). Sends are blocking `WriteFile` per packet with no timeout; a blocked write cannot stop to read. +- The Windows pipeline-batch benchmark passes only because its clients already keep a byte budget: `inflight_budget = packet_size * 2` (`bench_windows.c:2007`), each slot costed as request plus response (`:2052`), responses drained before a send that would exceed it (`:2054-2063`); Go and Rust Windows drivers mirror it. This came from the pre-SOW rewrite, where the full-duplex pipelining deadlock was recorded (`.agents/sow/todo-history/TODO-pending-from-rewrite.md:2713`). +- `docs/level1-windows-np.md:159-167` says nothing about pipelining limits. +- The POSIX mechanism does not port directly: a synchronous pipe handle has no per-call "don't wait" flag. Windows progress needs overlapped I/O (`FILE_FLAG_OVERLAPPED` on both ends, every read and write in the C, Rust and Go Windows transports) or `PIPE_NOWAIT`, which Microsoft keeps only for LAN Manager compatibility. +- Netdata on Windows uses only typed one-in-flight calls (same consumer set as above), so it cannot hit this. + +POSIX design under decision 5C (no further forks found): + +- Where: only the POSIX transport's packet hooks change (C `nipc_uds_raw_send_iov` and the receive packet reads in `netipc_uds_receive.c`; Rust `transport/posix.rs` and `posix_stream.rs` write/read closures passed to `framing::send_message`/`receive_message`; Go `sendPacket`/`Recv` closures given to `framing.SessionSend`/`SessionReceive`). The shared framing code and the Windows transports stay unchanged. +- When: client sessions only, and only while other requests are in flight (the in-flight set holds more than the request being sent). A one-in-flight call, which is every typed call and every Netdata caller, keeps today's blocking send exactly. +- How: send the packet with `MSG_DONTWAIT`; on `EAGAIN`, `poll` for readable and writable; read what is readable without blocking into a per-session raw packet queue (SEQPACKET: whole datagrams; macOS stream: raw bytes); resume sending when writable. Receive takes packets from the queue before the socket, so reassembly and validation stay as they are. +- Bound: queued bytes may not exceed what the in-flight responses can occupy (in-flight count x (maximum response payload + header + chunk headers)); more is a protocol failure of the session. +- No new timeout: a stalled send waits as long as today's blocking send; abort semantics unchanged. +- Server sessions unchanged: the deadlock needs both peers blocked in send, and client progress alone breaks it. +- Benchmark clients unchanged on POSIX: their send-everything-then-read pattern is the reproduction, and with the fix it completes. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- A POSIX client with more than one request in flight sends each packet with a blocking call. When the unread responses fill its receive buffer, the server blocks sending and stops reading, and the client blocks sending: both peers block forever. Buffer size only moves the threshold (Linux about 208 KiB, FreeBSD and macOS 8 KiB). The current API cannot read during a send (Analysis). + +Evidence reviewed: + +- FreeBSD reproduction and `truss` traces (Assistant Understanding); send paths C `netipc_uds_send.c` (all packets through `nipc_uds_raw_send_iov`, `netipc_uds.c:69`; stream `nipc_uds_stream_send_iov`), Rust `transport/posix.rs:353-365` (`send_packet` closure into `framing::send_message`) and `posix_stream.rs` (`send_iov`), Go `uds_send.go:16-40` (`sendPacket` closures into `framing.SessionSend`) and `uds_stream.go`; receive paths C `netipc_uds_receive.c` (`receive_first_packet`, `receive_one_chunk`, stream `nipc_uds_stream_read_exact`, the stream wait at the top of `nipc_uds_receive_timeout`), Rust `posix.rs:369-430` (`raw_recv_control`, stream `recv_session_packet`/`read_exact`), Go `uds_receive.go`; session state C `netipc_uds.h:92-115`, Rust `posix.rs:255-270`, Go `uds_session.go`; Netdata consumers and Windows (Analysis). + +Affected contracts and surfaces: + +- POSIX transport client send and receive in C, Rust and Go, both SEQPACKET (Linux, FreeBSD) and the macOS stream; session state gains a receive queue; session close frees it. +- Wire format, handshake, server sessions, SHM, shared framing modules, Windows transports: unchanged. +- Specs and docs: `docs/level1-transport.md` (pipelining), `docs/level1-posix-uds.md` (send progress; the macOS "Socket buffers" caller rule becomes a library guarantee), `docs/level1-windows-np.md` (interim caller rule, decision 6A), `docs/netipc-integrator-skill.md`. + +Existing patterns to reuse: + +- `poll`-based waits (`nipc_uds_wait_for_readable`, Rust `wait_fd_readable`, Go `internal/syspoll`); the stream partial-write loops; the in-flight set as the source of the bound; per-instance test hooks for timing. + +Risk and blast radius: + +- Only client sessions with more than one request in flight take the new path. Typed calls and every Netdata caller have one in flight and keep the blocking send unchanged. +- Receive gains one "queue empty?" check per packet; the queue is drained before the socket, so reassembly, limits and in-flight validation are unchanged. +- macOS stream: partial writes under `MSG_DONTWAIT` resume after progress; covered by the macOS host suites. +- Peer close during a stalled send: the read side stops at EOF and the next send fails with the existing send error; no busy loop. + +Sensitive data handling plan: + +- Source paths, command names and counts only, in this SOW, specs, docs, the integrator skill, AGENTS.md and code comments; hosts named by role. + +Implementation plan: + +1. C: session receive queue (SEQPACKET datagram records, stream raw bytes) and its bound; a session-level packet send that uses `MSG_DONTWAIT` plus `poll` progress when the client has other requests in flight; the receive packet reads and the stream wait take from the queue first; free on close. +2. Rust: the same in `transport/posix.rs` and `posix_stream.rs`, through the existing closures. +3. Go: the same in `transport/posix` (`uds.go`, `uds_stream.go`, `uds_receive.go`, `uds_session.go`). +4. Reproduction tests in each language: a client pipelines 16 messages of about 16 KB over a session with forced small packet size and shrunken socket buffers, against a plain receive-then-send server thread; plus peer-close during a stalled send, and the queue bound. +5. Docs and integrator skill. + +Validation plan: + +- Reproduction tests fail on the current code (recorded) and pass after the fix, in C, Rust and Go. +- Linux: CTest, `cargo test` stable and 1.91, fmt, clippy gate, `go test`, all interop scripts; all 9 Netdata Linux targets. +- FreeBSD 14.3: full suite, plus the `uds-pipeline-batch` benchmark scenario for all 9 language pairs (the original failure) as real-use evidence. +- macOS host: full suite (stream variant). +- Windows: the shared framing code is untouched; Windows Rust clippy and the Windows suites confirm. + +Artifact impact plan: + +- Specs/docs listed under Affected contracts; AGENTS.md unaffected unless commands change; the project vendoring skill unaffected. + +Performance assessment: + +- Per-request established-session code touched: the client packet send (one branch on role and in-flight count before the existing `sendmsg`; pipelining clients use `MSG_DONTWAIT` on the same call) and each packet read (one queue-empty check). Single-in-flight rows keep the same syscalls. This touches the measured send/receive and pipelining paths, so the POSIX benchmark rows run once in the consolidated pre-vendoring run after SOW-0063 (Test Cadence batching); the FreeBSD pipeline-batch rows run here as acceptance. + +Open decisions: + +- None (decisions 3a/5C and 6A recorded). + +## Implications And Decisions + +- Depth (2026-11-01, user question): the 16 is the benchmark's `PIPELINE_DEPTH` (`tests/run-posix-bench.sh:1107`), not a production limit; NetIPC has no in-flight cap on the baseline transport, and no production POSIX code pipelines. In-flight capacity is set by socket buffer bytes and the peer's reading, so the deadlock comes from send-everything-then-read, not from the number (with 8 KiB buffers and ~16 KB batches even 2 in flight can deadlock). +- Fix (2026-11-01, user: "3a"): option 1 — benchmark clients read responses while sending; also verify whether production sessions support concurrent send and receive, so a production pipelining caller can avoid the same deadlock. Option 2 (byte cap) dropped: it is another arbitrary limit. +- Fix revised (2026-11-02, user: "ok C"): the current session API cannot read while sending (see Analysis), so decision 3a is replaced by option C, non-blocking send with progress, in the library for C, Rust and Go: send each chunk with `MSG_DONTWAIT`; only on `EAGAIN`, poll for readable/writable, receive replies into a per-session queue bounded by the in-flight set, then resume; the next receive returns queued replies first. No extra syscall on the fast path. Rejected: full-duplex sessions (lock on every send/receive, threads required in callers) and a benchmark-only capacity window (production callers would carry a documented rule). Windows named pipes: checked in the gate; widening scope needs the user. +- Decision 1 (2026-11-03, user: "ok, A"): queued responses vs fd polling. Chosen: A, a public has-pending check in C (`nipc_uds_session_has_pending`), Rust (`UdsSession::has_pending`) and Go (`Session.HasPending`), and fd-accessor docs telling fd-polling callers to check it before waiting. Rejected: B, docs only (a caller cannot follow the rule without a check); C, an extra signalling fd per session (an fd and syscalls per session for a path no caller uses yet). No internal caller is affected: server sessions never queue (only a pipelining client fills the queue), and the service client paths call receive, which reads the queue first. +- Windows (2026-11-02, user: "A"): Windows gets the same guarantee with overlapped I/O in its own SOW (SOW-0064). This SOW stays POSIX-only, and adds the interim caller rule (keep bytes in flight under the pipe buffer) to `docs/level1-windows-np.md` and the integrator skill so the docs are accurate until SOW-0064 lands. + +## Plan + +The gate's implementation plan: C, then Rust, then Go, then docs, then validation. + +## Execution Log + +### 2026-11-01 + +Created from the SOW-0059 FreeBSD benchmark run. + +### 2026-11-02 + +- Reproduction test first (C `test_pipeline_exceeds_socket_buffers` in `tests/fixtures/c/test_uds.c`): the first version failed in about 40 ms for the wrong reason. Its 256-byte caller buffer was smaller than one 1024-byte packet, so the server's receive truncated packets (`NIPC_UDS_ERR_CHUNK`). With the buffer sized to the packet, the test fails on the unfixed code through the real deadlock: the client's send blocks until the test's 2 s `SO_SNDTIMEO` bound fires. +- C (`netipc_uds_send.c`, `netipc_uds_receive.c`, `netipc_uds.c`, `netipc_uds_internal.h`, `netipc_uds_lifecycle.c`, public session struct in `netipc_uds.h`): `session_send_packet` routes a client packet sent while another request is in flight to `send_with_progress` (`MSG_DONTWAIT`, `poll` for in/out, `nipc_uds_pending_drain` into the session queue, retry); everything else keeps `nipc_uds_raw_send_iov`. Receive reads the queue first (SEQPACKET `session_recv_packet`, stream `session_read_exact`/`session_recv_packet`), and the stream's pre-receive wait is skipped while the queue holds bytes. A bound overflow, or a failed queue allocation, shuts the socket down and fails all in-flight requests. `nipc_uds_close_session` frees the queue. No new source file, so Netdata's build lists are unchanged. +- Rust (`transport/posix.rs`, `posix_stream.rs`): `Pending` queue, `pending_bound`, `send_with_progress`; `UdsSession::send` computes the in-flight count before `framing::send_message` (the closure holds the in-flight set), and clears the set after a protocol failure. The SEQPACKET receive closure and `stream::recv_session_packet` take from the queue first. `framing.rs` unchanged. +- Go (`uds_send.go`, `uds_session.go`, `uds_receive.go`, `uds_stream.go`, `internal/syspoll` gains `Out`): `pendingQueue`, `pendingBound`, `sendWithProgress`; the send closures call `s.sendSessionPacket`, which uses `s.progressBound` set before `framing.SessionSend`, so no new closure per send. Shared `transport/internal/framing` unchanged. +- Tests in each language: pipelining 16 x 16000-byte requests with 1024-byte packets and 4096-byte socket buffers against a receive-then-send server (C `test_pipeline_exceeds_socket_buffers`, Rust `test_pipeline_exceeds_socket_buffers`, Go `TestPipelineExceedsSocketBuffers`); the server closing during a stalled send (`*_peer_close_during_send`, expects the send error and a cleared in-flight set); a server flooding responses beyond the bound (`*_pending_bound`, expects the protocol error and a cleared in-flight set). The 2 s `SO_SNDTIMEO` in the tests only bounds a failing run; it is not a synchronization sleep, and the passing runs never reach it. +- macOS: the first macOS run failed the progress tests in all three languages after the 2 s bound. A probe on the macOS host showed that macOS ignores `MSG_DONTWAIT` on a stream `send` that lacks buffer space (it blocked until `SO_SNDTIMEO`), while `O_NONBLOCK` returns `EAGAIN` at once and `recv(MSG_DONTWAIT)` works. The macOS progress send now sets `O_NONBLOCK` for the send and restores it (C `send_with_progress`, Rust `send_with_progress`, Go `sendSessionPacket`); Linux and FreeBSD keep `MSG_DONTWAIT`. The decided design (client-only, only while pipelining, no new timeout) is unchanged; only the macOS non-blocking mechanism differs. +- Acceptance criterion correction: the benchmark drivers build only with POSIX SHM (`CMakeLists.txt:1182`, `:1214`, `if(NETIPC_POSIX_SHM)`), so macOS runs no benchmark rows. macOS evidence for the stream path is the reproduction tests in C, Rust and Go. +- FreeBSD 14.3 VM: `uds-pipeline-batch` depth 16 completes for all 9 pairs (5 s each, 17.7M-31.2M items/s; before the fix all 9 deadlocked). +- Independent review (one reviewer, read-only) findings and handling: + 1. Queued responses are invisible to callers that wait on the session fd (`nipc_uds_session_fd`, Rust `UdsSession::fd`, Go `Session.Fd`): a design decision, open decision 1 below. + 2. macOS stream: a failed receive shut the read side but left peer bytes in the queue, and the next receive delivered them (a forged response for an in-flight id was accepted). Fixed: the queue is cleared at that shutdown in all three languages. Reproduction tests `test_stream_failed_receive_drops_pending` (C, Rust) and `TestStreamFailedReceiveDropsPending` (Go, darwin build tag) fail without the clear and pass with it on the macOS host. + 3. Go `pendingBound` could overflow `int` on 32-bit targets (a bound wrapping to 0 would bring back the blocking send). Fixed: saturates at `math.MaxInt`, as C and Rust saturate. + 4. A failed restore of the macOS descriptor flag failed the session in C only. Fixed: all three shut the socket down and report a send error, which fails every in-flight request. + 5. Tests: the 2 s `SO_SNDTIMEO` was also set on the server, so a stall over 2 s could fail a passing run. Fixed: the hang bound is client-only and 10 s. The reviewer's "C peer-close test lacks the in-flight check" was wrong (the check exists). The peer-close tests assert the contract (the send fails, no hang) whichever path reports the close (EPIPE directly, or EOF then EPIPE); which one runs depends on timing, and both satisfy the contract. +- Removing the server-side timeout exposed a Go test bug on macOS: `TestPipelinePendingBound` waited for the flooding server goroutine before closing the client, and on macOS a peer's `shutdown` does not wake a sender blocked on a full unix stream, only closing does. The Go tests now close the client before waiting, as the C and Rust tests already did. +- Process note: a `tar` re-sync to the macOS host kept old file mtimes, so cargo reused a binary built from a temporary revert and reported a false failure. Remote syncs now extract with `tar -m`. +- Docs: `docs/level1-transport.md` (pipelining guarantee), `docs/level1-posix-uds.md` ("Pipelined sends"; the macOS "Socket buffers" caller rule now points to it), `docs/level1-windows-np.md` ("Pipelining": interim caller rule until SOW-0064), `docs/netipc-integrator-skill.md` (L1 ownership list). + +### 2026-11-03 + +- Decision 1 (option A): `nipc_uds_session_has_pending` (public inline in `netipc_uds.h`; the internal `nipc_uds_pending_queued` now calls it), Rust `UdsSession::has_pending`, Go `Session.HasPending`. The fd accessors' docs point to it. `docs/level1-posix-uds.md` ("Pipelined sends", "Polling the fd") and `docs/netipc-integrator-skill.md` state the caller rule. The `*_exceeds_socket_buffers` tests assert it in all three languages: false before sending, true after the sends (the 4096-byte buffers hold a fraction of the 16 responses, so finishing the sends required queueing responses), false after every response is received. +- Follow-up mapping: SOW-0064 gains the matching Windows acceptance criterion; SOW-0050's performance assessment lists this SOW's per-request changes for the pre-vendoring benchmark run. + +## Validation + +Acceptance criteria evidence: + +- `uds-pipeline-batch` (depth 16, all 9 C/Rust/Go pairs): FreeBSD 14.3 VM, all 9 complete (5 s each, 17.7M-31.2M items/s; before the fix all 9 deadlocked). Linux workstation (final tree, 2026-11-03): all 9 complete (5 s each, 27.5M-47.7M items/s). macOS: the criterion was corrected (Execution Log 2026-11-02): the benchmark drivers build only with POSIX SHM (`CMakeLists.txt:1182`, `:1214`), so macOS runs no benchmark rows; its evidence is the reproduction tests on the stream path below. +- No deadlock on small buffers: `test_pipeline_exceeds_socket_buffers` (C, Rust) and `TestPipelineExceedsSocketBuffers` (Go) fail on the unfixed code after the client's 2 s hang bound (recorded 2026-11-02) and pass on Linux, FreeBSD and macOS. One-in-flight calls keep the blocking send: `session_send_packet` takes the progress path only for a client with more than one request in flight (`netipc_uds_send.c:236`; Rust `UdsSession::send`; Go `uds_send.go:34-36`, `:80`). +- Docs: `docs/level1-transport.md` ("Pipelining"), `docs/level1-posix-uds.md` ("Pipelined sends", including "Polling the fd"), `docs/level1-windows-np.md` ("Pipelining": interim caller rule), `docs/netipc-integrator-skill.md`. + +Tests: + +- Reproduction tests in all three languages: pipelining beyond the buffers, peer close during a stalled send (send error, in-flight set cleared), the queue bound (protocol error, in-flight set cleared), macOS stream failed receive drops the queue (`test_stream_failed_receive_drops_pending`, Go `TestStreamFailedReceiveDropsPending`), and the has-pending check. Each bug reproduction test failed without its fix. +- Linux workstation (final tree, 2026-11-03): CTest 32/32; `cargo test` stable and 1.91.0, 398 passed; clippy gate (`-D clippy::correctness -D clippy::suspicious`) on Linux, plus Windows-gnu and darwin on 2026-11-02 (no Windows- or darwin-specific change since); `cargo fmt --check` on both toolchains; `go test ./...`; `go vet` linux/darwin/freebsd; gofmt clean. The 8 interop suites, valgrind on `test_uds` and the 386 Go transport tests ran on 2026-11-02; the 2026-11-03 change adds a read-only accessor and test assertions, with no wire or interop effect. +- Netdata Linux targets (x86_64-musl, i386-gnu, i386-musl, armv7-gnu, armv7-gnu-time64, armv7-musl, armv6-musl, aarch64-gnu, aarch64-musl): all 9 rc=0 on the final tree (2026-11-03). +- FreeBSD 14.3 VM (final tree): CTest 31/31, `cargo test` 397 passed, `go test ./...`, `GOARCH=386 go test` of the POSIX transport, all 8 interop suites including SHM. +- macOS test host (final tree): CTest 24/24, `cargo test` 330 passed, `go test ./...`, C `test_uds` 271 passed, `test_uds_interop`, `test_service_interop`, `test_cache_interop`, `test_lookup_scale_interop`, `interop_codec`. `test_shm_interop` exits early by design (no SHM on macOS). +- Windows: no Windows-compiled file changed (`netipc_service.h` includes `netipc_uds.h` only in its non-Windows branch; `internal/syspoll` is unix-only; the Rust POSIX transport is `cfg(unix)`); Rust clippy for `x86_64-pc-windows-gnu` clean and `GOOS=windows go vet` shows only the existing `windows/shm.go` warnings. +- Timing: the tests synchronize on the server thread and socket state; the 10 s client `SO_SNDTIMEO` only bounds a failing run (Execution Log 2026-11-02). + +Real-use evidence: the original failure, the FreeBSD `uds-pipeline-batch` benchmark scenario, now completes for all 9 pairs with the production benchmark drivers. + +Reviewer findings: one independent read-only review (2026-11-02), five findings; handling recorded in the Execution Log (1 became decision 1, now implemented; 2-4 fixed with reproduction tests where testable; 5 fixed in the tests, with one reviewer claim rejected as wrong with evidence). + +Same-failure search: + +- Other pipelining paths: `uds-pipeline-d16` (single-item pipelining) passed before and after; POSIX SHM allows a single request in flight (`docs/level1-posix-shm.md`, "Single in-flight constraint"), so it cannot pipeline; Windows Named Pipes have the same class, tracked in SOW-0064 (their benchmark clients already keep a byte budget). +- Production callers: no Netdata caller pipelines (Analysis, "Netdata consumers"); all use typed one-in-flight calls. +- fd polling: every internal caller that waits on a session fd waits on a server session, which never queues (C `netipc_service_posix_server_session.c:143`, Rust `server_session_unix.rs:69`, `:86`, Go `server_unix.go:292`); the other fd uses check for peer disconnection after an error or pass the fd to the SHM wait, and the client service paths call receive, which reads the queue first. + +Performance assessment: per-request established-session changes are one branch per client packet send (`netipc_uds_send.c:236`), one queue-empty check per packet receive (`netipc_uds_internal.h:115-118`, `netipc_uds_receive.c:202`, `:477`), and for pipelining clients a `MSG_DONTWAIT` send (macOS: plus three `fcntl` calls per packet, `netipc_uds_send.c:213-220`). The has-pending check runs only when a caller asks. These are measured paths, so the POSIX benchmark rows run once in the consolidated pre-vendoring run (SOW-0050, after SOW-0063), as Test Cadence batching requires. macOS has no benchmark rows (no SHM build), so its `fcntl` cost stays unmeasured; no macOS caller pipelines. + +Sensitive data gate: this SOW, the docs, the integrator skill and the code comments hold source paths, line numbers, test names, counts and command names only; hosts are named by role; the Netdata evidence cites `netdata/netdata` at a commit with repo-relative paths. + +Artifact maintenance gate: + +- AGENTS.md: no change; no command, workflow or guardrail changed. +- Runtime project skills: `project-netdata-vendoring` unchanged; its preflight already covers performance assessment, and the SOW-0050 assessment now lists this SOW's changes. +- Specs: the public specs under `docs/` were updated (`level1-transport.md`, `level1-posix-uds.md`, `level1-windows-np.md`); `.agents/sow/specs/` holds no transport rules. +- End-user/operator docs: README lists the pipeline benchmark rows only and makes no buffer or deadlock claim; no change. +- End-user/operator skills: `docs/netipc-integrator-skill.md` updated (pipelining limits, has-pending rule, interim Windows rule). +- SOW lifecycle: completed and moved to `done/`; SOW-0064 (Windows) created from decision 6A and given the has-pending parity criterion; SOW-0050 records the benchmark coverage. + +Spec update: see the artifact gate. + +Project skill update: none needed (see above). + +Lessons: see Lessons Extracted. + +Follow-up mapping (search for defer/later/follow-up/future/TODO/pending): + +- Windows pipelining progress: SOW-0064. +- Benchmark rows for the changed paths: the consolidated run in SOW-0050. +- macOS `fcntl` cost per pipelined packet: rejected for now with evidence: no macOS caller pipelines and macOS has no benchmark rows; a whole-session non-blocking mode would be a design change brought to the user if a pipelining macOS caller appears. +- "Pending" sections of this file: filled. + +## Outcome + +A POSIX client may pipeline any number of requests, whatever the socket buffer sizes, in C, Rust and Go: a send that waits for buffer space reads the arriving responses into a bounded per-session queue, and receive returns them first. The FreeBSD `uds-pipeline-batch` benchmark row, which deadlocked for all 9 pairs, completes. Callers that poll the session fd check `has_pending` first. One-in-flight calls, which is every typed call and every Netdata caller, are unchanged. + +## Lessons Extracted + +- macOS ignores `MSG_DONTWAIT` on a unix stream send that lacks buffer space and blocks until `SO_SNDTIMEO`; only `O_NONBLOCK` returns `EAGAIN`. A platform's honoring of per-call flags needs a probe on that platform. +- On macOS a peer's `shutdown` does not wake a sender blocked on a full unix stream; closing does. Tests that stop a blocked peer must close, not only shut down. +- A session-local queue in front of a socket hides data from fd readiness; any such queue needs a public "has pending" check next to the fd accessor. +- Remote tree syncs with `tar` must reset mtimes (`-m`), or incremental builds reuse stale binaries and report false results. + +## Followup + +- SOW-0064: Windows Named Pipe pipelining progress (overlapped I/O), including has-pending parity. +- SOW-0050: the consolidated pre-vendoring benchmark run covers this SOW's rows. + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0063-20261101-go-bench-clock-and-snapshot-server.md b/.agents/sow/done/SOW-0063-20261101-go-bench-clock-and-snapshot-server.md new file mode 100644 index 00000000..45ed0df4 --- /dev/null +++ b/.agents/sow/done/SOW-0063-20261101-go-bench-clock-and-snapshot-server.md @@ -0,0 +1,283 @@ +# SOW-0063 - Go Benchmark Clock Overhead, SHM Clock And Snapshot Server + +## Status + +Status: completed + +Sub-state: delivered 2026-11-03. + +## Requirements + +### Purpose + +Language implementations should not show dramatic performance differences. Benchmark rows must measure NetIPC, not the harness, and every remaining gap must be explained or fixed. + +### User Request + +2026-11-01: "this is a strong indication that one of the implementation is slow. Check the pairs that produced the low value. What is common to them? ... generally, the languages should not have so dramatic differences." Decisions: "1a 2a 3a 4a"; "ok, proceed" (test lock-file cleanup in this SOW). + +### Assistant Understanding + +Facts (SOW-0059 FreeBSD benchmark run, FreeBSD 14.3 VM, `kern.timecounter.hardware: kvmclock`): + +- SHM ping-pong, max rate: Go clients 526k-643k req/s against every server; C and Rust clients 1.45M-1.81M. Go servers are fast (C client -> Go server 1.65M). On Linux (`benchmarks-posix.csv`) Go clients are about 0.7x of C (2.15M-2.27M vs 2.43M-3.33M). +- Go's FreeBSD runtime reads the clock in user space only for the TSC and HPET timecounters (`runtime/vdso_freebsd_x86.go:81-88`); with `kvmclock` every `nanotime`/`walltime` falls back to a syscall. Measured on the VM: Go `time.Now()` 310 ns, C `clock_gettime(CLOCK_MONOTONIC)` 41 ns. +- The Go benchmark client reads the clock 5 times per request (`bench/drivers/go/main.go`: loop `time.Since(wallStart)`, `t0 := time.Now()`, `t1 := time.Now()`; each `time.Now` reads wall and monotonic). The C client reads 3 times (`now_ns`). +- CPU profile of the Go SHM ping-pong client against a C server (pprof, 6 s): `time.Now` 36.6% + `time.Since` 10.3% cumulative (all in `fallback_nanotime`/`fallback_walltime`); wake syscall 14.8%; wait 11.2%; spin 12.8%; library `monotonicNow` 0.9%. +- The library's Go SHM `monotonicNow` (`src/go/pkg/netipc/transport/posix/shm_posix.go:670-672`) uses a raw `SYS_CLOCK_GETTIME` syscall, which bypasses the Linux vDSO; it runs in the wait phase when a timeout is set. +- Snapshot SHM, max rate: Go servers are slower than C and Rust servers on both FreeBSD (C client -> Go server 412k vs C -> C 727k) and Linux (936k vs 1.26M). Not profiled yet. +- Test run directories accumulate endpoint `.lock` files: the library never deletes them (by design, `docs/level1-posix-uds.md`, "Endpoint lock"), and tests that use per-PID unique service names leave one per run (about 25k leftover files found under `/tmp/nipc_*` on the workstation, 2026-11-01). + +Inferences: + +- Without the harness clock cost the Go SHM client would land near C and Rust on the FreeBSD VM (speculation until re-measured). + +Unknowns: + +- The cause of the Go snapshot-server gap (to be profiled). + +### Acceptance Criteria + +- The Go benchmark harness reads the clock no more often than the C harness, monotonic-only, per request. +- The library's Go SHM wait uses Go's monotonic clock (vDSO on Linux) instead of the raw syscall, with the same timeout semantics. +- The Go snapshot-server gap is profiled and its cause fixed or explained with evidence. +- Test fixtures stop accumulating endpoint lock files. +- Re-measured rows (FreeBSD VM and Linux) show the effect; remaining language gaps are explained. + +## Analysis + +Harness clock (decision 1a): + +- The C client reads `CLOCK_MONOTONIC` 3 times per request (`bench/drivers/c/bench_posix.c:63-68` `now_ns`; loop check, `t0`, `t1`, e.g. `:577`, `:613`, `:709`). The Go client calls `time.Since(wallStart)` for the loop check plus `time.Now()` twice (`bench/drivers/go/main.go:1000`, `:1015`, `:1065`, and the same pattern in every client loop); each `time.Now` reads the wall and the monotonic clock. +- `time.Since(t)` for a `t` carrying a monotonic reading reads only the monotonic clock (`runtimeNano`, Go 1.27 `src/time/time.go:1227-1233`). A process-start base plus `time.Since(base)` gives a monotonic-only nanosecond clock without `linkname`. +- Plan: one `nowNs()` helper (monotonic-only) for every Go client loop, the latency recorder and the rate limiter; the loop deadline check reuses the request's end timestamp, so a request costs 2 clock reads (C: 3). + +Library clock (decision 2a): + +- `monotonicNow` (`src/go/pkg/netipc/transport/posix/shm_posix.go:668-672`) issues a raw `SYS_CLOCK_GETTIME`, used at `:504` and `:527` (futex-wait phase with a timeout). Both uses only need elapsed nanoseconds to compute a relative futex/umtx timeout (Linux `FUTEX_WAIT` and FreeBSD `_umtx_op` with a relative `_umtx_time`, `shm_umtx_freebsd.go:64`). Plan: `time.Since(base)` (vDSO on Linux), same deadline arithmetic. + +Go snapshot server gap (decision 4a), Linux workstation, C `snapshot-shm-client` against a Go or C `snapshot-shm-server`, low priority: + +- Throughput with a Go server varied 190k-605k req/s between runs; with a C server 880k-1.0M. +- CPU profile of the Go server (pprof, 7 s): futex wake syscall 34%, `CgroupsBuilder.Add` 19% (16 items per response), `ShmReceive` 14%, `memmove` 10%, GC 7%. +- Allocation profile: about 2.25 heap allocations per request. `protocol.NewCgroupsBuilder` returns a heap builder (`protocol/cgroups_snapshot.go:364`); the session loop's `respHdr` escapes because its address goes to the send function (`service/raw/server.go:245`); `DecodeCgroupsRequest`'s result escapes into the handler (`service/raw/cgroups_snapshot.go:60`). That is 41 GC cycles in 5 s, each costing about 25 ms of CPU across the 112 Ps (gctrace). +- Context switches over 5 s: with the Go server the C client sleeps 91k-135k times (it waits in futex when the server stalls, which makes the server's next wake a real one); with a C server 52k. +- Experiment (scratch copy, not committed): `respHdr` hoisted out of the loop and the snapshot request plus builder reused from a pool. Go server 810k req/s in both runs, client sleeps 2.8k-4.2k. The remaining gap to C (about 0.85x) matches the Go SHM ping-pong server gap on Linux (`benchmarks-posix.csv`: C client -> Go server 2.63M vs C 3.14M), so it is the shared transport path (Go syscall entry/exit, bounds-checked atomics), not snapshot-specific. +- Same class elsewhere (escape analysis, `go build -gcflags=-m`): Go lookup servers allocate their request view and builder per request (`protocol/cgroups_lookup.go:188`, `:517`, `protocol/apps_lookup.go:206`, `:581`) and, for cgroups lookups, a suffix-size slice of item count + 1 entries (`protocol/cgroups_lookup.go:790`); Go typed clients allocate the request header per call (`service/raw/client.go:312`), and snapshot/string-reverse/lookup clients return a heap view by API design (`service/raw/cgroups_snapshot.go:41`, `string_reverse.go:32`). C and Rust build on the stack. + +Lock files (user approved fixing here, 2026-11-01): + +- About 3,000 `.lock` files built up under `/tmp/nipc_*` within a day; names carry a PID and a counter (Go `gotest__`, `go_rob_*`; Rust `rs_*__`, `cache__*`; C `svc_*__` in `nipc_service_extra`, `nipc_service_limits`). Tests remove the socket but never the lock, which the library keeps by design (`docs/level1-posix-uds.md`, "Endpoint lock"). + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- Go benchmark clients spend a large share of each request reading the wall and monotonic clocks (5 reads per request; syscalls on the FreeBSD VM's `kvmclock`), so their rows measure the harness. +- The Go SHM wait reads the clock through a raw syscall instead of the vDSO. +- Go typed servers allocate per request; the resulting GC cycles stall the server, the client falls asleep in futex, and every response then pays a real wake (Analysis). +- Tests leave one endpoint lock file per run under `/tmp/nipc_*`. + +Evidence reviewed: Analysis (profiles, escape analysis, context-switch counts, the scratch experiment), `bench/drivers/go/main.go`, `bench/drivers/c/bench_posix.c`, `src/go/pkg/netipc/transport/posix/shm_posix.go`, `src/go/pkg/netipc/service/raw/*.go`, `src/go/pkg/netipc/protocol/{cgroups_snapshot,cgroups_lookup,apps_lookup,lookup_common}.go`, the test helpers that create services in C (`tests/fixtures/c/`), Rust (`src/crates/netipc/src/**/tests*.rs`) and Go (`*_test.go`). + +Affected contracts and surfaces: + +- Go benchmark driver (POSIX) timing: rows stay comparable; the CSV format is unchanged. +- Go library internals: SHM wait clock, typed server dispatch and session loop, typed client request header, protocol builders/decoders gain internal reset/in-place forms. No exported API is removed or changed; any new exported helper is additive and documented. Correction (2026-11-07, SOW-0070): decision 6C later changed exported Go types on purpose (`protocol.StringReverseView.Str`, `protocol.DispatchStringReverse`, `raw.StringReverseHandler` take borrowed `[]byte`); see Implications And Decisions. +- Wire format, C and Rust code, Windows Go driver (already uses a QPC `nowNS`): unchanged, except test helpers in C and Rust that create endpoints (lock cleanup). + +Existing patterns to reuse: client scratch buffers (`ensureClientScratch`), the session loop's per-session buffers, `testing.AllocsPerRun`, existing socket cleanup helpers in each language's tests. + +Risk and blast radius: + +- Pooled scratch must never be shared by two in-flight requests: each dispatch takes its own entry and returns it after the response is copied out; handlers must not keep the request or builder after returning (already the documented contract: the builder writes into the caller's response buffer). +- Builder reset must clear every field the old allocation zeroed (error state, counts, payload-exceeded settings). +- Removing a lock file in test cleanup only after the listener closes; test names are unique per test, so no other process holds them. + +Sensitive data handling plan: + +- Paths, line numbers, function names, counts and benchmark numbers only; hosts by role; no workstation paths in durable artifacts. + +Implementation plan: + +1. Go benchmark driver: monotonic-only `nowNs()`; every POSIX client loop, the rate limiter and the latency recorder use it; the deadline check reuses the request end time. +2. `monotonicNow` replaced by a monotonic elapsed-nanoseconds helper based on `time.Since`. +3. Go server: session loop response header per session; typed dispatchers take request and builder scratch from a `sync.Pool`; protocol gains internal-use reset/decode-into forms so the pooled values are reused; cgroups lookup's suffix slice comes from the scratch. Typed clients keep the request header in the client. +4. Allocation tests: one `testing.AllocsPerRun` test per typed server method (dispatch level) and per typed client call path that can be zero-alloc, asserting 0. +5. Lock files: each language's test endpoint cleanup removes `{service}.lock` after the listener is closed. +6. Re-measure (purpose of this SOW): the affected rows on Linux and the FreeBSD VM: Go client and server SHM/UDS ping-pong and snapshot rows, compared with SOW-0059's FreeBSD run and `benchmarks-posix.csv`. + +Validation plan: + +- Allocation tests fail before the change (recorded) and pass after. +- Linux: `go test ./...`, `go vet` (linux, freebsd, darwin, windows), gofmt; CTest and `cargo test` for the lock cleanup changes; interop suites (service, cache, SHM); 9 Netdata Linux targets (Go 386/arm included). +- FreeBSD VM: full suite (C, Rust, Go including `GOARCH=386`, interop). macOS host: full suite (the Go library changes touch shared POSIX code). +- Windows: `GOOS=windows go vet`, and the Windows Go suite on the Windows test host, since the typed service layer is shared. +- Benchmarks: the rows in plan step 6. + +Artifact impact plan: `docs/` only if a documented behavior changes (the handler contract on retaining the request/builder is checked and stated if missing); benchmark report regenerated if rows are re-measured into `benchmarks-posix.csv`; integrator skill if the handler contract text changes. + +Performance assessment: this SOW changes established-session per-request code (Go server dispatch and session loop, Go client call, Go SHM wait clock); its purpose is performance, so it runs the benchmark rows it needs (plan step 6). + +Open decisions: none (1a, 2a, 3a, 4a, 5A, 6C, 7A, 8A recorded). + +## Implications And Decisions + +- Harness clock (2026-11-01, user: "1a"): fix the Go benchmark harness to read the clock like C (monotonic-only, deadline checked less often), then re-measure FreeBSD and Linux. +- Library clock (2026-11-01, user: "2a"): switch `monotonicNow` to Go's monotonic clock in this SOW. +- Snapshot server (2026-11-01, user: "4a"): profile the Go snapshot-server gap inside this SOW. +- Lock-file accumulation in tests (2026-11-01, user: "ok, proceed" after the proposal to fix it here): fixed in this SOW. +- Ordering: after SOW-0062. +- Go per-request allocations (2026-11-03, user: "a"): option A. Remove steady-state per-request heap allocations from every Go typed server (snapshot, cgroups lookup, apps lookup, increment, string-reverse) and the server session loop, and the request-header allocation from Go typed clients, with no public API change; add Go tests that fail when a request allocates. Rejected: B, snapshot server and session loop only (leaves the same defect in the lookup servers); C, explain only. Client calls that return a decoded view by pointer keep that one allocation (an API change, to be raised separately if wanted). +- String-reverse server copy (2026-11-03, user: "6a", revised the same day to "6C" after the assistant found the spec conflict): option C. `docs/codec.md:50-55` requires that the default decode path does not allocate and that string fields are exposed as pointer + length into the payload; Go's `StringReverseDecode` copied into a `string` (`protocol/string_reverse.go:80`), while C (`const char *` + length) and Rust (`str_data: &[u8]`) borrow. Go `StringReverseView.Str` becomes a borrowed `[]byte`, and the Go STRING_REVERSE handlers (`raw.StringReverseHandler`, `protocol.DispatchStringReverse`) take the request as a borrowed `[]byte`; the response stays a `string`. A public Go API break limited to the test method (`docs/level1-wire-envelope.md:77`). Rejected: A, keep the copy and add a spec exception (Go out of parity with C and Rust); B, an `unsafe.String` alias of the receive buffer (a retained string would change under the handler, breaking Go's string immutability). +- Go client clock reads (2026-11-03, user: "7a"): option A. Go typed clients read the clock about 8 times per call, half of them wall-clock reads (`service/raw/client.go:48`, `:62`, `client_unix.go:239`, `:247`, `:253`), against 4 monotonic reads in C (`netipc_service_common.c:202`, `:209`, `netipc_service_posix_client_call.c:96`, `:102`); on the FreeBSD VM's `kvmclock` each is a syscall and they take about 40% of the Go snapshot client's CPU. Fix in this SOW: per-call deadlines in monotonic-only nanoseconds and one clock read per receive-loop iteration, in the POSIX and Windows Go clients and transports, with no API change; re-measure the snapshot rows. Rejected: B, explain and track separately. +- Go client returned views (2026-11-03, user: "8A"): option A. `CallSnapshot` and `CallStringReverse` moved the decoded view to the heap on every call (`result = &view`, `service/raw/cgroups_snapshot.go:47`, `string_reverse.go:35`). The documented lifetime is already "valid until the next typed call on this client" (`cgroups_snapshot.go:21`, `string_reverse.go:12`), so the view is kept in the client and its address returned: no API change. This corrects the note under the allocation decision above, which called it an API change. Rejected: B, leave the allocation. The Go lookup clients' per-call buffers for multi-part responses are a separate redesign, tracked in a pending SOW. + +## Plan + +The gate's implementation plan, in order. + +## Execution Log + +### 2026-11-01 + +Created from the SOW-0059 FreeBSD benchmark analysis. + +### 2026-11-03 + +- Harness clock: `bench/drivers/go/main.go` reads a monotonic-only `nowNs()` (`time.Since` of a process-start base); every client loop reuses the request's end timestamp for the deadline check (`loopNow`), so a request costs 2 clock reads; the rate limiter works in the same nanoseconds. `main_windows.go` already used a QPC clock. +- Library clock: `monotonicNow` (raw `SYS_CLOCK_GETTIME`) replaced by `monotonicNs()` over `time.Since` (`transport/posix/shm_posix.go`); same deadline arithmetic. +- Allocation tests written first (`service/raw/alloc_test.go`, dispatch level, 1 request vs 2000 requests on one session): before the fix the Go server allocated 1 (increment), 2 (snapshot), 4 (cgroups lookup) and 3 (apps lookup) times per request. `alloc_unix_test.go` (`TestCallNoAllocsBaseline`, a real Go server and client over UDS, `testing.AllocsPerRun`): 9 allocations per increment call. +- Server fixes: response header per session (`service/raw/server.go`); snapshot request and builder from a `sync.Pool` (`service/raw/cgroups_snapshot.go`, `protocol.CgroupsBuilder.Reset`); lookup request views, builders and the cgroups payload-exceeded suffix slice from pools (`protocol/dispatch_scratch.go`, decode-into and `reset` forms in `protocol/cgroups_lookup.go`, `protocol/apps_lookup.go`, `reusePayloadExceededSuffixBytes` in `protocol/lookup_common.go`). +- Client and transport fixes: the request header lives in the client (`client_unix.go`, `client_windows.go`, `client.go`, `increment.go`); the snapshot request buffer uses the client scratch. The shared framing receiver took a method value per receive; it now takes a disconnect predicate and an in-flight reset built once per session (`transport/internal/framing/receive.go`, POSIX `uds_receive.go`/`uds_session.go`, Windows `pipe_receive.go`/`pipe_session.go`). `rawRecv` uses `read(2)` instead of `syscall.Recvmsg`, which allocated the source address (`transport/posix/uds.go`). +- All allocation tests pass after the fixes (UDS and SHM; Windows variants in `alloc_windows_test.go`). A constant +1 allocation seen in the session test is the runtime starting an OS thread (not per request); the test tolerates fewer than 1 per 100 requests. +- Harness allocations: escape analysis of the Go driver showed the request header escaping inside every measured client loop (`main.go`, 4 loops; `main_windows.go`, 4 loops); it is now declared once per run. The remaining escapes are once per run or in warmup. +- Lock files: each language's test helpers record the endpoints they create and remove `{dir}/{service}.lock` at process exit (Go `TestMain` in `transport/posix`, `service/raw`, `service/cgroups_snapshot`; Rust `test_locks.rs` via `atexit` with a PID guard; C `tests/fixtures/c/test_lock_cleanup.h` via `atexit` with a PID guard). After full Go, Rust and C runs no new lock files remained. +- String-reverse server: `StringReverseDecode` copies the request into a Go `string` (`protocol/string_reverse.go:80`) because the handler type takes a `string`; removing it changes the handler contract (decision 6). +- Re-measured after the harness and server/client fixes, then profiled the Go snapshot client on the FreeBSD VM: about 8 clock reads per call, half of them wall-clock reads, took about 40% of its CPU (decision 7). +- Client clock (decision 7A): new package `internal/monotime` (`Now`/`After`, int64 nanoseconds of `time.Since` a process-start base; zero means "no deadline"). Per-call deadlines in `service/raw/client.go` (`beginOp`, `endOp`, `opRemainingMs`, `doRawCallWithTimeout`), the SHM receive loops in `client_unix.go`/`client_windows.go` (one read per wait slice, as in C), lookup deadlines (`service/raw/lookup_common.go`), the POSIX and Windows transport receive waits (`transport/posix/uds_receive.go`, `transport/windows/pipe_receive.go`, `pipe_session.go`) and the SHM wait clock (`transport/posix/shm_posix.go`, `monotonicNs`) use it. Go now reads the clock 4 times per call, like C. The connect-time SHM attach loop and the SHM generation stamp (`shm_posix.go`, wall clock by design) are unchanged. +- Windows allocation tests failed on the Windows host: `TestWinCallNoAllocsBaseline` 1035 allocations per call and `TestWinCallNoAllocsShm` 3, from `LazyProc.Call` (variadic, `//go:uintptrescapes`) in the Named Pipe poll loop (`PeekNamedPipe`) and the SHM signal/wait (`SetEvent`, `WaitForSingleObject`). Those three now call `syscall.SyscallN(proc.Addr(), ...)`, which keeps arguments on the stack (`transport/windows/pipe.go`, `shm.go`); the tests pass. +- The allocation tests left `go_alloc_*.lock` files on the FreeBSD VM; `assertCallsDoNotAllocate` now tracks its endpoints for the exit-time cleanup. +- Decision 6 revisited (6C): `protocol.StringReverseView.Str` is a borrowed `[]byte` (`protocol/string_reverse.go`), `protocol.DispatchStringReverse` and `raw.StringReverseHandler` take the request as a borrowed `[]byte`; tests updated (`protocol/codec_edge_test.go`, `service/raw/ping_pong_test.go`, `ping_pong_windows_test.go`, `helpers_windows_test.go`, `shm_windows_test.go`, `lookup_common_test.go`). No Go fixture, benchmark driver or doc used the old shape. +- Decision 8A: `CallSnapshot` and `CallStringReverse` store the decoded view in the client (`snapshotView`, `stringReverseView` in `client_unix.go`/`client_windows.go`) and return its address; escape analysis no longer reports `moved to heap: view`. The client allocation tests now cover increment, snapshot and string-reverse calls (`service/raw/alloc_calls_test.go`, shared cases with non-allocating handlers; UDS, POSIX SHM, Named Pipe, Windows SHM). Red check: with the old copy-to-heap return restored in `CallSnapshot`, `TestCallNoAllocsBaseline/snapshot` failed with 1 allocation per call; restored, it passes. +- Lookup clients in all three languages copy every response item and stitch into a new buffer per call; tracked in `SOW-0065-20261103-lookup-client-reusable-stitch-storage.md` (pending). +- Independent review (a separate assistant reviewer over the Go diff, read-only, with build, vet and race runs) found no correctness bug in the library change. Findings and handling: + 1. The server allocation tests failed under `-race` (`sync.Pool` drops items on purpose there): fixed, the allocation tests skip under the race detector (`service/raw/race_enabled_test.go`, `race_disabled_test.go`). + 2. `testing.AllocsPerRun` floors the per-call average, so an allocation on only some calls passed: fixed, the client tests count total allocations over 2000 calls (`callAllocs`, `alloc_calls_test.go`) and fail at 20 or more. Red check: an allocation on 1 call in 4 in `CallSnapshot` now fails with "500 allocations in 2000 calls" (it reported 0 before); restored. + 3. The `service/cgroups_snapshot` wrapper did not repeat the view lifetime: added (`client_common.go`). + 4. The benchmark loop's deadline check reuses the previous request's end time and can overshoot by one rate-limiter interval: rejected, the reported rate divides by a freshly read elapsed time, so an overshoot of at most one interval does not bias it. + 5. The pooled cgroups-lookup scratch keeps its largest suffix table until GC clears the pool: rejected, bounded by the configured maximum item count. + 6. A stale `CLOCK_MONOTONIC` mention and the now-unused `shmClockMonotonic` constant in `transport/posix/shm_futex_linux.go`: fixed (the FreeBSD constant stays, it selects the `_umtx_op` timeout clock). + +## Validation + +Acceptance criteria evidence: + +- Harness clock: the Go POSIX client loops read a monotonic-only `nowNs()` twice per request (C: 3), `bench/drivers/go/main.go`; the request header no longer escapes in the measured loops (escape analysis). +- Library clock: the SHM wait and every per-call deadline use `internal/monotime` (`time.Since` of a process-start base; vDSO on Linux); Go typed clients read the clock 4 times per call, like C. +- Snapshot server gap: profiled (Analysis); caused by per-request allocations and the resulting GC stalls; fixed (server allocation tests pass at 0 per request). The remaining Linux gap, C client to Go server about 0.85x of C, matches the SHM ping-pong server gap and is the shared transport path. +- Lock files: each language's test helpers remove the endpoint lock files they create; after full Go, Rust and C runs on Linux, FreeBSD and macOS no new lock files remained. +- Re-measured rows (5 s per row, max rate, all 9 pairs; client to server; noise about 20% on these hosts): + +| Row | Linux before | Linux after | FreeBSD VM before | FreeBSD VM after | +|---|---|---|---|---| +| shm-ping-pong go to c | 904k | 1.50M | 562k | 1.06M | +| shm-ping-pong go to go | 636k | 1.10M-1.54M | 573k | 944k | +| shm-ping-pong c to go | 852k | 1.41M | 1.55M | 1.49M | +| shm-ping-pong c to c | 1.48M | 1.71M | 1.81M | 1.66M | +| snapshot-shm go to c | 715k | 898k | 232k | 418k | +| snapshot-shm go to go | 416k | 533k-639k | 235k | 361k | +| snapshot-shm c to go | 213k | 827k | 485k | 524k | +| snapshot-shm c to c | 844k | 977k | 743k | 699k | +| snapshot-baseline go to go | 30k | 52k | 69k | 73k | + + (Ranges: three extra repeats of the Go-to-Go rows, unchanged code.) + +- Remaining gap, explained: on the FreeBSD VM Go clients reach about 0.55-0.65x of C. The Go client now reads the clock as often as C, but each read is a syscall: Go's FreeBSD runtime has a user-space clock only for the TSC and HPET timecounters (`runtime/vdso_freebsd_x86.go:81-88`) and the VM uses `kvmclock`; profile of the Go snapshot client against a C server after decision 7A: `runtime.fallback_nanotime` 32.6% of CPU. This is a Go runtime limitation on that timecounter; the library cannot remove it without reading the clock less often than C. On Linux Go clients are 0.8-0.9x of C. + +Tests or equivalent validation (final tree): + +- Linux: build, CTest 32/32, `interop_codec`, UDS, SHM, service, service SHM, cache, cache SHM and lookup-scale interop; `cargo test` 399 on stable and 1.91.0; clippy (Linux and Windows target); `cargo fmt --check` on both toolchains; `go test ./...` amd64 and `GOARCH=386`; `go vet` linux, linux/386, freebsd, darwin, windows; gofmt clean. `GOOS=windows go vet` reports 9 `possible misuse of unsafe.Pointer` warnings in `transport/windows/shm.go` and `shm_test.go`; the same 9 are reported at HEAD (`48cc98b`), not introduced here. +- Netdata Linux targets (Go and interop parts; C and Rust unchanged since the full run earlier in this SOW): x86_64-musl, i386-gnu, i386-musl, armv7-gnu, armv7-gnu-time64, armv7-musl, armv6-musl, aarch64-gnu, aarch64-musl, all rc=0. +- FreeBSD VM: CTest 31/31, all 8 interop suites, `cargo test` 397, `go test ./...` and `GOARCH=386 go test ./...`. +- macOS host: CTest 24/24, `cargo test`, `go test ./...`, UDS, service, cache, lookup-scale and codec interop. +- Windows host: Rust 273, Go suite (including the allocation tests on Named Pipe and Windows SHM), CTest 20/20. +- After the review fixes (test files, one comment and one constant): `go test ./...` on Linux, FreeBSD (amd64 and 386), macOS and Windows, `go test -race` of the allocation tests on Linux, vet on all targets. + +Real-use evidence: + +- The benchmark rows above run the real C, Rust and Go benchmark binaries across all 9 language pairs over UDS and SHM on Linux and the FreeBSD VM. + +Reviewer findings: + +- Independent assistant review of the Go diff: six findings, three fixed, one fixed as a doc gap, two rejected with reasons (Execution Log, 2026-11-03). + +Same-failure scan: + +- Escape analysis (`go build -gcflags=-m`) of `service/raw`: no remaining per-call `moved to heap`; the remaining escapes are per session (`server.go` `msgBuf`, `respHdr`) or in the lookup clients (SOW-0065). +- `LazyProc.Call` on Windows per-request paths: none left in `transport/windows` receive, send or SHM signalling (connect-time calls keep it). +- Wall-clock deadline reads (`time.Now`) in per-call Go paths: none left; the connect-time SHM attach loop and the SHM generation stamp keep `time.Now` by design. +- Client calls returning a view by pointer: snapshot and string-reverse fixed; lookups tracked in SOW-0065. The same per-item lookup copies exist in Rust and C and are covered by SOW-0065. +- The view-lifetime doc in Go wrappers: `service/cgroups_snapshot` fixed; the lookup wrappers carry no doc comments and their behavior did not change here. + +Sensitive data gate: + +- Durable artifacts carry file paths relative to the repository, line numbers, function names, counts and benchmark numbers only; hosts are named by role (Linux workstation, FreeBSD VM, macOS host, Windows host); no credentials, personal data or workstation paths. + +Artifact maintenance gate: + +- AGENTS.md: no update; workflow, commands and guardrails did not change. +- Runtime project skills: no update; `project-netdata-vendoring` workflow is unaffected (vendoring of these changes goes through SOW-0050). +- Specs: no update needed. `docs/codec.md:50-58` (non-allocating decode, strings as pointer + length) and `docs/level2-typed-api.md:440-462` (borrowed views valid until the next typed call) already describe the new Go behavior; the change brings Go into line with them. +- End-user/operator docs: no update; the only public API change is the STRING_REVERSE test method's Go types, which no doc, example or fixture shows (`grep` of `docs/`, `tests/`, `bench/`). +- End-user/operator skills: `docs/netipc-integrator-skill.md` unaffected; it names string-reverse as a non-production method (`:112`) and documents no Go signature for it. +- SOW lifecycle: `Status: completed`, moved to `.agents/sow/done/` in the same commit as the work; follow-up SOW-0065 created in `pending/`. + +Specs update: + +- None; see the artifact gate (docs already match). + +Project skills update: + +- None; see the artifact gate. + +End-user/operator docs update: + +- None; see the artifact gate. + +End-user/operator skills update: + +- None; see the artifact gate. + +Lessons: + +- See Lessons Extracted. + +Follow-up mapping: + +- Lookup client per-item allocations (C, Rust, Go): tracked, SOW-0065. +- FreeBSD Go client clock cost on `kvmclock`: rejected as a library change (Go runtime limitation, evidence above). +- Benchmark loop overshoot and pooled suffix table size (review findings 4 and 5): rejected with reasons in the Execution Log. +- Consolidated benchmark run and report regeneration: part of the agreed consolidated benchmark step before SOW-0050 vendoring; these rows are SOW evidence, not a `benchmarks-posix.csv` update. + +## Outcome + +Go benchmark rows now measure NetIPC instead of the harness clock. Go typed servers and the increment, snapshot and string-reverse clients do not allocate per request on an established session (UDS, POSIX SHM, Named Pipe, Windows SHM), Go per-call deadlines read only the monotonic clock as often as C, Go STRING_REVERSE decoding borrows the payload as the codec spec requires, and test runs no longer leave endpoint lock files behind. Go clients rose from about 0.4-0.6x to 0.8-0.9x of C on Linux and from about 0.3x to 0.55-0.65x on the FreeBSD VM, where the rest is the Go runtime's clock syscall on `kvmclock`. + +## Lessons Extracted + +- Check the authoritative spec before presenting a design option: decision 6 was first offered without `docs/codec.md:50-55`, and option A would have kept a spec violation. +- `testing.AllocsPerRun` floors the per-call average; allocation tests that must catch occasional allocations need total counts. +- `sync.Pool` drops items under the race detector; pool-based allocation tests must skip there. +- On Windows, `LazyProc.Call` allocates per call (variadic, `//go:uintptrescapes`); per-request syscalls use `syscall.SyscallN(proc.Addr(), ...)`. +- On FreeBSD guests the Go runtime clock is a syscall unless the timecounter is TSC or HPET; benchmark Go numbers there include it. + +## Followup + +- SOW-0065 (pending): lookup clients stitch into reusable client storage (C, Rust, Go). + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0064-20261102-windows-pipe-pipelining-progress.md b/.agents/sow/done/SOW-0064-20261102-windows-pipe-pipelining-progress.md new file mode 100644 index 00000000..ecebec05 --- /dev/null +++ b/.agents/sow/done/SOW-0064-20261102-windows-pipe-pipelining-progress.md @@ -0,0 +1,221 @@ +# SOW-0064 - Windows Named Pipe Pipelining Without Deadlock + +## Status + +Status: completed + +Sub-state: delivered 2026-11-04; pipeline-batch benchmark cost accepted by the user (decision 8A). + +## Requirements + +### Purpose + +A client that pipelines over the Windows named-pipe baseline must not deadlock when the bytes in flight exceed the pipe buffers, matching the POSIX guarantee from SOW-0062. + +### User Request + +2026-11-02, decision 6A: give Windows the same guarantee with overlapped I/O, in its own SOW. + +### Assistant Understanding + +Facts (SOW-0062 Analysis, "Windows named pipes"): + +- C, Rust and Go create synchronous, message-mode pipes with 64 KiB buffers per direction (`netipc_named_pipe.c:58-63`, `:218-222`, `:986-990`; Rust `windows.rs:1164-1176`; Go `pipe_listener.go:268-276`); each packet is a blocking `WriteFile` with no timeout. +- With more than about 64 KiB in flight, client and server can both block in `WriteFile`. +- The Windows benchmark clients avoid it with a byte budget (`bench_windows.c:2007`, `:2052-2063`; Go and Rust Windows drivers mirror it). +- A synchronous pipe handle has no per-call non-blocking write; progress needs overlapped I/O (`FILE_FLAG_OVERLAPPED` on both ends) or `PIPE_NOWAIT` (kept by Microsoft only for LAN Manager compatibility). +- Netdata's Windows consumers use typed one-in-flight calls only. + +Unknowns: + +- The exact overlapped design per language (event per operation, cancellation, interaction with the existing `PeekNamedPipe` receive timeout), to be analyzed. + +### Acceptance Criteria + +- A Windows client pipelining beyond the pipe buffers completes in C, Rust and Go, with interop. +- One-in-flight calls keep their current behavior and performance (benchmark evidence). +- The interim Windows caller rule added by SOW-0062 is replaced by the real behavior in the docs. +- The Windows benchmark clients' byte budget is removed or justified. +- If responses read during a stalled send are queued in the session, the Windows session exposes the same has-pending check as POSIX (SOW-0062 decision 1: C `nipc_uds_session_has_pending`, Rust `UdsSession::has_pending`, Go `Session.HasPending`), and the handle accessor docs say to check it before waiting on the handle. + +## Analysis + +Sources checked (2026-11-03): C `src/libnetdata/netipc/src/transport/windows/netipc_named_pipe.c`, Rust `src/crates/netipc/src/transport/windows.rs`, Go `src/go/pkg/netipc/transport/windows/`, the Windows service layer in all three, `bench/drivers/*/bench_windows*`, Windows tests, `docs/level1-windows-np.md`, `docs/level1-transport.md`, `docs/netipc-integrator-skill.md`. + +- No language uses overlapped I/O: every `ReadFile`, `WriteFile` and `ConnectNamedPipe` passes a NULL overlapped pointer (C `raw_send` `:242-254`, `raw_recv` `:288-304`, accept `:881`; Rust `raw_write` `:427-450`, `raw_recv` `:470-492`, `:922`; Go `rawWrite` `pipe.go:292-305`, `rawRecv` `:311-334`, `:136-142`). +- Server instances: `CreateNamedPipeW` with `PIPE_ACCESS_DUPLEX`, message mode, `PIPE_WAIT`, buffers `max(packet_size, 64 KiB)` (C `:208-226`, `:58-63`; Rust `:1157-1176`; Go `pipe_listener.go:255-283`). Clients: `CreateFileW` without `FILE_FLAG_OVERLAPPED`, then `PIPE_READMODE_MESSAGE` (C `:985-992`, `:1071`; Rust `:975-990`, `:760`; Go `pipe_session.go:172-175`, `:258`). The overlapped flag is per handle, so a client handle can be overlapped while the server's stays synchronous; the wire does not change. +- Send: one blocking `WriteFile` per packet; C chunks inline (`nipc_np_send` `:1145-1294`), Rust and Go use the shared framing senders (`framing::send_message`, `framing.SessionSend`), the same hooks SOW-0062 used for the POSIX pending queue. In-flight IDs live in the transport session (C `netipc_named_pipe.h:141-144`, Rust `:682`, Go `pipe_session.go:65`). Only Level 1 callers pipeline (benchmarks, tests); Level 2 Windows clients send one request at a time (C `netipc_service_win_client_call.c:69`, `:136`). +- Receive: readability by a `PeekNamedPipe` spin/sleep loop with abort and deadline (C `:396-457`, Rust `:540-633`, Go `pipe_session.go:99-145`, `pipe_receive.go:123-139`), then `ReadFile`; without timeout or abort, a plain blocking `ReadFile` (C `:1352-1357`). No Windows pending queue or has-pending check exists. +- Message larger than the receive buffer (`ERROR_MORE_DATA`): C and Rust fail the receive; Go returns the partial read as a complete message (`pipe.go:315-324`), so the rest of that message would be read as the next one. A cross-language difference. +- The pipe stays open under SHM sessions as the dead-peer probe (zero-wait `PeekNamedPipe`: C `netipc_service_win_internal.h:38-42`; Rust `client_call.rs:500`, `:589`, `:607`; Go `client_windows.go:228-233`). +- Handshake, rejection ack and accept use the same raw functions (C `:584-603`, `:686`, `:100`, `:933`; Rust `:1226-1243`, `:1299-1301`; Go `pipe_handshake.go:45`, `:51`, `:89`, `:140`, `:176`). On an overlapped handle every one of these calls must pass an `OVERLAPPED` (a NULL one can report completion wrongly), so converting a handle converts all of its I/O. +- Raw handle accessors are documented as wait objects (C `netipc_named_pipe.h:125`, `:343-350`; Rust `:707-710`; Go `pipe_session.go:82-85`), while the C code says waiting on the pipe handle is not reliable for readability (`:307-309`). An overlapped handle is also signalled by I/O completion. Only tests and the Rust impersonation check use them. +- Docs: `docs/level1-windows-np.md:80` says the pipe has "overlapped I/O capability", contradicting `:172`; the interim caller rule from SOW-0062 is `level1-windows-np.md:171-184`, `level1-transport.md:99-100`, `netipc-integrator-skill.md:202-204`. +- Benchmark byte budget: C `bench_windows.c:2007`, `:2052-2063`; Go `main_windows.go:1224`, `:1362`, drain `:1259`; Rust `bench_windows.rs:1571`, `:1615`, `:1627`. +- Tests: the largest Windows pipelining test keeps about 2 KB in flight (C `test_named_pipe.c:1482`, Rust `test_pipeline_chunked`, Go `TestPipePipelineChunked`); nothing reproduces the deadlock. +- Go without cgo: `syscall.ReadFile`/`WriteFile` take `*syscall.Overlapped`; `GetOverlappedResult`, `CancelIoEx` and `CreateEvent` exist. The `Overlapped` and buffer must stay alive until completion (per-session allocation; the Go heap does not move objects). + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- A Windows client send is a blocking `WriteFile` on a synchronous handle (Analysis). With more than the pipe buffers in flight, the server blocks writing responses the client is not reading, stops reading requests, and the client's write never completes: both block. A synchronous handle cannot stop a write to read. + +Evidence reviewed: Analysis; SOW-0062 (POSIX pending queue, `pendingBound`, has-pending) and its code (Go `transport/posix/uds_send.go`, `uds_session.go`); C `netipc_named_pipe.c` (raw I/O `:242-304`, waits `:307-487`, client handshake `:536-640`, connect `:984-1095`, close `:1100-1118`, send `:1145-1294`, receive `:1300-1499`); Rust `transport/windows.rs` (raw I/O `:426-492`, session `:660-800`); Go `transport/windows/pipe.go`, `pipe_session.go`, `pipe_send.go`, `pipe_receive.go`, `pipe_handshake.go`; the C session struct copies in the service layer (`netipc_service_win_client_connect.c:105`, `netipc_service_win_server_session.c:350`, ownership moves after connect/accept, as `recv_buf` already does). + +Affected contracts and surfaces: + +- Windows Named Pipe transport in C, Rust and Go (client side): handle flags, raw read/write, send progress, receive, close, wait-readable; new public has-pending (C `nipc_np_session_has_pending`, Rust `NpSession::has_pending`, Go `Session.HasPending`); handle accessor docs. +- C `nipc_np_session_t` gains fields (source-level; vendored, no binary ABI). +- Wire format, server side, SHM, Level 2 APIs: unchanged. +- Windows benchmark clients (byte budget removed), Windows tests, docs (`level1-windows-np.md`, `level1-transport.md`, `netipc-integrator-skill.md`). + +Existing patterns to reuse: SOW-0062's pending queue (length-prefixed records, `pendingBound` formula, progress only for a client with more than one request in flight, protocol error and in-flight failure when the bound is exceeded); the existing `PeekNamedPipe` spin-then-sleep loop; the shared framing senders/receivers in Rust and Go. + +Risk and blast radius: + +- Every client read/write changes (handshake included): a NULL overlapped pointer on an overlapped handle would misreport completion, so all client I/O goes through the overlapped helpers; server I/O keeps the synchronous helpers (decision 2A). +- An `OVERLAPPED` and its buffer must stay valid until completion: no I/O is left posted when a call returns; a failure while a write is posted cancels it (`CancelIoEx`) and waits for completion first. C and Rust keep the `OVERLAPPED` on the stack of the call; Go keeps them in a per-session heap object, because goroutine stacks can move while a write is posted. +- One-in-flight calls: one extra completion wait per read/write (data is already present after the peek loop, so reads complete at once). Benchmark evidence required. +- Bound exceeded or a protocol failure while draining: the session is unusable; it is closed for I/O and every in-flight request fails, as on POSIX. + +Sensitive data handling plan: + +- Evidence is code paths, test output and benchmark numbers only; hosts by role; no workstation paths. + +Implementation plan: + +1. Go: overlapped client handle and per-session events; overlapped raw read/write; `ERROR_MORE_DATA` fails the receive (decision 6A, test updated); pending queue, progress send, `HasPending`, wait-readable honouring the queue; close releases events. +2. Rust: the same in `transport/windows.rs`. +3. C: the same in `netipc_named_pipe.c` / `.h`. +4. Tests per language: a deadlock reproduction (a client pipelining beyond 2 x 64 KiB before reading, against a server that answers each request), first shown to hang/fail on the old code where feasible; has-pending; partial-message error (Go). Interop: a pipelining-beyond-buffer case in the Windows named-pipe interop. +5. Benchmarks: remove the Windows byte budget in C, Rust and Go drivers (decision 5A). +6. Docs: `level1-windows-np.md` (`:80` correction, Pipelining section, has-pending, handle accessor), `level1-transport.md:99-100`, `netipc-integrator-skill.md:202-204`. + +Validation plan: + +- Windows host: C CTest, Rust and Go suites, named-pipe interop; Linux: Windows cross-builds (`GOOS=windows go vet`, Rust Windows clippy target), Linux suites unaffected but compiled. +- Benchmark: the Windows suite (`tests/run-windows-bench.sh`) once after all fixes; one-in-flight rows compared with `benchmarks-windows.csv`; pipelining rows run without the byte budget. + +Artifact impact plan: + +- AGENTS.md: unaffected (no workflow change). +- Runtime project skills: unaffected. +- Specs: `docs/level1-windows-np.md`, `docs/level1-transport.md` updated. +- End-user/operator docs: same files; benchmark report if regenerated. +- End-user/operator skills: `docs/netipc-integrator-skill.md` Windows pipelining rule. +- SOW lifecycle: in-progress in current; one commit with the move to done. + +Open-source reference evidence: none; Win32 behaviour from the API contracts, verified by tests on the Windows host. + +Performance assessment: per-request code on an established session changes (client read/write on Windows named pipes); the Windows benchmark suite runs once after all fixes. + +Open decisions: none (1A, 2A-7A recorded). + +## Implications And Decisions + +- Mechanism (2026-11-02, user: "A"): overlapped I/O, own SOW, after SOW-0062. +- Overlapped ends (2026-11-03, user: "i like your recomendations", decision 2A): only the client handle (`CreateFileW`) is opened with `FILE_FLAG_OVERLAPPED`; server instances, accept, the server handshake and the server session loop stay synchronous. Rejected: B, both ends (rewrites accept and the server paths without being needed to break the deadlock). +- Stalled-send progress (decision 3A): the client posts an overlapped `WriteFile` and, while it is not complete, waits on its event in short slices; between slices it peeks and reads any arriving message into a per-session pending queue; receive returns queued messages first. No read is ever left posted when a call returns. Rejected: B, a posted overlapped read alongside the write (needs `CancelIoEx`, races with every `PeekNamedPipe` user). +- One-in-flight receive (decision 4A): the peek-then-read loop with its timeout and abort stays; reads on the overlapped client handle pass an `OVERLAPPED` and are waited to completion at once. Rejected: B, a receive redesign around a posted read plus abort event. +- Benchmark byte budget (decision 5A): removed from the C, Rust and Go Windows benchmark clients. Rejected: B, keep it. +- Go `ERROR_MORE_DATA` (decision 6A): Go fails the receive like C and Rust, with a reproduction test. Rejected: B, a separate SOW. +- Stalled-send yield budget (2026-11-03, assistant decision under the autonomous-execution instruction, within 3A, for user review): the yield phase before the wait slices is bounded by time (1 ms since the last progress) instead of 256 yields. Evidence: Execution Log, pipeline-batch investigation (E1 recovers the rows; the slices caused the loss). Rejected: pure yielding (E1; burns a core for as long as a server stalls), a drain before every pipelined write (E2; small pipelines 0.63x), a posted read alongside the write (3B, rejected earlier). Cost: a stalled pipelined client keeps one core busy for up to 1 ms per stall before it sleeps. +- Pipeline-batch benchmark cost (2026-11-04, user: "1a", decision 8A): accepted. np-pipeline-batch-d16 is about 10% lower for C and Rust clients and 15-20% lower for Go clients than HEAD's byte-budgeted clients (Execution Log, residual gap investigation); one-in-flight and small-message pipeline rows are equal or better. Rejected: B, profile further (needs an ETW tool install on the Windows host, uncertain gain); C, overlapped only while pipelining (does not help the pipelined row, adds a second code path in three languages). +- Handle accessors (decision 7A): the accessor docs say the pipe handle is not a readability signal and callers must check the new has-pending first; has-pending is added to the Windows session in C, Rust and Go. Rejected: B, removing the accessors (API break). + +## Plan + +After analysis. + +## Execution Log + +### 2026-11-02 + +Created from SOW-0062. + +### 2026-11-03 + +- Go: `pipe_overlapped.go` (new: overlapped helpers, `pipeIO` heap-held `OVERLAPPED`s with manual-reset events, `writeWithProgress`, `drainPending`, `breakSession`, `pendingQueue`, `pendingBound`); `pipe.go` (`rawRecv` fails on `ERROR_MORE_DATA`); `pipe_session.go` (overlapped client handle, `HasPending`, `WaitReadable` honours the queue, `Close` releases events and queue, handle doc); `pipe_handshake.go`, `pipe_send.go`, `pipe_receive.go` (client I/O through the session helpers, progress bound when more than one request is in flight, receive pops the queue first). `GetOverlappedResult` and `CreateEventW` are called through kernel32 procs (`syscall` lacks them); no cgo. +- Rust: `transport/windows.rs`, the same design (`PipeIo`, `PendingQueue`, `write_packet` with progress, `has_pending`); the `OVERLAPPED` is a stack local of each call, which waits for completion before returning. +- C: `netipc_named_pipe.c` / `.h`, the same design (session events and queue fields, `pipe_send`, `pending_drain`, `break_session`, `nipc_np_session_has_pending`); server sessions are zeroed, so their events stay NULL and their I/O synchronous. +- Tests: Go `TestPipePipelineBeyondPipeBuffers`, `TestPendingQueuePopTooSmall`, `TestPipeClientReceiveMoreDataFails` (and `TestRawRecvMoreData` replaced by `TestRawRecvMoreDataFails`); Rust `test_pipeline_beyond_pipe_buffers`, `test_pending_queue_pop_too_small`; C `test_pipeline_beyond_pipe_buffers` in `test_named_pipe.c`. Each pipelines 32 x 16 KiB requests (512 KiB each way, 8x the pipe buffers) before reading, against a server that answers each with blocking writes; a 30 s watchdog only reports a deadlock. Interop: `pipeline-server` / `pipeline-client` modes in the three `interop_named_pipe` fixtures; `tests/test_named_pipe_interop.sh` runs the 9 pairs once per mode. +- Red check: the Go reproduction (without the new APIs) ran on the Windows host against the old code and hung until Go's 10-minute test timeout (`FAIL ... 601.289s`, goroutine dump in the client send); the temporary test file was removed from the host afterwards. +- Benchmarks (decision 5A): the byte budget and its accounting (`inflight_budget`, per-slot cost, `outstanding`) removed from `bench_windows.c`, Go `main_windows.go` (warmup and measured loop), Rust `bench_windows.rs`; each cycle sends the whole depth, then reads. +- Docs: `level1-windows-np.md` (connection lifecycle states which handle is overlapped; Pipelining section describes the real behaviour, bound, failure, peer close, has-pending and the handle accessor), `level1-transport.md` (Pipelining scope; Windows wait object note), `netipc-integrator-skill.md` (Windows pipelining rule). + +- Independent review (read-only agent over the full diff): no confirmed bug; findings and handling under Validation. Fixes applied: Go `pipeIO` holds the posted read/write buffers (`readBuf`, `writeBuf`), so every buffer handed to the kernel escapes to the heap (`go build -gcflags=-m`: `leaking param: buf/data/msg`); a non-disconnect `PeekNamedPipe` error while draining now fails the send (protocol error, cancel, break) in C, Rust and Go instead of stopping the drain; the Rust reproduction test reports a client panic instead of "deadlocked"; doc wording (wait slices, bound, peek failure, oversized message). + +- Benchmark round 1 (NP scenarios: `np-ping-pong`, `np-batch-ping-pong`, `snapshot-baseline`, `np-pipeline-d16`, `np-pipeline-batch-d16`; SHM and lookup rows do not run the changed client pipe I/O: SHM data moves through shared memory and its pipe sees only the handshake and the zero-wait `PeekNamedPipe` probe). Against `benchmarks-windows.csv` (2026-06-15) every NP max-rate row was lower, the pipeline rows about 0.4x; a same-host A/B against HEAD (5e6bfb7) showed that drift is the host, not this change (np-pipeline-d16 c->c: HEAD 98.4k, change 109.7k). +- A/B against HEAD, same host, same session, 5 x 20 s samples per row: + - np-ping-pong max (two runs, both orders): throughput equal or higher (c->c 44.2k/48.2k vs 44.2k; rust->c 44.0k vs 38.4k; go->c 41.9k-45.8k vs 39.0k-41.2k), p50 equal or lower; client CPU 7-11 points higher at max rate, no consistent difference at 10k req/s (82.7/76.7/86.8% vs 85.1/90.1/80.0%). + - np-batch-ping-pong max: c->c 8.99M vs 8.78M, go->c 8.87M vs 8.26M. + - np-pipeline-batch-d16: 15-23% lower (c->c 16.6M vs 19.4M; rust->c 15.6M vs 18.7M; go->c 17.0M vs 22.0M), p50 up about 100 us, server CPU down (the server blocked). +- Pipeline-batch regression investigation: + - Go and Rust zeroed a full packet slot per drained message (`append(make(...))`, `resize(.., 0)`); fixed (the queue storage only grows and is zeroed once; Rust `PendingQueue` keeps `end`, Go `pendingQueue.grow`). Not the main cause: the rows stayed 15-23% lower. + - C experiment (scratch tree on the Windows host, c->c, same session): current code 16.9M (p50 480 us); E1, yield without the 1 ms slices, 22.8M (p50 323 us), np-pipeline-d16 111k; E2, a drain before every pipelined write, 19.0M but np-pipeline-d16 70k. Cause: after 256 yields (about 100 us) the send slept in `WaitForSingleObject(write_event, 1)`, whose slice lasts a timer tick and does not end when the server's reply arrives, so the server stayed blocked on its reply (review finding 2). + - Fix: the stalled send yields for up to 1 ms after the last progress (`NP_PROGRESS_YIELD_US`, Rust `PROGRESS_YIELD`, Go `progressYield`; QPC / `Instant` / `monotime`, read only while a write is stalled), then waits in slices as before. +- Benchmark round 2 (all NP scenarios, after the yield fix; 108 rows, rc 0): one-in-flight rows hold (np-ping-pong max c->c 44.7k, rust->c 46.4k, go->c 44.5k; np-batch-ping-pong max c->c 8.85M, go->c 8.63M; np-pipeline-d16 c->c 102.5k vs HEAD 98.4k). np-pipeline-batch-d16 is still below HEAD: c->c 17.6M vs 19.4M, rust->c 17.2M vs 18.7M, go->c 15.6M vs 22.0M. A 1 ms yield does not cover the stalls; investigated next. +- Residual pipeline-batch gap investigation (C variants in the scratch tree, same host, np-pipeline-batch-d16, items/s): + - HEAD is noisy run to run: c->c 19.4M, 18.2M, 21.4M; go->c 20.9M, 18.6M, 21.3M. + - Wait variants, c->c: y1 (current, 1 ms yield) 15.6M-16.6M; i1 (y1 instrumented) 15.4M; e1 (pure yield, no slices) 17.6M; y16 (16 ms yield) 18.4M; hr (1 ms yield, then 500 us high-resolution waitable-timer slices) 16.5M. No variant reaches HEAD consistently, so the wait slices are not the main cause in this session. + - Current transport with HEAD's byte-budgeted drivers: c->c 18.5M, rust->c 20.2M, go->c 18.7M vs HEAD 21.4M, 18.4M, 21.3M in the same session. The budgeted client never stalls a write, so the remaining difference is the per-call cost of the overlapped client handle (client CPU 75% vs 66%; np-pipeline-d16 64% vs 43%), which buys no throughput loss on the small-message rows (np-pipeline-d16 c->c 102.8k vs 94.0k). + - Conclusion: pipeline-batch rows are about 10% lower for C and Rust clients and 15-20% lower for Go clients, inside a noise band of about 10%; one-in-flight and small-message pipeline rows are equal or better. Decided 2026-11-04 (decision 8A): accepted. + +## Validation + +Acceptance criteria: + +- Pipelining beyond the pipe buffers completes in C, Rust and Go: `test_pipeline_beyond_pipe_buffers` (C, 7 checks), Rust `test_pipeline_beyond_pipe_buffers`, Go `TestPipePipelineBeyondPipeBuffers` pass on the Windows host; the same Go test hung to the 10-minute timeout on the old code (Execution Log, red check). Interop: `tests/test_named_pipe_interop.sh` 18/18 (9 pairs single, 9 pairs pipelining 32 x 16 KiB). +- Has-pending and wait-readable honouring the queue: asserted in all three reproduction tests (queued after the sends, `wait_readable(0)` true, drained at the end). +- Docs: interim caller rule replaced (`level1-windows-np.md` Pipelining, `level1-transport.md`, `netipc-integrator-skill.md`). +- Benchmark byte budget removed in C, Rust and Go Windows drivers. + +Tests (Windows host, after the review fixes): Rust `cargo test --lib` 275 passed (stable and 1.91.0-msvc); Go `go test ./...` all packages ok (includes the Windows allocation tests); CTest 20/20 (`test_named_pipe` 301 checks, `test_named_pipe_go`, `test_named_pipe_interop`). Linux: `go vet ./...`, `GOOS=windows go vet ./...` (only the pre-existing `shm.go` unsafe.Pointer warnings), `cargo check --all-targets`, `cargo clippy --target x86_64-pc-windows-gnu --all-targets` (no correctness/suspicious lints; style lints pre-existing or following the FFI naming), `cargo fmt --check`, zig `x86_64-windows-gnu` compiles of the C transport, test, interop fixture and bench driver. + +Reviewer findings: + +1. Go read buffers could sit on a movable goroutine stack while a read is posted (risk): fixed (buffers pinned through `pipeIO`). +2. The 1 ms wait slice is rounded up by the system timer (perf risk): kept, as decided in 3A and as the existing receive wait loops do (`Sleep(1)`); the slice ends early when the write completes; doc wording corrected; checked by the pipeline rows of the benchmark run. +3. Doc bound omitted the 4-byte queue record (nit): doc fixed (also the same wording in `level1-posix-uds.md`, whose SEQPACKET queue keeps the same record). +4. Any peek error stopped draining, which could bring the deadlock back on a live pipe (risk): fixed in C, Rust and Go. +5. Oversized-message state differs between queue (message dropped) and pipe (rest stays) (nit): documented; behaviour matches C/Rust before this SOW. +6. Rust test reported a client panic as a deadlock (nit): fixed. + +Benchmark (Test Cadence): one run of all NP scenarios after all fixes (round 2, 108 rows, rc 0); regressions analysed with same-host A/B variants and HEAD brackets (Execution Log). One-in-flight rows: throughput equal or higher than HEAD; `benchmarks-windows.csv` (2026-06-15) is not comparable (host drift shown by the HEAD A/B). Pipelining rows ran without the byte budget. Floors (`tests/generate-benchmarks-windows.sh` floor rules): the only NP floor, np-ping-pong max > 0, holds on every row. The pipeline-batch cost is accepted (decision 8A). The committed report is regenerated by the full POSIX and Windows run that SOW-0050 schedules before vendoring. + +Real-use evidence: the interop suite runs real C, Rust and Go client and server processes over Named Pipes, single and pipelined (18/18); the benchmark drivers pipeline 16 requests without the byte budget across all 9 language pairs (rc 0, no hang). + +Same-failure search: no other Windows pipelining workaround (`grep` for `packet_size * 2`, `inflight_budget`, `inflightBudget` in `src/`, `bench/`, `tests/`: none left); every client read/write goes through the overlapped helpers (reviewer confirmed; remaining `raw_*` calls are server handles or test-created synchronous pipes). + +## Outcome + +Windows Named Pipe clients in C, Rust and Go can keep more requests in flight than the pipe buffers hold: a stalled client write reads arriving responses into a bounded per-session pending queue, so the client and server both keep making progress. The client handle is overlapped; server handles stay synchronous. Callers no longer need a byte budget, and the benchmark drivers no longer carry one. The cost is a higher client CPU per call and about 10-20% on the batched pipeline benchmark row, accepted by the user. + +Artifact maintenance gate: + +- AGENTS.md: no update; no workflow, command or guardrail changed (the Windows suites and bench commands are the existing ones). +- Runtime project skills: no update; `project-netdata-vendoring` workflow is unchanged, and this SOW's code reaches Netdata through SOW-0050's normal preflight. +- Specs: `docs/level1-windows-np.md` (handle kinds, Pipelining section, has-pending, accessor rule), `docs/level1-transport.md` (Pipelining scope, Windows wait object), `docs/level1-posix-uds.md` (bound wording includes the queue record). `.agents/sow/specs/` holds no Named Pipe rule, so it needs no change. +- End-user/operator docs: the same `docs/` files are the published guides; the benchmark report is regenerated with SOW-0050's full run. +- End-user/operator skills: `docs/netipc-integrator-skill.md` Windows pipelining rule replaced (no byte budget; check has-pending before waiting on the handle). +- SOW lifecycle: completed, moved to `done/` in the same commit; follow-up mapping below. + +## Lessons Extracted + +- Windows wait slices last a system timer tick (about 15.6 ms), not the requested 1 ms; a progress loop that sleeps early stalls its peer. +- Same-host A/B runs need HEAD runs before and after the variants: HEAD alone moved 18.2M-21.4M items/s between runs on the pipeline-batch row, more than most of the effects measured. +- A benchmark driver change (here the removed byte budget) changes what a row measures; isolate it by running the new transport with the old driver before blaming the transport. +- Processes started by a detached ssh session on the Windows host fail with 0xC0000142 once ssh closes; hold the ssh session open for long runs (recorded in assistant memory, not in the repo). + +## Followup + +- Regenerated Windows benchmark report: tracked by SOW-0050 (full POSIX and Windows run before vendoring, line "Ordering (2026-10-31, user)"). +- Further pipeline-batch profiling: rejected by the user (decision 8A). +- Deferral search (`defer|later|follow-up|future|TODO|pending`): remaining hits are the has-pending API name and the pending queue, not deferred work. + +## Regression Log + +None yet. diff --git a/.agents/sow/done/SOW-0066-20261104-pre-vendoring-ci-remediation.md b/.agents/sow/done/SOW-0066-20261104-pre-vendoring-ci-remediation.md new file mode 100644 index 00000000..54c78c2c --- /dev/null +++ b/.agents/sow/done/SOW-0066-20261104-pre-vendoring-ci-remediation.md @@ -0,0 +1,300 @@ +# SOW-0066 - Pre-Vendoring CI Remediation + +## Status + +Status: completed + +`completed` is the successful terminal status. `done` is a directory name, not a status value. Do not use `Status: done` or `Status: complete`. + +Sub-state: fixes implemented and validated locally and on the macOS, Windows and FreeBSD hosts; CI rerun on the pushed commit is recorded in SOW-0050's preflight. + +Regression (2026-11-05): `mkdir -m 0700` broke `tests/run-windows-bench.sh` on MSYS; repaired, see `## Regression - 2026-11-05`. + +## Requirements + +### Purpose + +Make the plugin-ipc vendoring candidate pass its CI and code-scanning gates, so SOW-0050 can vendor it into Netdata with evidence instead of exceptions. + +### User Request + +The user accepted the recommendations of the SOW-0050 preflight report (2026-11-04): fix the CI failures in a separate SOW before vendoring (1A), make the macOS bind fd-relative (2A), dismiss the five remaining CodeQL alerts with evidence (3A), pin Semgrep (4A), accept the Codacy coverage-token failure for this vendoring (5B), push this SOW's commit to the branch and rerun the workflows (6), rerun the POSIX benchmarks once this SOW is in and the workstation is quiet (7A), and settle performance with same-host full A/B runs of `63919ec` against the candidate (8A, executed in SOW-0050). + +### Assistant Understanding + +Facts: + +- All six workflows dispatched on `2eee40a` failed. The triage is in SOW-0050's Execution Log (2026-11-04). +- The product code is correct in every failing case except the macOS bind, where the code works but uses path-based `chmod`/`rename` that scanners flag as a race. + +Inferences: + +- None of these changes run per request on an established session, so the pending benchmarks still measure the vendored code. + +Unknowns: + +- Whether the pinned Semgrep stops crashing on the runner can only be proven by the rerun CI. + +### Acceptance Criteria + +- Go and Rust Windows DACL tests pass when the process user is the built-in Administrator (SDDL alias `LA`) and for any other account: proven by the CodeQL Go Windows job and the Windows test host. +- The C CLOEXEC robustness test ignores descriptors the process inherited: proven by the Linux target jobs. +- `armv7-gnu` and `armv7-gnu-time64` Rust builds link with Rust 1.99: proven by the Linux target jobs. +- staticcheck reports nothing for `src/go`: proven locally and by the Go Static Analysis job. +- flawfinder `--minlevel=5` and Semgrep report no `chmod` race; the macOS bind still produces an owner-only socket: proven locally (flawfinder) and by the macOS host suites. +- The rustdoc warning `unresolved link to directory` is gone (`cargo doc`). +- `tests/run-posix-bench.sh` and `tests/run-windows-bench.sh` create private run directories whatever the umask. +- The Semgrep job finishes on the runner. + +## Analysis + +Sources checked: + +- CI logs of runs 41818654495, 41818662881, 41818667746, 41818696256, 41818701021, 41818758377 (`netdata/plugin-ipc`, `2eee40a`). +- Code-scanning alerts 7759 to 7765 on `refs/heads/sow-0040-robustness-repro-tests`. +- `src/go/pkg/netipc/transport/windows/security_test.go:45`, `src/crates/netipc/src/transport/windows.rs:2740`, `src/crates/netipc/src/transport/win_shm.rs:1450`, `tests/fixtures/c/test_win_security.h` (SID comparison, correct). +- `tests/fixtures/c/test_robustness.c:1068` (`all_ipc_fds_cloexec`). +- `tests/run-linux-target.sh:152` (`+crt-static`). +- `src/go/pkg/netipc/service/raw/large_helpers_unix_test.go`, `src/go/pkg/netipc/transport/posix/uds_platform_other.go`, `uds_cloexec_other.go`. +- `src/libnetdata/netipc/src/transport/posix/netipc_uds_lifecycle.c:104`, `src/crates/netipc/src/transport/posix.rs:869`, `src/go/pkg/netipc/transport/posix/uds_bind_darwin.go`. +- `.github/workflows/supply-chain-security.yml`, `tests/run-posix-bench.sh:576,905`, `tests/run-windows-bench.sh`. + +Current state: + +- Windows DACL tests: the runner account is RID 500; `ConvertSecurityDescriptorToStringSecurityDescriptor` prints it as `LA`, while the tests build the expected string from `ConvertSidToStringSid` (full `S-1-5-21-...-500`). +- CLOEXEC test: it scans every fd from 3; the runner passes inherited pipes (fds 142, 145). +- armv7 gnueabihf with `+crt-static` links as static-pie on Rust 1.99.1 and needs `rcrt1.o`, which Ubuntu's `libc6-dev-armhf-cross` does not ship (local Rust 1.97.1 links it as static non-PIE). +- staticcheck U1000: `simpleHash`, `largeHandler`, `startServerWithWorkers` are used only by `stress_test.go` (`netipc_extended`); `setNoSigpipe` in `uds_platform_other.go` (`unix && !darwin`) is called only from `uds_cloexec_other.go` (`unix && !linux && !freebsd`). +- macOS bind: `chmod(dir)`, `chmod(tmp)` and `rename(tmp, path)` resolve paths again after `mkdir`; if another account could rename entries in `run_dir`, the directory could be swapped between those calls. +- Semgrep: `pip install --upgrade semgrep` on Python `3.x` installed semgrep 1.183.0 on Python 3.15.1; `semgrep-core` exits with -11. Semgrep 1.177.0 on Python 3.12 scans this tree locally. +- Bench scripts: `mkdir -p "$RUN_DIR"` gives mode `0777 & ~umask`; under umask `0002` the C driver refuses it (`tests/fixtures/c/interop_path.h:94`). + +Risks: + +- The fd-relative bind changes the macOS listener path. It is validated on the macOS host with the C, Rust and Go suites and the interop scripts. +- Pinning Semgrep freezes its rules engine version; Dependabot does not update pip pins in workflow `run` steps, so the pin must be bumped by hand. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- Five test or tooling defects and one scanner-flagged pattern, each with the cause above; none is a product behaviour defect on Linux or Windows. + +Evidence reviewed: + +- The CI logs and alerts listed in Analysis; local flawfinder rule list (`chmod` and `chown` are level 5; `fchmodat` and `renameat` are not listed); local semgrep run. + +Affected contracts and surfaces: + +- macOS listener creation (C, Rust, Go); Windows and Linux test suites; Linux target script; supply-chain workflow; bench scripts; one rustdoc line. No wire format, API or default changes. + +Existing patterns to reuse: + +- `tests/fixtures/c/test_win_security.h` (compare by SID); Go and Rust `processUserSID`/`process_user_sid`; the `netipc_extended` build tag; `uds_cloexec_*.go` platform split. + +Risk and blast radius: + +- macOS listeners only for the bind change; tests and CI otherwise. +- Performance assessment: no changed code runs per request on an established session. The bind runs once per listen (`netipc_uds_lifecycle.c` `bind_owner_only_socket`), the rest is tests, CI, scripts and a doc comment. No benchmark run is needed for this SOW; the SOW-0050 runs cover the candidate. + +Sensitive data handling plan: + +- Hosts are named by role; the runner SID appears only as the generic alias `LA`/RID 500, not the runner's full SID; no tokens or private endpoints in the SOW, code comments or commit. + +Implementation plan: + +1. Windows DACL tests (Go, Rust): normalize the expected SDDL by converting it to a descriptor and back. +2. C CLOEXEC test: record the descriptors open before the test and check only new ones. +3. `tests/run-linux-target.sh`: add `-C relocation-model=static` for the armv7 gnu targets. +4. Go: tag `large_helpers_unix_test.go` `unix && netipc_extended`; move the no-op `setNoSigpipe` to a file built only with `uds_cloexec_other.go` on non-darwin. +5. macOS bind, C/Rust/Go: `mkdir`, open the directory `O_DIRECTORY|O_NOFOLLOW`, check it is ours, `fchmod` it, `bind` the socket inside it, `fchmodat` and `renameat` relative to the directory fd, clean up relative to it. +6. Rustdoc backport (`protocol/mod.rs`). +7. Bench scripts: `mkdir -m 0700` for run directories. +8. Semgrep: pin Python 3.12 and semgrep 1.177.0. +9. Dismiss code-scanning alerts 7759 to 7763 with evidence comments. + +Validation plan: + +- Linux: CTest, `cargo test`, `go test`, staticcheck, flawfinder `--minlevel=5`, `cargo doc`, fmt on stable and 1.91, `go vet` for darwin/freebsd/linux cross targets. +- Linux targets: `tests/run-linux-target.sh armv7-gnu` with the new flag. +- macOS host: CTest, `cargo test`, `go test`, the UDS, service, cache, lookup-scale interop scripts and `interop_codec.sh`. +- Windows host: the Go and Rust DACL tests. +- Rerun the dispatched workflows on the pushed commit. + +Artifact impact plan: + +- AGENTS.md: unaffected; no workflow change. +- Runtime project skills: unaffected; the vendoring preflight already covers these gates. +- Specs: unaffected; no protocol or contract change. +- End-user/operator docs: `docs/level1-posix-uds.md` macOS bind steps change to the fd-relative sequence. +- End-user/operator skills: `docs/netipc-integrator-skill.md` describes only the private directory, which is unchanged; recheck at close. +- SOW lifecycle: SOW-0050 stays in progress and waits for this SOW. + +Open-source reference evidence: + +- No external repositories were consulted; the evidence is this repository's CI and tooling. + +Open decisions: + +- None; the user decided items 1A to 8A on 2026-11-04. + +## Implications And Decisions + +1. Fix location: a separate SOW, own commit, new vendoring candidate (user, 1A). +2. macOS `chmod` alerts: fix with fd-relative calls in C, Rust and Go (user, 2A). +3. CodeQL alerts 7759 to 7763: dismiss with evidence comments (user, 3A). +4. Semgrep crash: pin Python and semgrep in the workflow (user, 4A). +5. Codacy coverage upload refused for the token's account: accepted for this vendoring; token permission fixed later by the user (user, 5B). +6. Push this SOW's commit to the branch and rerun the workflows (user, 6). +7. Go macOS bind (user, 10A, 2026-11-04): darwin's `syscall` exports no `Fstatat`, `Fchmodat` or `Renameat`, so plugin-ipc's Go module takes its first dependency, `golang.org/x/sys` pinned to v0.49.0 (the version Netdata's `src/go/go.mod` already requires), and uses `unix.Fstatat`, `Fchmodat`, `Renameat`, `Unlinkat`. Rejected: raw `syscall.Syscall` (deprecated on macOS) and keeping Go path-based (breaks parity). + +## Plan + +See the implementation plan above. + +## Execution Log + +### 2026-11-04 + +- SOW created from the SOW-0050 preflight triage. +- Done: Go and Rust DACL tests normalize the expected SDDL through Windows (`security_test.go` `ownerOnlyDACL`, `win_security.rs` `owner_only_dacl_sddl`); C CLOEXEC test skips descriptors open before it (reproduced with an inherited pipe on fd 9: the old binary fails, the new one passes); `run-linux-target.sh` adds `-C relocation-model=static` for armv7 gnu; Go `large_helpers_unix_test.go` tagged `netipc_extended` and the no-op `setNoSigpipe` moved to `uds_nosigpipe_other.go` (`unix && !darwin && !linux && !freebsd`; `go vet` passes for linux, freebsd, darwin, netbsd, openbsd); C and Rust macOS bind made fd-relative (flawfinder level 5: 0 hits; Rust `cargo check --target aarch64-apple-darwin` clean); rustdoc line now equals Netdata's `a80f0e3dc0` (`cargo doc` without warnings); bench run directories created with `mkdir -p -m 0700` (POSIX 2 sites, Windows 7 sites; the interop scripts already use `mktemp -d`); Semgrep pinned to 1.177.0 on Python 3.12. +- Blocked (design cannot be applied as decided): Go has no `golang.org/x/sys` dependency, and darwin's `syscall` package exports no `Fstatat`, `Fchmodat` or `Renameat` (only unexported `fstatat`, `unlinkat`, `openat`). The Go macOS bind was restored to its committed content pending the user's decision. +- Unblocked by decision 7 (10A): Go macOS bind rewritten with `golang.org/x/sys/unix` v0.49.0; `src/go/go.mod` requires it, `go.sum` added; the dependent modules `bench/drivers/go` and `tests/fixtures/go` carry it as an indirect requirement with their own `go.sum` (`go mod tidy`). Dependabot already tracks `/src/go`; vendoring copies only `pkg/netipc`, and Netdata's `src/go/go.mod` already requires `golang.org/x/sys v0.49.0`. +- `docs/level1-posix-uds.md` macOS bind steps rewritten for the fd-relative sequence. +- Code-scanning alerts dismissed with evidence comments (decision 3): 7761 and 7763 false positive, 7759 and 7760 used in tests, 7762 won't fix. +- Independent review (fresh read-only agent, code only): no correctness bugs in the three bind implementations or the DACL helpers; three findings: + - 1.1 (low, valid, fixed): opening the bind directory read-only right after `mkdir` failed under a umask without owner read (the old path `chmod` ran first). Repro: new C case `listen under umask 0477` in `tests/fixtures/c/test_uds.c` failed on the macOS host with the unfixed bind and passes with the fix. Fix in C, Rust and Go: `fchmodat(AT_FDCWD, dir, 0700, AT_SYMLINK_NOFOLLOW)` before the open (not a flawfinder rule; flawfinder level 5 still 0 hits); the `fchmod` of the descriptor stays. + - 1.2 (informational, no change): on the uid-mismatch path the directory name is still `rmdir`ed, which removes only an empty directory; unchanged from before and within `docs/level1-posix-uds.md` ("A directory with other content is never deleted"). + - 3.1 (low-medium, valid, fixed): the CLOEXEC snapshot was taken after about 15 earlier tests, so a descriptor an earlier test leaked would be skipped. The snapshot now runs first in `main()`. +- Same-umask check of Rust and Go on the macOS host (test binaries built under the normal umask, run under `umask 0477`): the listeners start and narrow the socket to `0200`; assertions that expect `0600` fail as they assume the default umask. Two Rust lock tests got `Err(Socket(13))`: the endpoint lock file (SOW-0052) becomes `0200` and later `O_RDWR` opens fail. That defect predates this SOW, affects all POSIX platforms, needs a design decision and is tracked in `.agents/sow/pending/SOW-0067-20261104-endpoint-lock-under-restrictive-umask.md`. + +## Validation + +Acceptance criteria evidence: + +- Windows DACL tests: Rust `test_pipe_dacl_owner_only`, `test_server_objects_dacl_owner_only_windows` and Go `TestPipeDACLOwnerOnly`, `TestWinShmObjectsDACLOwnerOnly` pass on the Windows test host (a non-RID-500 account). The RID 500 (`LA`) case is proven by the CodeQL Go Windows job on the pushed commit (SOW-0050). +- CLOEXEC test: with an inherited non-close-on-exec pipe on fd 9, the old binary fails ("fd 9 (pipe) is not close-on-exec") and the new one passes with 5 descriptors checked. +- armv7 gnu Rust: `tests/run-linux-target.sh armv7-gnu c rust` passes (C 14/14, Rust 398) with the new flag on the local Rust 1.97.1; the Rust 1.99.1 link is proven by the Linux target jobs on the pushed commit. +- staticcheck: the local staticcheck cannot load Go 1.27 export data; `go vet` passes for linux, freebsd, darwin, netbsd and openbsd; the Go Static Analysis job proves staticcheck on the pushed commit. +- flawfinder `--minlevel=5 --error-level=5 src/libnetdata/netipc tests`: level-5 hits 0 (was 2). +- macOS bind: owner-only socket and umask narrowing hold under umask 022, 0277 and 0477 (C `test_uds`), and the macOS suites pass. +- rustdoc: `cargo doc --offline --no-deps --lib` without warnings; `protocol/mod.rs:425` equals `netdata/netdata @ a80f0e3dc0`. +- Bench run directories: `mkdir -p -m 0700` at every run-directory site of both bench scripts. +- Semgrep: pinned version scans this tree locally; the runner result is recorded with the CI rerun (SOW-0050). + +Tests or equivalent validation: + +- Linux: CTest 32/32 (`test_shm_full_backing_store` skipped as before, needs root), `cargo test` 398 passed, `go test ./...` ok, `go vet` for linux/darwin/freebsd, `cargo fmt --check` on stable and 1.91, `cargo check --tests` for `aarch64-apple-darwin` and `x86_64-pc-windows-gnu`, clippy shows no warnings on changed lines. +- macOS host: CTest 24/24, `cargo test` 330, `go test` ok, interop uds 18, service 18, service_shm 18, cache 9, cache_shm 9, lookup_scale 36, codec ok (SHM interop does not apply on macOS). +- FreeBSD 15.1 and 14.3: CTest 31/31, `cargo test` 398, `go test` and `GOARCH=386 go test` ok, interop uds 18, shm 9, service 18, service_shm 18, cache 9, cache_shm 9, lookup_scale 36, codec ok. +- Windows host: Rust `cargo test --lib` 275 passed, Go `go test ./...` ok. +- `govulncheck`: no vulnerability in code paths; the one module finding is the workstation's Go 1.27.2 standard library (fixed in 1.27.3, which CI uses). `osv-scanner` on `src/go/go.mod`: no issues. + +Real-use evidence: + +- The interop suites start real servers and clients across C, Rust and Go on macOS and FreeBSD with the new bind and dependency. + +Reviewer findings: + +- Independent read-only review: findings 1.1 and 3.1 fixed with reproductions, 1.2 recorded as unchanged behaviour (Execution Log). + +Same-failure scan: + +- Other path-based `chmod`/`chown` in `src/libnetdata/netipc` and `tests`: none (flawfinder level 5: 0). +- Other DACL string comparisons: the C helper compares SIDs (`tests/fixtures/c/test_win_security.h`); no other SDDL string compare in Rust or Go tests. +- Other umask-dependent run directories: the interop scripts use `mktemp -d` (0700), unaffected. +- Other unused Go code under staticcheck: only the two cases found; confirmed by the CI rerun. +- Same restrictive-umask issue elsewhere: the endpoint lock file, tracked in SOW-0067. + +Sensitive data gate: + +- No raw secrets, tokens, customer data or private endpoints; the runner account is referred to only as RID 500 / `LA`; hosts are named by role. + +Artifact maintenance gate: + +- AGENTS.md: no update; workflows, commands and guardrails are unchanged. +- Runtime project skills: no update; the vendoring preflight already requires these gates. +- Specs: no `.agents/sow/specs/` change; no protocol or contract change. +- End-user/operator docs: `docs/level1-posix-uds.md` macOS bind steps updated. +- End-user/operator skills: `docs/netipc-integrator-skill.md` mentions only the private `{service}.d` directory, which is unchanged; no update. +- SOW lifecycle: `Status: completed`, moved to `done/` in this SOW's commit; SOW-0050 stays paused until the CI rerun; SOW-0067 created in `pending/`. + +Specs update: + +- None needed: the change is the macOS bind mechanism, documented in `docs/level1-posix-uds.md`, which is the authoritative spec. + +Project skills update: + +- None needed: no new reusable workflow; the preflight skill already covers CI and scanner gates. + +End-user/operator docs update: + +- `docs/level1-posix-uds.md`. + +End-user/operator skills update: + +- None affected (see the artifact gate). + +Lessons: + +- See Lessons Extracted. + +Follow-up mapping: + +- Endpoint lock under a restrictive umask: tracked in SOW-0067. +- Codacy coverage token permission: user action, accepted risk recorded in SOW-0050 (decision 5). +- Semgrep pin bump: recorded in the workflow comment; Dependabot does not update pip pins in `run` steps. + +## Outcome + +The vendoring candidate's CI and scanner failures are fixed: Windows DACL tests are independent of the runner account, the CLOEXEC test ignores inherited descriptors, armv7 gnu Rust links on current Rust, staticcheck has nothing to report, the macOS bind is fd-relative in C, Rust and Go (with `golang.org/x/sys` v0.49.0 in Go), Semgrep is pinned, bench run directories are private, and the Netdata rustdoc fix is backported. Five CodeQL alerts were dismissed with evidence. + +## Lessons Extracted + +- Workflows that run only on `main` and PRs leave branch-only work without CI; long-lived branches need `workflow_dispatch` runs before vendoring, not after. +- Test expectations built from strings that Windows prints must go through the same Windows conversion, or they break on accounts with SDDL aliases. +- Tests that scan process-wide state (open descriptors) must snapshot it at process start. +- Removing a path-based step can drop an implicit precondition (here: `chmod` before open restored owner read); restrictive-umask tests belong next to every umask-related change. +- `tar` keeps source mtimes; syncing into an existing build tree needs `tar -m`, or the build skips changed files. + +## Followup + +- SOW-0067 (pending): endpoint lock under a restrictive umask. +- Codacy coverage token permission: user action (decision 5); tracked in SOW-0050's close-out. + +## Regression Log + +None yet. + +Append regression entries here only after this SOW was completed or closed and later testing or use found broken behavior. Use a dated `## Regression - YYYY-MM-DD` heading at the end of the file. Never prepend regression content above the original SOW narrative. + +## Regression - 2026-11-05 + +What broke: + +- The SOW-0050 decision 8A Windows run of `80a56de` stopped before its first benchmark: `mkdir: cannot change permissions of '/tmp/netipc-bench-394060': Permission denied`. The `63919ec` run, without this SOW's script change, ran to the end on the same host. + +Evidence: + +- On the Windows test host (MSYS2, root mounted `noacl`), `mkdir -p -m 0700 /tmp/x` exits 1 with the same message, while `mkdir` followed by `chmod 0700` exits 0; MSYS cannot apply POSIX modes on that mount and `mkdir -m` reports it. +- The Windows benchmark drivers are native MinGW binaries; the run-directory ownership and group/world-write check in `tests/fixtures/c/interop_path.h` is compiled only for POSIX and MSYS-runtime builds (`#if defined(_WIN32) && !defined(__MSYS__)` takes the stat-only branch). The Windows script never needed a 0700 directory. + +Why validation missed it: + +- This SOW validated the Windows Rust and Go suites but did not run `tests/run-windows-bench.sh` after changing it. + +Repair: + +- `tests/run-windows-bench.sh`: the 7 run-directory sites are back to `mkdir -p`, as at `63919ec`. `tests/run-posix-bench.sh` keeps `mkdir -p -m 0700`, which the POSIX drivers need. + +Validation: + +- `bash -n` passes; shellcheck reports the same 5 SC2034 warnings as before the repair (none on the changed lines). +- Windows host: `NIPC_BENCH_SCENARIOS=lookup NIPC_BENCH_CLIENTS=c bash tests/run-windows-bench.sh 2` on the `80a56de` tree with the repaired script exits 0 and writes its row (lookup c 45.7M/s). +- Status returned to `completed` and the SOW moved back to `done/` in the repair commit. + +Artifact updates: + +- No spec, doc or skill change; the bench run directory mode is not documented for Windows. Lesson: a script change is validated by running that script on every platform it serves. diff --git a/.agents/sow/done/SOW-0067-20261104-endpoint-lock-under-restrictive-umask.md b/.agents/sow/done/SOW-0067-20261104-endpoint-lock-under-restrictive-umask.md new file mode 100644 index 00000000..3f3402b9 --- /dev/null +++ b/.agents/sow/done/SOW-0067-20261104-endpoint-lock-under-restrictive-umask.md @@ -0,0 +1,273 @@ +# SOW-0067 - Endpoint Lock And SHM Region Under A Restrictive Umask + +## Status + +Status: completed + +`completed` is the successful terminal status. `done` is a directory name, not a status value. Do not use `Status: done` or `Status: complete`. + +Sub-state: implemented in C, Rust and Go (endpoint lock and SHM region file); validated on Linux, macOS and FreeBSD. + +Regression (2026-11-05): the Rust SHM umask test broke the CI coverage job; repaired, see `## Regression - 2026-11-05`. + +## Requirements + +### Purpose + +A POSIX listener must start, and must report a running peer as `AddrInUse`, whatever the process umask, as the socket bind already does. + +### User Request + +Found during SOW-0066 validation (2026-11-04); the user chose option A ("11a"): restore the lock file's mode through its descriptor after every open, and fall back to a narrower open when read-write is refused. + +### Assistant Understanding + +Facts: + +- The endpoint lock `{run_dir}/{service_name}.lock` (SOW-0052) is opened `O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW` with mode `0600` (C `netipc_uds_lifecycle.c` `acquire_endpoint_lock`; Rust `posix.rs:549-560`; Go `uds_stale.go:42`). The file stays on disk by design. +- Under a umask that strips owner read or write (for example `0477`), the creator gets a file of mode `0200`; the creator's own open succeeds, but every later `O_RDWR` open by the same account fails with `EACCES`. +- Reproduced on the macOS host with the Rust test binary under `umask 0477`: `test_listen_excludes_starting_server` gets `Err(Socket(13))` instead of `AddrInUse`, and `test_listen_close_releases_lock_held_by_child` gets `Err(Socket(13))`. The code is shared by all POSIX platforms. +- Effects: a second server is told "socket error" instead of "address in use"; after the first server exits, the lock file it left blocks every restart of that endpoint until someone deletes or chmods it. + +Inferences: + +- Netdata runs with ordinary umasks (`022`, `007`), so production Netdata is not expected to hit this; embedders with unusual umasks would. + +Unknowns: + +- None that investigation can resolve; the remaining choice is a design decision. + +### Acceptance Criteria + +- Under umask `0477` and `0277`, a second listener gets `AddrInUse` while the first runs, and a listener starts after a crashed server left the lock file: C, Rust and Go tests that set the umask in a child process or a serialized test. +- Under umask `0477`, an SHM client attaches to a new region and the region file is `0600` (added by the same-failure scan): C, Rust and Go tests. + +## Analysis + +Sources checked: + +- SOW-0066 Validation (macOS umask runs); the three lock implementations listed above. + +Current state: + +- Bug present in C, Rust and Go on Linux, FreeBSD and macOS. + +Risks: + +- Any fix that restores the lock file's mode by path reintroduces a path-based `chmod`, which flawfinder and Semgrep flag (SOW-0066); `fchmodat(..., AT_SYMLINK_NOFOLLOW)` is not supported for files on older Linux kernels. + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- The lock file inherits the umask; later opens need both owner read and write. + +Evidence reviewed: + +- Reproduction above. + +Affected contracts and surfaces: + +- POSIX listener start-up (C, Rust, Go); `docs/level1-posix-uds.md` endpoint lock section. +- POSIX SHM region creation (C, Rust, Go; Linux and FreeBSD); `docs/level1-posix-shm.md` create step (added by the same-failure scan). + +Existing patterns to reuse: + +- The socket bind's umask handling (`fchmod` before bind on Linux and FreeBSD; fd-relative bind on macOS). + +Risk and blast radius: + +- Listener start-up and SHM session set-up only; no per-request path, so no benchmark run (Test Cadence). + +Sensitive data handling plan: + +- No sensitive data involved; evidence is test names and error codes. + +Implementation plan: + +1. C `acquire_endpoint_lock`, Rust `acquire_endpoint_lock`, Go `acquireEndpointLock`: open `O_RDWR|O_CREAT|O_CLOEXEC|O_NOFOLLOW` with mode 0600; on `EACCES` retry `O_WRONLY`, then `O_RDONLY` (both `O_CLOEXEC|O_NOFOLLOW`, no `O_CREAT`), since `flock` works on any open mode; then `fchmod` the descriptor to 0600 (best effort: the lock still works if another account owns the file) before `flock`. +2. Tests: C `test_uds` creates and contends the lock under umask 0477 and 0277, and starts after leftover lock files of mode 0200 and 0400; Rust and Go start after leftover lock files of mode 0200 and 0400 and see the mode restored (no umask change in multithreaded test runners). +3. `docs/level1-posix-uds.md` endpoint lock text states the mode handling. + +Validation plan: + +- Linux, FreeBSD and macOS suites; the new umask tests. + +Artifact impact plan: + +- AGENTS.md: unaffected. +- Runtime project skills: unaffected. +- Specs: `docs/level1-posix-uds.md` endpoint lock section states the lock file mode. +- End-user/operator docs: same file. +- End-user/operator skills: `docs/netipc-integrator-skill.md` if it mentions the lock file. +- SOW lifecycle: open; follows SOW-0050. + +Open-source reference evidence: + +- None; the evidence is this repository. + +Open decisions: + +- Resolved: decision 1 = A (user). + +## Implications And Decisions + +1. Lock file under a restrictive umask (user, 2026-11-04, "11a"): option A, restore the mode with `fchmod` on the lock descriptor after every open and fall back to a narrower open when `O_RDWR` is refused. Rejected: B, path-based `fchmodat` with `AT_SYMLINK_NOFOLLOW` (unsupported for files on older Linux kernels); C, documenting a umask requirement. +2. Assistant decision within A: the fallback goes `O_RDWR`, then `O_WRONLY`, then `O_RDONLY`. The read-only step also opens a leftover 0400 lock (umask 0277), which closes the gap the option description named. `flock` locks the open file description regardless of its access mode on Linux, FreeBSD and macOS. + +## Plan + +The implementation plan in the Pre-Implementation Gate, extended by the same-failure scan to the SHM region file (Execution Log). + +## Execution Log + +### 2026-11-04 + +- Created from the SOW-0066 macOS validation finding. +- Implemented decision 1 = A in C (`netipc_uds_lifecycle.c` `acquire_endpoint_lock`), Rust (`posix.rs` `acquire_endpoint_lock`) and Go (`uds_stale.go` `acquireEndpointLock`): `O_RDWR|O_CREAT`, then `O_WRONLY`, then `O_RDONLY` on `EACCES`, then a best-effort `fchmod(fd, 0600)` before `flock`. +- Tests: C `test_listen_lock_restrictive_umask` (`tests/fixtures/c/test_uds.c`: umask 0477 and 0277 create, contend and restart; leftover lock files 0200 and 0400), Rust `test_listen_over_leftover_lock_modes`, Go `TestListenOverLeftoverLockModes` (leftover 0200 and 0400, mode restored, second server `AddrInUse`). +- Reproduction before the fix, on the macOS host tree that still has the old lock code: C under umask 0477 gives second=2 restart=2 lock mode 0200 and under 0277 lock mode 0400; leftover 0200 and 0400 fail to listen (`NIPC_UDS_ERR_SOCKET`); Rust `listen over a 200 lock: Socket(13)`; Go `lock open: permission denied`. With the fix, Linux gives second=12 (`ADDR_IN_USE`), restart=0, lock mode 0600 in every case. +- `docs/level1-posix-uds.md` "Endpoint lock" states the mode handling. +- Performance assessment: the lock is taken once per `listen` (start-up only); nothing runs per request on an established session, so no benchmark run. +- Same-failure scan at close found the SHM region file has the same defect: the server creates it `0600 & ~umask` and the client attaches `O_RDWR` (`netipc_shm.c:637`). The existing C `test_shm` binary under umask 0477 failed every client attach. It is the same root cause and the same remedy as decision 1, so it is fixed here: the server `fchmod`s the new region's descriptor to 0600 right after creating it (C `netipc_shm.c`, Rust `shm.rs`, Go `shm_posix.go`; it runs once per SHM session set-up, not per request). New C test `test_shm_attach_under_restrictive_umask` fails on the FreeBSD 15.1 tree without the fix ("region file is 0600", "client attaches") and passes with it. Rust and Go SHM test binaries under umask 0477: Rust 21 failures with the old code (FreeBSD tree) against 3 with the fix, Go 10 passing tests against 18. The remaining failures are tests that create their own fixture files under that umask. `docs/level1-posix-shm.md` create step states the `fchmod`. +- Added Rust `test_shm_attach_under_restrictive_umask` and Go `TestShmAttachUnderRestrictiveUmask`. The umask is process-wide, so each test re-runs its own test binary as a child (helpers `shm_umask_helper_process` and `TestShmUmaskHelperProcess`) under umask 0477, which creates a region, checks it is 0600 and attaches a client. With the `fchmod` line commented out, both fail on Linux (Rust child exit status; Go `region file mode 200, want 600`); restored, both pass. +- A Linux and a FreeBSD 15.1 rerun first failed two Rust tests (`*_directory_symlink_*`): `Permission denied` removing target directories that my earlier manual umask 0477 run had left as mode 0300 under `/tmp/nipc_shm_rust_test`. Test environment debris, not a code fault: after restoring owner rwx on those leftovers, the same tests pass. + +## Validation + +Acceptance criteria evidence: + +- C `test_listen_lock_restrictive_umask`: under umask 0477 and 0277 the first listen succeeds, a second gets `NIPC_UDS_ERR_ADDR_IN_USE`, the endpoint restarts and the lock file is 0600; leftover lock files 0200 and 0400 no longer block a listen and come back 0600. Before the fix the same test failed on the macOS host (Execution Log). +- Rust `test_listen_over_leftover_lock_modes` and Go `TestListenOverLeftoverLockModes`: pass with the fix, fail without it (macOS host: `Socket(13)`, `lock open: permission denied`). +- SHM: C `test_shm_attach_under_restrictive_umask`, Rust `test_shm_attach_under_restrictive_umask` and Go `TestShmAttachUnderRestrictiveUmask` fail without the region `fchmod` and pass with it (Execution Log). + +Tests or equivalent validation: + +- Linux, final tree: CTest 32/32, `cargo test` 401 passed, `go test ./...` ok, `cargo fmt --check` on stable and 1.91, `cargo +1.91 test --no-run` builds, flawfinder level 5: 0 hits (lock-fix tree; the SHM change adds an `fchmod` on a descriptor, which flawfinder does not flag). +- macOS host (lock-fix tree): CTest 24/24, `cargo test` 331, `go test` ok, interop uds 18, service 18, service_shm 18, cache 9, cache_shm 9, lookup_scale 36, codec ok. macOS has no SHM transport; the SHM change was checked there by `cargo check --tests --target aarch64-apple-darwin` and `GOOS=darwin go vet ./...` on Linux. +- FreeBSD 15.1 and 14.3, final tree: CTest 31/31, `cargo test` 401, `go test` and `GOARCH=386 go test` ok (the new Go umask test passes on both architectures), interop uds 18, shm 9, service 18, service_shm 18, cache 9, cache_shm 9, lookup_scale 36, codec ok. +- Windows: not affected; the endpoint lock and the SHM region file exist only in the POSIX transports. + +Real-use evidence: + +- The interop suites start real C, Rust and Go servers through the changed lock path on Linux, macOS and FreeBSD, and the SHM interop suites create and attach real regions across the three languages on Linux and FreeBSD. + +Reviewer findings: + +- No separate reviewer: a few lines per language on start-up and session set-up paths whose behaviour the new tests pin down, written against the reviewed SOW-0066 code. The reproductions before the fix and the passing runs after it are the evidence. + +Same-failure scan: + +- Other files NetIPC creates with a fixed mode and reopens later: the socket file (umask handling already specified and tested, SOW-0066), the macOS bind directory (SOW-0066), and the SHM region file, which the client reopens `O_RDWR`. The region file had the same defect and is fixed here (Execution Log). No other file NetIPC creates is opened again by name. + +Sensitive data gate: + +- No secrets, customer data or private endpoints; evidence is test names, modes and error codes. + +Artifact maintenance gate: + +- AGENTS.md: no update; no workflow change. +- Runtime project skills: no update; no new workflow. +- Specs: `docs/level1-posix-uds.md` "Endpoint lock" and `docs/level1-posix-shm.md` create step updated (the public specs are authoritative); no `.agents/sow/specs/` change. +- End-user/operator docs: same files. +- End-user/operator skills: `docs/netipc-integrator-skill.md` describes the lock file's existence and lifetime and the SHM transport's use, none of which changed; no update. +- SOW lifecycle: `Status: completed`, moved to `done/` in this SOW's commit; SOW-0050 remains paused. + +Specs update: + +- `docs/level1-posix-uds.md`, `docs/level1-posix-shm.md`. + +Project skills update: + +- None needed; no reusable workflow changed. + +End-user/operator docs update: + +- `docs/level1-posix-uds.md`, `docs/level1-posix-shm.md`. + +End-user/operator skills update: + +- None affected (see the artifact gate). + +Lessons: + +- See Lessons Extracted. + +Follow-up mapping: + +- None remaining. + +## Outcome + +A POSIX server now starts and reports a running peer as address-in-use under any umask that leaves the creator able to open its own file, and a lock file left by a server that ran under a restrictive umask no longer blocks the endpoint. SHM clients attach to regions created under such a umask. + +## Lessons Extracted + +- A file that is reopened later must not rely on the umask to keep the opener's own access; restore its mode through the descriptor. +- Running existing test binaries under an unusual umask found this; keep restrictive-umask cases in the suites for every file NetIPC creates and reopens. +- Process-wide state such as the umask is tested by re-running the test binary as a child, never by changing it inside a multithreaded test runner. The child restores it before exit, since coverage runtimes write their files then. Manual experiments under such a umask leave debris (directories without owner read) that breaks later runs; restore owner access afterwards. + +## Followup + +None. + +## Regression Log + +None yet. + +Append regression entries here only after this SOW was completed or closed and later testing or use found broken behavior. Use a dated `## Regression - YYYY-MM-DD` heading at the end of the file. Never prepend regression content above the original SOW narrative. + +## Regression - 2026-11-05 + +What broke: + +- Codacy Coverage on `bbc55a2` failed in "Generate coverage reports": `llvm-profdata merge` died with SIGILL. On `d54b22c` that step passed (only the upload failed, decision 5 of SOW-0050). + +Evidence: + +- Reproduced locally with `cargo llvm-cov --no-report --lib -- umask` and `cargo llvm-cov report`: the same SIGILL. The child process of `test_shm_attach_under_restrictive_umask` inherits the coverage environment and wrote its `.profraw` under umask 0477 as mode 0200 (`--w-------`), which the merge cannot read. + +Why validation missed it: + +- The SOW validation ran the correctness suites but not the coverage scripts, which are the only runs that write profile files from the umask child. + +Repair: + +- The Rust child now sets umask 0477 itself and restores the previous value through a drop guard (`UmaskGuard`, also on panic) instead of the parent setting it in `pre_exec`; the Go child restores it with `defer`. Files the process writes at exit keep their normal mode. The C tests already restore the umask right after the call under test. + +Validation: + +- `cargo llvm-cov` on the umask tests: both profile files are mode 0664 and the report merges. +- `tests/run-coverage-rust.sh`: 92.26% lines, threshold 90%, passes. +- `tests/run-coverage-go.sh`: runs; 88.4% against its 90% threshold, the same as `d54b22c` (88.3%, measured from a `git archive` export), so the shortfall predates this SOW. The CI workflows do not run this threshold. +- The Rust test still fails with the region `fchmod` commented out and passes with it. +- Correctness: Linux `cargo test` 401 and Go transport tests ok, `go vet` (also FreeBSD 386), gofmt and rustfmt clean; FreeBSD 15.1 and 14.3 `cargo test` 401, Go transport tests on amd64 and 386 ok. +- Status returned to `completed` and the SOW moved back to `done/` in the repair commit. + +Artifact updates: + +- No spec, doc or skill change: the defect was in test code. Lesson added: a child test that changes process-wide state must restore it before exit, because coverage runtimes write files at exit. + +## Regression - 2026-11-07 + +What broke: + +- Acceptance criterion "Rust and Go lock tests under a restrictive umask" was not met: Rust and Go only tested leftover 0200/0400 lock files (`test_listen_over_leftover_lock_modes`, `TestListenOverLeftoverLockModes`); lock creation under a restrictive umask was tested in C only (reported on netdata/plugin-ipc#18, review comment 4496524331). + +Why validation missed it: + +- The Rust and Go reproductions were written from the macOS failures, which came from leftover files; the umask-creation case was checked against the C test only. + +Repair: + +- Rust `test_listen_lock_under_restrictive_umask` and Go `TestListenLockUnderRestrictiveUmask` run a child test process under umask 0477 and 0277 (the umask is process-wide), as the SHM umask tests do: first listen succeeds, a second server gets address-in-use, the endpoint restarts, and the lock file is 0600. The child restores the umask before exit. + +Validation: + +- Both pass on Linux. With the Rust lock `set_permissions(0600)` removed, the Rust test fails (lock mode assertion), so it detects the defect this SOW fixed. + +Artifact updates: + +- None: tests only. diff --git a/.agents/sow/done/SOW-0070-20261107-pr-review-findings.md b/.agents/sow/done/SOW-0070-20261107-pr-review-findings.md new file mode 100644 index 00000000..409cf667 --- /dev/null +++ b/.agents/sow/done/SOW-0070-20261107-pr-review-findings.md @@ -0,0 +1,383 @@ +# SOW-0070 - PR review findings before the Netdata vendoring + +## Status + +Status: completed + +Sub-state: implemented and validated (2026-11-08), including the regression at the end of this file. SOW-0050 resumes with the re-vendoring and the Netdata PR replies. + +## Requirements + +### Purpose + +Every confirmed defect reported by the automated reviewers on the source PR (netdata/plugin-ipc#18) and the vendoring PR (netdata/netdata#24633) is fixed upstream before the vendoring merges, so Netdata receives a library with zero known bugs. + +### User Request + +The user pointed out that both PRs carry many AI review comments and asked them to be handled. After the triage the user chose (2026-11-07): "1-6: as you recommend, 7.B, 8.B, 9.A" (decisions below). + +### Assistant Understanding + +Facts: + +- About 170 inline comments (cubic-dev-ai, CodeRabbit) plus CodeRabbit review-body nitpicks were triaged against `9568f4e` (library sources equal to the vendored `4261919`), each against code and specs, with C/Rust/Go parity checks. +- About 12 are behavioural bugs, about 30 are valid minor defects (hardening, docs, tests, records), the rest are false positives or duplicates. None is triggered by Netdata's current call patterns. +- Two findings are regressions of completed SOWs and are handled there per AGENTS.md: SOW-0053 (default-CTest helpers still poll with sleeps, `tests/fixtures/c/test_service_limit_helpers.h:199-202,227-236`) and SOW-0067 (Rust and Go lack lock-creation tests under a restrictive umask, an acceptance criterion at SOW-0067:42). + +Inferences: + +- F5 and F6 change the per-request send path, and F19/F20 the receive path, so the affected benchmark rows run once after all fixes (Test Cadence). + +Unknowns: + +- FreeBSD 13 behaviour of `fchmod` on an unbound AF_UNIX socket (decision 7B): out of scope by decision 11 (FreeBSD 13 is end of life). + +### Acceptance Criteria + +- Every finding in the Findings list is fixed with a test where behaviour changes, or answered with evidence as a false positive. +- C, Rust and Go stay interoperable; the affected specs under `docs/` describe the new behaviour. +- Correctness suites pass on Linux, FreeBSD (14.3, 15.1; 13 out of scope per decision 11), macOS and Windows; extended fuzz and stress pass with the fixed `run()` helper; affected benchmark rows show no unaccepted regression. +- Every PR thread gets an individual reply and is resolved (decision 9A). + +## Analysis + +Sources checked: + +- Review comment dumps of both PRs; code at `9568f4e`; `docs/level1-*.md`, `docs/level2-typed-api.md`, `docs/codec*.md`, `docs/netipc-integrator-skill.md`; prior SOWs named below. + +Current state: see Findings. + +Risks: + +- F6 (deadline-aware send) and F7 (listener close order) change shutdown and send semantics in three languages; interop and robustness suites must cover them. +- F19 (MSG_TRUNC on the pending queue) and F6 touch the steady-state path; benchmark evidence required. + +## Findings + +IDs are GitHub review-comment ids (`pi` = netdata/plugin-ipc#18, `nd` = netdata/netdata#24633). + +Service layer: + +- F1 C managed server rejects every connection when `supported_profiles == 0`, the documented default (`netipc_service_posix_server_session.c:305`, `netipc_service_win_server_session.c:234`). Fix: normalize 0 to baseline at init. pi 4496633058, 4496633108. +- F2 C POSIX `nipc_server_run`/`nipc_server_drain` never close the listener, contrary to `netipc_service.h:543-547` and `docs/level2-typed-api.md:628`. nd 4496664519, 4496693511. +- F3 C POSIX run/destroy start race: run publishes `acceptor_started` after its second stop check, so destroy can tear down the mutex under a starting run (`netipc_service_posix_server.c:160-165`). pi 4496523675. +- F4 C Windows run/destroy race: both can close the listener (`netipc_service_win_server.c:150-156,235-240`). nd 4496693533 (the id first recorded, 4496693516, was deleted). +- F5 The receive budget is computed before a send that can block (C `netipc_service_common.c:351-371`, Rust `client_call.rs:122-138`, Go `client.go:302-327`). pi 4496523808, 4496523698, 4496523689. +- F6 The typed-call send is not bounded by the call deadline or abort, contrary to `docs/level2-typed-api.md:276-299`; Level 1 specs define it as blocking (`level1-posix-uds.md:316-318`, `level1-windows-np.md:194-195`). nd 4496664569, 4496664475. +- F7 Go and Rust keep the listening socket open until workers exit; C releases the endpoint lock before workers finish. pi 4496633236, nd 4496693428. +- F8 Go lookups drop the capacity-reconnect error (`apps_lookup.go:88`, `cgroups_lookup.go:159`); C and Rust propagate it. pi 4496523683, nd 4496664546, 4496664500. +- F9 SHM attach boundary: rounding plus a strict `<` blames SHM when the call deadline ended the wait (C `netipc_service_posix_client_connect.c:128-131`, Rust `client_unix.rs:41-45`, `client_windows.rs:33-36`, Go `client_unix.go:115-117`). pi 4496633368, 4496524165. +- F10 Go Windows worker runs uncancellable when `OpenThread` fails (`server_windows.go:262`). nd 4496693441. +- F11 C Windows reads `learned_*_payload_bytes` with plain loads (`netipc_service_win_server_session.c:218-219`). nd 4496664678. +- F12 C POSIX HELLO wait uses `poll_ms` instead of the remaining handshake deadline and ignores EINTR time (`netipc_service_posix_server_session.c:362,395`). pi 4496523940, 4496523948, nd 4496664669. + +Transport: + +- F13 Windows connect: `WaitNamedPipeW` does not reserve the instance; a second `ERROR_PIPE_BUSY` fails the connect (C `netipc_named_pipe.c:1303-1304`, Rust `windows.rs:1543-1548`, Go `pipe_session.go:240-246`; spec `level1-windows-np.md:88-93`). pi 4496633422, 4496633407, 4496523972, nd 4496664610, 4496664553, 4496664506. +- F14 Windows listener after a failed replacement instance: C closes the listener handle without clearing it (`netipc_named_pipe.c:1168-1173`); all three stop accepting. pi 4496523774. +- F15 Windows clients accept an agreed packet size above the proposal (C `netipc_named_pipe.c:924`, Rust `windows.rs:1767`, Go `framing/handshake.go:162-207`). pi 4496633131. +- F16 POSIX advertises and accepts unimplemented profile bits (`netipc_uds_internal.h:45`, Go `shm_posix.go:59`). pi 4496523816. +- F17 A mode-000 lock file left by a crash between create and `fchmod` blocks every start (C `netipc_uds_lifecycle.c:352-358`, Rust `posix.rs:557-587`, Go `uds_stale.go:45-59`). pi 4496633657, 4496633634, 4496523757, nd 4496664484. +- F18 SHM region `fchmod` result ignored (C `netipc_shm.c:503`, Rust `shm.rs:245`, Go `shm_posix.go:151`). pi 4496524069, nd 4496664801. +- F19 Pending queue stores a truncated oversized datagram that the direct path rejects (C `netipc_uds.c:276-280`, Go `uds_session.go:108`, Rust equivalent). pi 4496524110, nd 4496664629. +- F20 A receive that consumed no byte (undersized buffer, timeout or abort between the two waits) marks the session failed (C `netipc_uds_receive.c:479-492`, Rust `posix.rs:480-484`, `posix_stream.rs:50-51`, Go `uds_receive.go:166-181`). pi 4496524158, 4496633567, 4496524150, 4496633357, nd 4496664710, 4496664532. +- F21 Rust clears in-flight ids on a clean timeout or abort (`framing.rs:216-221,289`); C and Go only on disconnect. nd 4496693410. +- F22 HELLO and HELLO_ACK lengths are not checked exactly (C `netipc_uds_handshake.c:168,197,243,269`, Rust `protocol/mod.rs:547,619`, Go `frame.go:582,651`). pi 4496524189, 4496523720, nd 4496664644. +- F23 `accept` does not retry EINTR (C `netipc_uds_lifecycle.c:69-71`, Rust `posix.rs:721`, Go `uds_cloexec_accept4.go:16`). nd 4496664573. +- F24 C connect and listen dereference NULL `config`/`out` (`netipc_uds_lifecycle.c:387,608`, `netipc_uds_handshake.c:130`). pi 4496523797. +- F25 Windows connect opens the pipe before checking abort/deadline (C `netipc_named_pipe.c:1279`, Rust `windows.rs:1514`, Go `pipe_session.go:212`). pi 4496524288. +- F26 Go macOS accept: fork window between accept and close-on-exec (`uds_cloexec_other.go:25`). pi 4496523784, nd 4496664595. +- F27 Empty `run_dir`: Go builds `x.sock` but `/x.lock`; C and Rust build `/x.*` (`uds.go:99`, `uds_stale.go:42`). pi 4496633348, nd 4496664580. +- F28 Go SHM receive returns a zero-length message instead of a protocol error (`shm.go:807`; spec `level1-posix-shm.md:144-146`). nd 4496664481. +- F29 Go handler types do not state that request and builder are valid only during the call (`apps_lookup.go:9`, `cgroups_lookup.go:10`, `cgroups_snapshot.go:9`). nd CodeRabbit nitpick. + +Docs: + +- D1 `netipc_shm.h:2,57` say Linux only; SHM also runs on FreeBSD. nd 4496664784. +- D2 Packet-size comments describe SEQPACKET only; the stream transport uses a fixed 256 KiB (C `netipc_uds.h:69`, Rust `posix.rs:196,225`, Go `uds_session.go:27,39`). nd 4496664791. +- D3 `level1-posix-uds.md:71` and `netipc_uds_lifecycle.c:102` overclaim descriptor-relative steps. pi 4496524021. +- D4 `netipc-integrator-skill.md:1313` packet-size default and cap wording. pi 4496524080. +- D5 `netipc-integrator-skill.md:45` omits that Go runs once per architecture on musl targets. pi 4496524341. +- D6 `level1-transport.md:474` lacks the POSIX `has_pending` cross-reference. pi 4496524316. +- D7 `level1-windows-shm.md:304` omits the live-pipe recovery case. pi 4496633308. +- D8 Lookup-scale interop moves to the extended suites (decision 8B). pi 4496633517. + +Tests, scripts, build: + +- T1 `run()` in `tests/run-extended-fuzz.sh:44-52` (and the asan, tsan, valgrind and go-race scripts) reads `$?` after `!` and always returns 0; rerun extended fuzz. pi 4496633181. +- T2 Stale `GoVersionOverride = "1.26.4"` in three `osv-scanner.toml`. pi 4496523872. +- T3 POSIX CTest registration skips `transport::framing::tests` (`CMakeLists.txt:492`). pi 4496633664. +- T4 Go bench SHM rows silently run baseline where Go SHM is a stub (`bench/drivers/go/main.go:471-488`). pi 4496633389. +- T5 Bench drivers accept a non-positive duration (Go `bench/drivers/go/main.go:508`, C driver). pi 4496524013. +- T6 Rust Windows robustness test calls `ConnectNamedPipe` outside the watchdog (`raw_robustness_windows_tests.rs:420`); check C and Go fixtures. pi 4496633193. +- T7 Rust stream test fixtures assume one read returns a whole HELLO (`posix_tests.rs:99,3337`, `raw_unix_tests.rs:569`). pi 4496633700, 4496633400, nd 4496664698. +- T8 Go `monotime_test.go:25` is timing-sensitive. nd 4496664493. +- T9 Go `shm_more_edge_test.go:487` unreadable-file case fails as root. nd 4496664513. +- T10 Go `uds_more_edge_test.go:1128` uses `info` before checking `err`. nd 4496664767. +- T11 Go `uds_bind_darwin_test.go:24` leaks the listener on early failure. nd 4496664776. + +Records (corrections only where a record is factually wrong; history is not rewritten): + +- R1 SOW-0040:234 Outcome omits the N8 exception (mapped to SOW-0043). pi 4496524039. +- R2 SOW-0046:7 omits the Go-once-per-architecture note. pi 4496524094. +- R3 SOW-0063:88 gate line superseded by decision 6C. pi 4496524126. +- R4 SOW-0060 (pending) duplication figure differs from SOW-0057:140; re-measure at activation. pi 4496633499. +- R5 SOW-0015:1095 Outcome compares a with-tests baseline to a production-only result. pi 4496633534. +- R6 SOW-0037:7 links `current/SOW-0046`, now in `done/`. pi 4496633437. +- R7 SOW-0027:5 records no vendor-parity result. pi 4496633375. +- R8 SOW-0032:201 claim later disproven by SOW-0067; SOW-0032:222 wording. pi 4496633599, 4496633607. +- R9 SOW-0021:1362 closure condition omits the adversarial-matrix gate. pi 4496633243. +- R10 SOW-0069 (pending) plan lacks the Windows host and durable host metadata. pi 4496633213, 4496633223. +- R11 SOW-0065 (pending) facts mix languages. pi 4496633320. +- R12 SOW-0050:68,72,110 candidate commit, gate evidence and open-decision lines are stale. pi 4496633173, 4496633279, 4496633462. + +Regressions handled in their own SOWs: SOW-0053 (pi 4496633289) and SOW-0067 (pi 4496524331). + +FreeBSD 13 (decision 7B): CodeRabbit claims listener creation fails on FreeBSD 13 (nd 4496693466, review summary). + +## Pre-Implementation Gate + +Status: ready + +Problem / root-cause model: + +- Each finding above names its root cause with file:line evidence; the triage read the code, the spec and the other two languages for every comment. + +Evidence reviewed: + +- Code at `9568f4e`; specs listed in Analysis; SOW-0043, SOW-0044, SOW-0051, SOW-0053, SOW-0062, SOW-0064, SOW-0066, SOW-0067 for the designs these fixes touch. + +Affected contracts and surfaces: + +- Level 1 POSIX and Windows transports (send/receive, handshake, connect, accept, listener, lock, SHM region); Level 2 managed server lifecycle and client call deadline in C, Rust and Go; specs `level1-posix-uds.md`, `level1-windows-np.md`, `level1-transport.md`, `level1-windows-shm.md`, `level2-typed-api.md`; `docs/netipc-integrator-skill.md`; test scripts, CTest registration, OSV config, benchmark drivers, `AGENTS.md` and `.github/workflows/macos.yml` (D8). + +Existing patterns to reuse: + +- SOW-0062 progress send (`send_with_progress_loop`, POLLOUT wait) for the POSIX deadline-aware send; SOW-0064 overlapped Named Pipe client handle for the Windows send; the Windows server's interlocked accept-loop state as the model for F3/F4; Rust `ensure_lookup_request_capacity` error handling as the model for F8; the SOW-0067 child-process umask helpers for the lock tests. + +Risk and blast radius: + +- Shutdown ordering (F2, F3, F4, F7) and send deadlines (F6) affect every server and client; covered by the robustness, interop and service suites on every platform. Wire format is unchanged except stricter handshake validation (F15, F16, F22), which rejects only non-conforming peers. + +Sensitive data handling plan: + +- Evidence is file:line, comment ids and test names; no hosts beyond role names, no tokens, no workstation paths in SOWs, specs, docs, skills, instructions or code comments. + +Implementation plan: + +1. Regressions first, each in its own commit: SOW-0053 (event-based readiness in the limit helpers), SOW-0067 (Rust and Go lock-creation umask tests). +2. Test scripts and build: T1 to T5 (rerun extended fuzz after T1). +3. POSIX transport in C, Rust, Go: F16, F17, F18, F19, F20, F21, F22, F23, F24, F26, F27, F28, D1 to D3. +4. Windows transport in C, Rust, Go: F13, F14, F15, F25, D7. +5. Service layer: F1, F2, F3, F4, F7, F8, F9, F10, F11, F12, F29. +6. Deadline-aware send (F5, F6) in all languages and both platforms, with spec revisions. +7. Tests T6 to T11; docs D4 to D6, D8; records R1 to R12. +8. FreeBSD 13 validation: dropped by decision 11 (end of life). +9. Full correctness matrix, extended suites, benchmarks for the send/receive paths. +10. Re-vendor under SOW-0050, push both PRs, reply to and resolve every thread (decision 9A). + +Validation plan: + +- A failing test before each behavioural fix where a deterministic test is possible; CTest, `cargo test`, `go test` on Linux; FreeBSD 14.3/15.1 (13 dropped, decision 11), macOS and Windows suites; all interop scripts; extended fuzz and stress; POSIX and Windows benchmark rows for the send/receive paths compared with `4261919`. + +Artifact impact plan: + +- AGENTS.md: D8 moves lookup-scale to the extended suites and updates the macOS command list. +- Runtime project skills: none expected; re-check at close. +- Specs: the `docs/` files named under Affected contracts. +- End-user/operator docs: `docs/netipc-integrator-skill.md` (D4, D5, and any API-visible change such as F27). +- End-user/operator skills: same file. +- SOW lifecycle: SOW-0050 paused; SOW-0053 and SOW-0067 reopened as regressions; R4, R10, R11 edit pending SOWs. + +Open-source reference evidence: + +- None; all evidence is in this repository. + +Open decisions: + +- Resolved (2026-11-07), see Implications And Decisions. Decision 7B was replaced by decision 11. + +## Implications And Decisions + +User decisions, 2026-11-07 ("1-6: as you recommend, 7.B, 8.B, 9.A"): + +1. A: one SOW fixes every confirmed finding (bugs, hardening, docs, tests, record corrections); completed SOW records are corrected only where factually wrong. +2. A: the typed-call send honours the call deadline and abort in C, Rust and Go on POSIX and Windows; the Level 1 specs are revised. Benchmarks run once after all fixes. +3. A: the managed server closes the listening socket when the accept loop exits and keeps the endpoint lock until workers finish, in all three languages. +4. A: a Windows client keeps retrying `ERROR_PIPE_BUSY` until the busy deadline, in all three languages; `level1-windows-np.md` changes. +5. A: after a failed replacement pipe instance, the Windows server disconnects that client and keeps the instance as its listener, in all three languages. +6. A: an empty `run_dir` is rejected in all three languages. +7. B: test FreeBSD 13 on a FreeBSD 13 host and add a fallback if listener creation fails there. +8. B: lookup-scale interop moves to the extended suites, including on macOS. +9. A: after the fixes land, every PR thread gets an individual reply (fix or false-positive evidence) and is resolved. + +Follow-up decisions, 2026-11-07: + +10. T2 (user: "we need to be aligned with netdata on this"): `GoVersionOverride` follows the Go patch release Netdata ships. Netdata keeps `go 1.27.0` in `src/go/go.mod` and builds with go1.27.3 (netdata/netdata @ 42313ad410, `packaging/check-for-go-toolchain.sh:136-184`); Netdata runs no Go stdlib vulnerability scanner. The three `osv-scanner.toml` files now set `1.27.3` and name that pin as their source; the vendoring preflight skill checks the alignment. Deleting them was considered and rejected: OSV would then scan the 1.27.0 stdlib, which neither CI nor Netdata ships, and `supply-chain-security.yml` fails on any finding. +11. Decision 7B revised to out of scope (user: "freebsd 13.x is EOL since April. We don't need to support that", then "A"). Netdata's `.github/data/distros.yml:517-521` (netdata/netdata @ 42313ad410) still lists `freebsd 13-STABLE` as Community support, its only FreeBSD entry, so the list looks stale; FreeBSD is validated on 14.3 and 15.1. The CodeRabbit thread gets this evidence in its reply. +12. F17 revised after the first CI run on `6b14803` (see Regression - 2026-11-08): flawfinder and Semgrep flag the path-based `chmod` of the lock repair. Options were to annotate the line, to prevent the mode-000 file, or to remove the repair. User chose prevention ("B"): a missing lock is created under a temporary name, made 0600 and hard-linked into place. A file already at mode 000 is no longer repaired. + +## Plan + +See Implementation plan. + +## Execution Log + +### 2026-11-07 + +- Triage of both PRs' review comments; decisions recorded. +- T1 (`run()` exit code in five scripts), T3 (Rust transport CTest filter), T4/T5 (bench driver SHM gate and duration parsing) done. +- F16 (explicit profile mask), F17 (mode-000 lock repair), F18 (checked SHM `fchmod`), F24/F27 (NULL params, empty `run_dir`) done in C, Rust and Go, each with a reproduction test where one is possible (F18 is hardening: no portable way to make `fchmod` fail on an owned file). +- F19: the SEQPACKET pending-queue drain now peeks each datagram's full length (`MSG_PEEK|MSG_TRUNC`, Linux 3.4+) and stores it whole, so a queued oversized datagram reaches the receive path as a direct receive sees it and is rejected the same way. Only the drain runs the extra peek; the drain runs only while a send waits for buffer space. Tests: C `test_pending_oversized_datagram_rejected`, Rust `test_pending_oversized_datagram_rejected`, Go `TestPendingOversizedDatagramRejected`; each failed before the fix (pending receive returned OK). +- F20: a receive buffer smaller than a header is `BAD_PARAM` before any byte is read (both transports). On the stream baseline the receive tracks whether any byte of the message was taken (C `session_read_exact` `consumed`, Rust `read_exact_pending` `consumed`, Go `streamConsumed`); a failure that took none (a timeout or abort firing between the outer and inner waits) no longer marks the session failed. Tests: C `test_receive_undersized_buffer_keeps_session`, Rust `test_receive_undersized_buffer_keeps_session`, Go `TestReceiveUndersizedBufferKeepsSession`; each failed before the fix. The between-waits timing variant (deadline expiring or abort firing after the outer wait succeeded and before the first read) has no deterministic trigger without a wait-injection hook, so the `consumed` tracking is covered by the existing stream receive suites (any regression that loses `consumed` on a taken byte fails the mid-message failure tests) but its no-byte branch has no dedicated test. +- Linux runs after F19/F20: CTest 32/32, `cargo test` 408 passed, `go test ./...` ok; Rust `cargo check --tests --target x86_64-unknown-freebsd` and Go `vet` for freebsd/darwin clean. +- F21: Rust framing clears the client's in-flight ids only when the failed receive means the peer is gone (`FramingError::is_recv_failure`: POSIX `Recv`, Windows `Recv`/`Disconnected`), as C and Go do; a timeout or abort keeps them, so a late response is still accepted. Test: Rust `test_receive_timeout_keeps_inflight` (failed before: late response rejected with an unknown id). +- F22: HELLO must carry exactly 44 payload bytes and HELLO_ACK exactly 48, by both `payload_len` and packet length, in C POSIX, C Windows, Rust (shared framing, POSIX and Windows) and Go (shared framing, POSIX and Windows). The checks run after the layout decode, so a future layout is still reported as incompatible. Tests: C `test_connect_malformed_hello_ack` (two new cases) and `test_accept_malformed_hello_payload` (two new cases), Rust `test_handshake_payload_length_exact`, Go `TestHandshakePayloadLengthExact`; each failed before the fix. C Windows `test_named_pipe.c` gains the two HELLO_ACK cases (run on the Windows host). +- F23: C and Rust retry `accept` on EINTR (C `accept_cloexec`, Rust `accept_pending` through `retry_eintr`). Go needs no change: its runtime installs every signal handler with `SA_RESTART` (go1.27.2 `runtime/os_linux.go:480`, `os_darwin.go:397`, `os_freebsd_amd64.go:15`), so the kernel restarts `accept`. Spec `level1-posix-uds.md` "Interrupted system calls" now covers accept and the Go behaviour. Tests: C `test_eintr_retried` injects EINTR into the server's first `accept4` through a new `--wrap=accept4` (failed before: connect failed); Rust `test_eintr_retried` injects through `eintr_inject::ACCEPT` (the injection exists only on the new retry path, so the C test is the reproduction). +- F26: Go on platforms without `accept4` (macOS) makes the listener non-blocking and runs `accept` plus close-on-exec under `syscall.ForkLock`, waiting for clients with `poll` outside the lock, as Go's `net` package does; the accepted socket is set back to blocking (BSD sockets inherit `O_NONBLOCK`). `TestListenerAndSessionSocketsAreCloexec` also requires a blocking accepted socket. The fork race itself has no deterministic test; the macOS CI run executes this path. +- F28: Go SHM receive (POSIX and Windows) reports a zero-length slot as `ErrShmBadHeader`/`ErrWinShmBadHeader`, as C and Rust do; the POSIX Go client maps it to `ErrTruncated`, as C and the Windows Go client already did, so the caller-visible error is unchanged. Tests: `TestShmChaosForgedLength` asserted the old behaviour and now requires the error (failed before the fix); Windows `TestWinShmReceiveRejectsZeroLength` (run on the Windows host). +- D1 `netipc_shm.h` says Linux and FreeBSD. D2 packet-size comments in C, Rust and Go name both transports' limits. D3 the macOS bind text in `level1-posix-uds.md` and the C, Rust and Go comments now say `fchmodat` and `open` resolve the path (no symlink following, owner check after open) and only later steps are descriptor-relative. +- Linux full run after F19-F28: CTest 32/32, `cargo test` 411 passed, `go test ./...` ok. +- F13 + F25 (decision 4A): Windows connect checks abort and deadline before opening the pipe (F25), and after each successful `WaitNamedPipeW` retries `CreateFileW`, waiting again on `ERROR_PIPE_BUSY` until the busy bound ends (F13), in C, Rust and Go. New test hook (C `nipc_np_set_connect_waited_hook_for_tests`, Rust `CONNECT_WAITED_HOOK`, Go `connectWaitedHook`) runs after a successful wait so a test client takes the freed instance first. Tests: C `test_connect_retries_stolen_instance`, Rust `test_connect_retries_stolen_instance`, Go `TestPipeConnectRetriesStolenInstance` (run on the Windows host). Spec `level1-windows-np.md` busy-connect and bounded-connect paragraphs updated. +- F14 (decision 5A): when creating the next pipe instance fails, accept disconnects the client and keeps its instance as the listener's (C, Rust, Go), so the next accept listens on it again; the service accept loops already retry. New test hook fails replacement creation (C `nipc_np_fail_next_instances_for_tests`, Rust `FAIL_NEXT_INSTANCES`, Go `failNextInstances`). Tests: C `test_accept_survives_failed_instance`, Rust `test_accept_survives_failed_instance`, Go `TestPipeAcceptSurvivesFailedInstance` (Windows host). Spec `level1-windows-np.md` accept paragraph updated. +- F15: Windows clients reject an agreed packet size above their proposal or not above the header, as POSIX clients do. C Windows client handshake; Rust Windows `client_handshake` (the too-small case now reports `Protocol`, as C, Go and Rust POSIX do, instead of `Handshake`); Go moves the check from the POSIX wrapper into the shared `framing.ClientHandshake`, covering both transports. Tests: Go `TestClientRejectsAgreedPacketSizeOutOfRange` (failed before the fix), C Windows `test_client_handshake_rejections` two new cases, Rust `test_connect_rejects_agreed_packet_size_out_of_range` (sharing a new `fake_hello_ack_server` helper with the selected-profile test). Spec `level1-wire-envelope.md` states the client rule for every transport. +- D7: `level1-windows-shm.md` attach paragraph now separates the cases: a broken pipe, call deadline or abort keeps SHM enabled; an attach failure or attach timeout with the pipe connected disables SHM and reconnects baseline-only, as C `netipc_service_win_client_connect.c:159-186` and the Rust and Go clients do. +- Windows host run after F13-F15: CTest found two failures, both fixed. C `test_zero_chunk_budget_rejected` relied on a client accepting a 32-byte agreed packet size, which F15 now rejects; it now connects normally and forges the session's packet size to reach the send guard, as the POSIX test does with a socketpair. Rust `test_connect_retries_stolen_instance` failed under CTest only: Rust runs tests in parallel and the hooks were process-wide, so another test's connect or accept could fire or consume them (`FAIL_NEXT_INSTANCES` was global). The connect hooks now get the pipe name and each test ignores other pipes, the three hook tests share a test lock, and the instance-failure counter is a per-listener test field. Rerun: C `test_named_pipe` 326/0, `test_win_service` 123/0, Rust `transport::windows` 5 runs x 34 passed, Go Windows transport and framing ok. +- F1: C POSIX and Windows managed servers normalize `supported_profiles == 0` (the documented default) to baseline at init; the accept path masks profiles and rejected every client. Rust and Go leave the mask to the transport, which already maps 0 to baseline. Tests: C `test_server_zero_profiles_selects_baseline` (POSIX, failed before the fix) and the Windows counterpart in `test_win_service.c` (passed on the Windows host). +- F2 + F7 (decision 3A): a POSIX listener can stop listening (close the socket, unlink its file) while keeping the endpoint lock (new C `nipc_uds_stop_listening()`, Rust `UdsListener::stop_listening()`, Go `Listener.StopListening()`). Every server's run closes the listening socket when its accept loop exits; the lock is released only after the workers are joined (C drain and destroy release it after joining, Rust drops the listener after `stop_workers`, Go's deferred `Close` runs after `stopSessionWorkers`). C drain previously never closed the listener and C destroy released the lock before joining workers; Rust and Go kept the socket open until workers exited, so a client reconnecting during shutdown waited out its call timeout on a socket nobody accepted. Windows already closed the listening instance first, and its pipe name stays owned while session instances exist. Tests: C `test_server_shutdown_order`, Rust `test_robust_shutdown_closes_socket_before_endpoint`, Go `TestRobustShutdownClosesSocketBeforeEndpoint`; all failed before the fix. Specs `level1-posix-uds.md` (endpoint lock) and `level2-typed-api.md` (shutdown) updated. +- F3 + F4: run and drain/destroy now meet through a store-then-load handshake that needs no lock: run publishes the active loop and then checks the stop; a stop publishes itself and then checks the loop (C POSIX sequentially consistent atomics, C Windows interlocked full barriers). Either the stop waits for the loop, or run sees the stop and returns without touching the session lock (POSIX) or the listener (Windows). The race needs destroy to land between run's checks and its first lock, and its effect is undefined behaviour without a reliable symptom, so it has no deterministic test; the fix holds by construction. Rust and Go own the listener inside run and are not affected. +- F8: Go lookups now return the capacity reconnect's own error when the item fits the client's limit, as C and Rust do; only an item above the client's limit falls back (apps: overflow, cgroups: oversized marker). Test `TestLookupCapacityReconnectFailureReported` (a reconnect to a socket that never answers must report the lookup timeout; a reconnect to a vanished endpoint must report overflow, not a later "not ready"); failed before the fix. +- F9: the SHM attach wait treats a tie between the call's remaining time and the attach timeout as call-bound (`<=`) in C, Rust and Go, POSIX and Windows. All three round the remaining time up, so the tie means the call deadline comes first, and SHM is no longer disabled for it. The boundary depends on sub-millisecond timing, so there is no deterministic test. +- F10: a Go Windows worker whose thread handle cannot be opened is refused before HELLO_ACK, as at capacity, because stop could not cancel its blocking pipe I/O. `openWorkerThread` is a test seam. Test `TestWinRobustWorkerWithoutThreadHandleRefused` (Windows host). +- F11: C Windows reads the learned payload limits with interlocked access in the accept path and in the growth loops. +- F12: the C POSIX HELLO wait is bounded by one monotonic handshake deadline: time lost to EINTR counts, the last poll slice ends at the deadline, and the handshake read that follows gets the remaining time instead of one poll slice. Rust and Go already pass the whole handshake timeout to one stop-aware call. Timing-only effect; no deterministic test. +- F29: the Go raw and typed handler types state that the request and builder are valid only during the call. +- F5 + F6 (decision 2A): a client send now honours the call deadline and abort, and the response wait gets only what the send left of the budget. New Level 1 entry points: C `nipc_uds_send_timeout` / `nipc_np_send_timeout`, Rust `UdsSession::send_with_control` / `NpSession::send_with_control`, Go `Session.SendTimeout` (POSIX and Windows); the old sends call them with no deadline, so server sessions and other callers are unchanged. POSIX: a bounded client send uses the existing non-blocking progress loop (it also drains the peer's bytes, as a pipelined send does), polling with the time left; C polls the abort pipe, Rust and Go recheck the flag at least every 100 ms. Windows: the posted overlapped write is collected without waiting first (`GetOverlappedResult(..., FALSE)`), then waited for with the time left (C waits on the write and abort events, Rust and Go use slices of at most 100 ms); the progress loop checks the same wait. A send that ends on the deadline or abort breaks the session (POSIX shutdown, Windows cancel and close) and fails every in-flight request, since part of the message may be on the wire; Level 2 already disconnects after a timeout or abort. The SHM sends never wait for the peer and are unchanged. The Go session keeps the wait by value so a send allocates nothing. Tests: C `test_client_send_bounded_by_deadline_and_abort` (POSIX `test_service.c`, Windows `test_win_service.c`), Rust `test_robust_send_bounded_by_deadline_and_abort` / `test_win_robust_send_bounded_by_deadline_and_abort`, Go `TestRobustSendBoundedByDeadlineAndAbort` / `TestWinRobustSendBoundedByDeadlineAndAbort`: a server completes the handshake and never reads, the client sends an 8 MiB apps lookup and must return the timeout (200 ms) or, aborted once the server sees the request start, the abort, within 5 s. POSIX mutation checks (send called without deadline and abort): C, Rust and Go each hung until the server let go (10 s) and failed. Specs `level1-posix-uds.md` (Who, Deadline and abort), `level1-windows-np.md` (Who, Deadline and abort, Failure), `level2-typed-api.md` (deadline and abort paragraphs) and `level1-transport.md` (chunked send) updated. +- Windows host after F1-F12 and F5/F6: C `test_named_pipe` 326/0, `test_win_service` 137/0 (both new bounded-send cases pass), Rust `transport::windows` plus Windows robustness 48 passed, Go Windows transport, framing and `TestWinRobust*` ok. Linux: CTest 32/32, `cargo test` 412 passed, `go test ./...` ok. +- Windows mutation checks (each fix reverted on the host, the matching tests run, the file restored and its checksum compared with the local copy): C F6, F13, F14, F15; Rust F6, F13, F14, F15; Go F6, F13, F14, F10. Every test failed with its fix reverted. +- Test defect found by the F15 mutation: both handshake rejection tables in C `test_named_pipe.c` passed `NIPC_NP_OK` as the "alternate" result and the check accepted it, so every row passed even when the handshake succeeded. `handshake_err_matches` now treats `NIPC_NP_OK` as "no alternate"; all 326 checks still pass, so no handshake bug was hidden, and the F15 and F22 rows now fail when their checks are removed. +- T6: every server-side `ConnectNamedPipe` in the Windows robustness fixtures is bounded: Rust runs it inside the 5 s `within` watchdog and asserts it connected; C runs it on a thread with a 5 s wait and exits the test process with a FATAL line on timeout; Go's two helper `wait()` methods select on `testWaitLimit`. A missing client now fails the test instead of hanging the suite. +- T7: Rust stream fixtures that decode the HELLO read until the whole HELLO is in (`posix_tests.rs` `recv_hello`, eight call sites; the raw HELLO-ACK server in `raw_unix_tests.rs`). The C (`test_service.c`, `test_uds.c`) and Go (`more_unix_test.go`) sites only wait for the HELLO to start and never decode it, so a short read cannot change their outcome; no change there. +- T8: Go `monotime_test.go` brackets `After(d)` between two `Now()` reads, so the check no longer depends on scheduling. +- T9: Go `shm_more_edge_test.go` writes the unreadable region file with no header magic, so both outcomes (open refused for non-root, no magic for root) report "invalid, removed", as the Rust twin does; no euid check needed. +- T10: Go `uds_more_edge_test.go` checks `err` before using `info`. T11: Go `uds_bind_darwin_test.go` closes the listener through `t.Cleanup` (Close is idempotent). +- D4: the integrator skill says the stream packet size defaults to 256 KiB and a smaller configured size still applies (`nipc_uds_packet_size`: a configured value below the limit wins). D5: it states that the Go suites run on the glibc targets and armv6 musl, and the other musl targets skip them (`tests/run-linux-target.sh` `run_go`). D6: `level1-transport.md` POSIX wait-object bullet names the `has_pending` check in each language. +- D8 (decision 8B): the macOS workflow and the AGENTS.md macOS commands no longer run `tests/test_lookup_scale_interop.sh`; it already runs under CTest only with `NETIPC_EXTENDED_TESTS=ON`, so `tests/run-extended-stress.sh` (regex `test_lookup_scale.*`) covers it on Linux, FreeBSD and macOS before vendoring. +- Spec notes for F16, F17, F27 in `level1-posix-uds.md`: the profile set a POSIX endpoint offers and accepts, the mode-000 lock repair, and the empty `run_dir` rejection. +- Linux full run after T6-T11, D4-D8 and the spec notes: CTest 32/32, `cargo test` 412 passed, `go test ./...` ok, interop codec 18, UDS 9, SHM 18, service 9 and cache interop pass. +- R1-R12 record corrections, each a dated "Correction (2026-11-07, SOW-0070)" note that keeps the original text: R1 SOW-0040 Outcome names N8 as the exception; R2 SOW-0046 status states Go ran once per architecture and the CI matrix had not run at close; R3 SOW-0063 gate discloses the 6C public Go API change; R4 jscpd 5.3.1 re-run at `7b73adc` on `posix.rs`, `windows.rs`, `framing.rs` (4289 lines) gives 29 clones, 309 lines (7.2%), so SOW-0057's 338 is corrected and SOW-0060's 309 stands; R5 SOW-0015 Outcome is a production-only rebaseline, not a fall; R6 SOW-0037 links `done/SOW-0046`; R7 SOW-0027 records that no vendor-parity result exists and points at SOW-0050's diffs; R8 SOW-0032 same-failure search and Outcome corrected for the umask (SOW-0067); R9 SOW-0021 closure condition includes the adversarial-matrix gaps; R10 SOW-0069 plan adds durable host metadata in both generators and the Windows host A/B and SHM stability analysis; R11 SOW-0065 facts separate Go's stitched-buffer allocation from Rust and C, which stitch into client-owned buffers (Rust `cgroups_lookup.rs:224-229`, C `netipc_service_cgroups_lookup.c:440-452`); R12 SOW-0050 gate names the vendored commits, the preflight evidence and the resolved push decision. +- Windows host full run after T6 and the other changes: CTest 20/20, Rust `cargo test --lib` 280 passed, Go `./...` ok (`service/raw` 37.6 s). +- Extended suites on Linux: `tests/run-extended-fuzz.sh` 15 passed (including Rust proptest, 100000 cases), `tests/run-extended-stress.sh` 7/7. +- FreeBSD 14.3 and 15.1: `cargo test` 411 passed, Go `./...` and `GOARCH=386` ok; CTest failed `test_uds` on two F22 cases ("HELLO_ACK packet with bytes after its payload rejected", "HELLO packet with bytes after its payload rejected"). Test defect, not a library bug: the stream baseline has no packet boundary, so the handshake reads exactly the header plus `payload_len` and the extra bytes stay in the stream as the next packet. The two cases now run only on SEQPACKET (`#if !TEST_UDS_STREAM`); the Rust and Go F22 tests check the parser on whole packets and need no change. Rerun: `test_uds` 303/0 on both FreeBSD hosts, Linux `test_uds*` 4/4. Spec `level1-wire-envelope.md` now states the exact HELLO/HELLO_ACK payload lengths and the per-transport packet rule. +- T2: the three `osv-scanner.toml` files set `GoVersionOverride = "1.27.3"` (decision 10); `project-netdata-vendoring` preflight step 8 checks the `go` directive and the override against Netdata. +- Overlapped Named Pipe I/O passes no byte-count pointer (pi 4496633078): C `overlapped_recv`/`start_overlapped_send`, Rust `overlapped_read`/`start_overlapped_write`, Go `pipe_overlapped.go` read and write now pass NULL, as the Win32 `ReadFile`/`WriteFile` docs require for asynchronous calls; the count already came from `GetOverlappedResult`, so behaviour is unchanged. The write start returned while the write could be pending, leaving the pointer to a dead local. Cross-checks: Rust Windows clippy (only pre-existing lints), Go `GOOS=windows go vet` (only the pre-existing `shm.go` unsafe.Pointer notes). Not yet built or run on the Windows host (busy benchmarking). nd 4496664636 is a duplicate of F18. +- Handoff (2026-11-07, session restart): + - Benchmarks (decision 2A) were started on frozen copies of the tree before the overlapped-I/O change: POSIX `tests/run-posix-bench.sh` into the scratch CSV `abfull-s70-posix.csv`, Windows `tests/run-windows-bench.sh` into a scratch CSV on the Windows host, to compare with the `4261919` runs of 2026-11-05 (`abfull-4261919-{posix,windows}.csv`). Both were children of the session; if the restart killed them, rerun both once (the overlapped change does not affect what they measure: it changes one argument). + - Next: Windows host build and suites for the overlapped change (CTest, `cargo test`, `go test`) after the benchmark; benchmark comparison; replies for all 168 open bot threads (118 on plugin-ipc#18, 50 on netdata#24633; an earlier count of 149 read only the first page of plugin-ipc threads), drafted in the scratch file `thread-replies.json`: 82 fixed, 18 corrected, 3 duplicates of fixed findings, 64 rejected with evidence, 1 out of scope, from the Findings and the triage notes (false positives and duplicates) in the scratch file `triage-notes.md`; fill Validation, Outcome, Lessons, Followup; commit SOW-0070; re-vendor (SOW-0050); ask the user before pushing. +- After the restart: `cargo fmt` rewrapped one call in `transport/posix.rs`; `cargo fmt --check` passes with Rust 1.91.0 and stable, `cargo +1.91.0 check --all-targets` is clean, `cargo clippy --all-targets` reports exactly the warnings of HEAD (no new one), `gofmt -l` is empty for the three Go modules, `go vet` passes for linux, freebsd and darwin. The Windows benchmark survived the restart; the POSIX one did not and was restarted from the same frozen tree. + +## Validation + +Acceptance criteria evidence: + +- Every finding is fixed or answered: F1-F29, D1-D8, T1-T11 and R1-R12 are each recorded in the Execution Log with the change, the test and the spec or record touched; the FreeBSD 13 item is out of scope (decision 11). The 168 open review threads (118 on netdata/plugin-ipc#18, 50 on netdata/netdata#24633) each have an individual reply, posted and resolved on netdata/plugin-ipc#18 at `6b14803`; the 50 on netdata/netdata#24633 wait for the re-vendored code under SOW-0050: 82 fixed, 18 corrected, 3 duplicates of fixed findings, 64 rejected with file or spec evidence, 1 out of scope. +- Interoperability: C/Rust/Go interop suites pass on Linux (codec 18, UDS 9, SHM 18, service 9, cache) and through CTest on FreeBSD and Windows. Specs updated: `level1-posix-uds.md`, `level1-windows-np.md`, `level1-transport.md`, `level1-windows-shm.md`, `level1-wire-envelope.md`, `level2-typed-api.md`. +- Correctness: Linux CTest 32/32, `cargo test` 412, `go test ./...` ok; Windows CTest 20/20, Rust 280, Go ok; FreeBSD 14.3 and 15.1 CTest (after the F22 test fix) with `test_uds` 303/0, `cargo test` 411, Go and Go 386 ok; extended fuzz 15/15 and stress 7/7. macOS: `macOS stream baseline (C, Rust, Go, interop)` passed on `6b14803`. +- Benchmarks (bounded send runs per request on established sessions, so the full suites ran once after all fixes, against the `4261919` runs on the same hosts): + - POSIX: 345 rows, median ratio 1.00; lookup, snapshot-baseline and pipeline rows within 0.87-1.05. The run exits non-zero on 26 floor rows, the same 26 rows the `4261919` run missed; floor rebaselining for this host is tracked by `pending/SOW-0069`. The nine worst rows (0.45-0.74, all SHM or ping-pong, which also reached 1.65 upward) were rerun as six interleaved A/B rounds of both builds: ratios 0.93-1.07, each row's range overlapping the baseline range. No regression. + - Windows: 194 rows comparable, median ratio 1.00, worst 0.82 (within the 0.58-1.42 spread earlier sequential runs showed on unchanged rows). The run exits non-zero because five max-rate `shm-ping-pong` rows with C or Rust servers failed the sample-stability check: their samples are bimodal (about 1.0M at 1 us p50 and 2.3-2.9M at 0.4 us p50; the upper mode matches the baseline), and this SOW changes no per-request Windows SHM code (C and Rust untouched, Go adds one length check on receive; bench driver changes are argument parsing only). Three interleaved A/B rounds of both builds on the six `shm-ping-pong` rows with C or Rust servers: each build lost 4 of 18 row-runs to the same stability check, and median ratios were 0.91-1.08 with overlapping ranges. No regression; the instability belongs to the host and is part of the stability analysis in `pending/SOW-0069`. + +Tests or equivalent validation: + +- A reproduction test that failed before its fix exists for every behavioural finding where a deterministic trigger exists (Execution Log, per finding). Mutation checks on the Windows host and POSIX confirmed the F6, F10, F13, F14 and F15 tests fail with their fix reverted; the F15 mutation exposed and fixed a defect in the C handshake rejection tables. +- No deterministic test, by construction or timing (each recorded with its reason): F3/F4 (undefined behaviour inside a narrow race; fixed by a store-then-load handshake), F9 and F12 (sub-millisecond timing), F18 (`fchmod` cannot be made to fail on an owned file), F20's between-waits branch, F26's fork race (macOS CI runs the path). +- Tooling: `cargo fmt --check` with Rust 1.91.0 and stable, `cargo +1.91.0 check --all-targets`, clippy (no warning beyond HEAD), Rust Windows clippy, `gofmt`, `go vet` for linux, freebsd, darwin and windows. +- Overlapped Named Pipe I/O (NULL byte-count pointer in C, Rust and Go) rebuilt and run on the Windows host with the final tree: CTest 20/20, Rust 280 passed, Go all packages ok (`transport/windows` and `service/raw` included). + +Real-use evidence: + +- The library has no standalone runnable product; its real use is Netdata. The re-vendored Netdata build and its NetIPC consumer tests run under SOW-0050 after this commit. + +Reviewer findings: + +- The findings of the two PRs' automated reviewers are this SOW's input; each is fixed or answered with evidence (Findings, Execution Log, drafted replies). Two triage results were wrong and corrected here: T2 (deleting the OSV overrides would have scanned a stdlib no build ships; decision 10) and the FreeBSD `test_uds` F22 cases (stream transport has no packet boundary). + +Same-failure search: + +- Every finding was checked in the other two languages and on the other platform during triage; parity fixes are recorded per finding. The overlapped byte-count pointer (pi 4496633078) was searched across C, Rust and Go read and write paths and fixed in all six sites. The handshake-table false pass (`NIPC_NP_OK` as alternate) was searched in both C rejection tables. + +Artifact maintenance gate: + +- `AGENTS.md`: macOS command list no longer runs lookup-scale interop; it is an extended suite (D8). +- Runtime project skills: `project-netdata-vendoring` preflight checks the Go `go` directive and the OSV `GoVersionOverride` against Netdata (decision 10). +- Specs: the `docs/` files listed above; no `.agents/sow/specs/` entry, since the public specs carry every rule changed here. +- End-user/operator docs: `docs/netipc-integrator-skill.md` (D4, D5); `docs/level1-transport.md` (D6). +- End-user/operator skills: `docs/netipc-integrator-skill.md` is that skill; updated as above. +- SOW lifecycle: SOW-0053 and SOW-0067 regressions fixed in their own commits (`616bc81`, `d223d9f`); records corrected in SOW-0015, 0021, 0027, 0032, 0037, 0040, 0046, 0050, 0057, 0063, 0065, 0069; this SOW moves to `done/`; SOW-0050 resumes for the re-vendoring. + +SOW status/directory: `completed` in `done/`. + +Spec update: yes, listed above. + +Project skill update: yes, `project-netdata-vendoring`. + +End-user/operator docs update: yes, listed above. + +End-user/operator skill update: yes, `docs/netipc-integrator-skill.md`. + +## Outcome + +Every confirmed defect from the automated reviews of netdata/plugin-ipc#18 and netdata/netdata#24633 is fixed in C, Rust and Go on POSIX and Windows, with reproduction tests where a deterministic trigger exists, and the specs describe the new behaviour. The largest changes: a typed call's send now honours its deadline and abort; managed servers close the listening socket before their workers finish and keep the endpoint lock until they have; Windows connect survives a stolen pipe instance and the listener survives a failed replacement instance; handshakes reject non-conforming lengths, profiles and packet sizes. Correctness suites pass on Linux, Windows, FreeBSD 14.3 and 15.1; macOS is validated by CI after the push. + +## Lessons Extracted + +- A test table whose "alternate" result can equal success passes every row; table-driven rejection tests need an explicit "no alternate" value and a mutation check. +- Handshake length checks that rely on packet length are transport-specific: a stream has no packet boundary, so tests that add trailing bytes belong to message-oriented transports only. +- A scanner override that looks stale is not stale just because its comment says so; check what it is aligned with (here, the Go patch release Netdata ships) before removing it. +- Paginated API dumps must be checked against their total count; the first thread dump read 100 of 119 threads. +- Long benchmark runs must be detached from the agent session (`setsid nohup`), or a session restart kills them. + +## Followup + +- macOS validation runs in CI after the push; a failure reopens this SOW as a regression. +- Re-vendoring, PR replies and thread resolution (decision 9A) continue under SOW-0050, which records the push approval. +- Netdata's `.github/data/distros.yml` still lists FreeBSD 13-STABLE; this is a Netdata record, not a plugin-ipc item. It is stated in the CodeRabbit reply and left to Netdata maintainers. +- R4: SOW-0060's 309-line figure is confirmed by the re-measurement; no further work. +- No other deferred item: the pre-close search for defer, later, follow-up, future, TODO and pending finds only finding descriptions, pending-SOW names and the pending queue. + +## Regression Log + +## Regression - 2026-11-08 + +What broke: + +- `C Static Analysis` (flawfinder level 5, CWE-362) and `Codacy Local Analysis` (Semgrep `c_race_rule-chmod`, code-scanning alert 7768) failed on `6b14803`, both on the `chmod` that F17 added at `netipc_uds_lifecycle.c:376`. They passed on `9568f4e`. +- A new automated review of `6b14803` opened 34 more threads, some on code this SOW added: a bounded send of an untracked message with nothing in flight divided by zero in `pending_bound` (N3 C, N14 Go). + +Evidence: + +- CI run ids 42460571771 (flawfinder) and 42460571682 (Codacy). Go `TestBoundedControlSendIdle` panics with "integer divide by zero" with the fix reverted. +- `C, Rust, and Go Coverage` also fails, on both `9568f4e` and `6b14803`, at the Codacy upload: "No Write permission on Project". It is a repository token problem, not this code; recorded for SOW-0050. + +Why validation missed it: + +- flawfinder and Semgrep run only in CI and were not run locally before the push; flawfinder is available locally and now runs before a push. +- The bounded-send tests only sent tracked requests, so `inflight_count` was never zero there. + +Repair: + +- F17 (decision 12): `publish_endpoint_lock` in C, Rust and Go creates `{service}.lock.{pid}.{seq}.tmp`, `fchmod`s it to 0600, hard-links it to the lock name and unlinks the temporary name. Any failure falls through to the previous creating open, so a filesystem without hard links keeps working. `level1-posix-uds.md` updated. Tests check through a publish hook, under umasks 0477, 0277 and 0777, that the file is 0600 before the lock name exists and that the temporary name is gone. +- Fixed from the new review (24 threads): N1 (above), N3/N14 zero in-flight bound in C, Go and Rust with tests (the C division is undefined behaviour that GCC folds at this build's optimisation level, so only the Go test is a reproduction), N8 lookup-scale interop on macOS through `workflow_dispatch` plus `AGENTS.md` (the full extended suite needs SHM, which macOS lacks), N10 and N27 this SOW's record, N11/N16 fixtures prove the request arrived, N15/N18 bench duration caps, N17 Go `StopListening` serialized (no deterministic test: proving a call stays blocked needs a timing wait), N20, N23 and N34 SOW records, N21, N33 and N35 docs, N22 truncated HELLO in a Rust fixture, N24/N26/N28 unassigned-bit tests use `0x10`, N25/N31 C server duration 0 restored, N32 comment. +- Rejected with evidence (11 threads, each answered in its thread): N2, N4, N5, N6, N7, N9, N12, N13, N19, N29, N30. + +Validation: + +- Linux CTest 32/32, `cargo test` 413, Go ok; `cargo fmt --check` on 1.91.0 and stable, `cargo +1.91.0 check --all-targets`, `go vet` for linux, freebsd, darwin and windows, flawfinder level 5: 0 hits. +- Windows CTest 20/20, Rust 280, Go ok. FreeBSD 14.3 and 15.1: CTest 31/31, `cargo test` 412, Go and Go 386 ok. macOS: CI on the push. +- Performance: no benchmark rerun. Lock creation runs once per listen, and `pending_bound` only changes its result when nothing is in flight, which no benchmark row does (every row sends tracked requests). + +Artifacts: + +- Specs: `level1-posix-uds.md` (lock creation, FreeBSD Go SHM architectures). Docs: `netipc-integrator-skill.md` (x86_64 coverage). `AGENTS.md`: macOS extended command. Project skills: `project-netdata-vendoring` unaffected. SOW records: SOW-0050, SOW-0053, SOW-0065. +- A review of `3fb1bef` added six comments, all fixed here: the Go stalled-reader poll retries `EINTR` and requires `POLLIN`, the macOS manual step runs only lookup-scale interop, and three bench comments and usage lines were corrected. Its three CodeQL alerts (`cpp/path-injection` on the caller-supplied `run_dir`, `cpp/stack-address-escape` on two test hooks) are false positives of classes already triaged on this repository (alerts 7633, 7641). +- Lesson: run the locally available static analyzers before pushing, and wait for CI and the automated reviewer before posting replies that describe the pushed code. diff --git a/.agents/sow/pending/SOW-0032-20260712-uds-permission-contract.md b/.agents/sow/pending/SOW-0032-20260712-uds-permission-contract.md deleted file mode 100644 index 5a5d800c..00000000 --- a/.agents/sow/pending/SOW-0032-20260712-uds-permission-contract.md +++ /dev/null @@ -1,178 +0,0 @@ -# SOW-0032 - Align POSIX UDS permission handling across languages - -## Status - -Status: open - -Sub-state: pending investigation after SOW-0030; no implementation started. - -## Requirements - -### Purpose - -Define and enforce one secure, portable POSIX Unix-domain-socket permission contract for -C, Rust, and Go without process-global permission side effects in embedding programs. - -### User Request - -The user delegated technical choices to the assistant with an explicit requirement to -choose the long-term-best design without shortcuts. SOW-0030 scanner triage discovered -this separate cross-language security and embedding-safety concern. - -### Assistant Understanding - -Facts: - -- C currently changes the process-global `umask()` around `bind()` under a NetIPC-local - mutex to create the socket as owner-only. -- A NetIPC-local mutex cannot serialize unrelated threads in the embedding process that - create files while the temporary mask is active. -- Rust and Go bind their pathname sockets without the same explicit owner-only creation - mechanism, so final modes depend on the embedding process mask. -- The documented transport contract expects a caller-controlled private runtime - directory. - -Inferences: - -- Directory ownership and permissions are the portable primary security boundary for - pathname sockets. Exact socket mode may remain useful as defense in depth, but it must - not silently mutate process-global state in an embeddable library. - -Unknowns: - -- The portable mechanism and compatibility policy that should replace or constrain the - current language-specific behavior require focused research and cross-platform tests. - -### Acceptance Criteria - -- One explicit UDS runtime-directory and socket-mode contract is documented and applied - consistently to C, Rust, and Go. -- The implementation has no uncontrolled process-global permission window and includes - concurrency tests that exercise unrelated file creation during listener setup. -- Existing cross-language UDS interoperability and stale-endpoint recovery remain green. - -## Analysis - -Sources checked: - -- `src/libnetdata/netipc/src/transport/posix/netipc_uds_lifecycle.c:16-44,212` -- `src/crates/netipc/src/transport/posix.rs:637-679,995-1019` -- `src/go/pkg/netipc/transport/posix/uds_listener.go:23-60` -- `docs/level1-transport.md:559-561` -- GitHub code-scanning alert 7750 and its historical/fixed commits, recorded in SOW-0030. - -Current state: - -- The historical post-bind `chmod(path)` race is fixed. The remaining issue is the C - process-global `umask()` window and inconsistent C/Rust/Go permission behavior. - -Risks: - -- An unrelated thread can create a file with unexpectedly restrictive permissions while - C temporarily changes the process mask, causing availability or operational failures. -- Changing modes or rejecting unsafe directories can break existing integrations unless - compatibility and migration behavior are designed explicitly. - -## Pre-Implementation Gate - -Status: blocked - -Problem / root-cause model: - -- POSIX `umask()` is process-global, but the C library uses it inside a local mutex that - cannot protect unrelated embedding threads. Rust and Go rely on ambient process state, - so the three language implementations do not expose one stable security contract. - -Evidence reviewed: - -- Initial evidence is listed under Analysis. Full POSIX/Linux/macOS/FreeBSD behavior, - project patterns, and mature open-source approaches remain to be researched before - implementation. - -Affected contracts and surfaces: - -- POSIX UDS listener creation in C, Rust, and Go; runtime-directory requirements; - embedding-process behavior; UDS tests; public transport docs and integrator guidance. - -Existing patterns to reuse: - -- Safe directory-relative operations already used by POSIX SHM and stale UDS cleanup; - the existing private-runtime-directory contract; cross-language interop fixtures. - -Risk and blast radius: - -- Cross-language behavioral and source compatibility, endpoint availability, filesystem - permissions, macOS/FreeBSD portability, and embedding-process concurrency. - -Sensitive data handling plan: - -- Use synthetic paths and service names only. No credentials, customer data, private - endpoints, personal data, or production logs are required in durable artifacts. - -Implementation plan: - -1. Research portable directory and socket permission primitives and existing project or - mature open-source patterns across Linux, macOS, and FreeBSD. -2. Present any irreducible public-contract choices before implementation. -3. Implement one C/Rust/Go contract with concurrency, permission, stale-path, and interop - coverage, then update public docs and integrator guidance. - -Validation plan: - -- Deterministic concurrent unrelated-file creation tests; mode and ownership assertions; - C/Rust/Go interop; stale endpoint recovery; Linux, macOS, FreeBSD, and sanitizer runs. - -Artifact impact plan: - -- AGENTS.md: likely unaffected unless a reusable security workflow is discovered. -- Runtime project skills: likely unaffected unless a reusable validation workflow is - discovered. -- Specs: POSIX UDS/transport contract likely updated. -- End-user/operator docs: integrator/runtime-directory guidance likely updated. -- End-user/operator skills: `docs/netipc-integrator-skill.md` likely updated. -- SOW lifecycle: remains pending until SOW-0030 completes; must be activated separately. - -Open-source reference evidence: - -- None yet; research is intentionally deferred to this dedicated SOW. - -Open decisions: - -- Exact portable permission mechanism and compatibility/migration behavior require the - pre-implementation investigation. - -## Implications And Decisions - -- Decision: track this independently from SOW-0030 because it affects UDS security and - embedding semantics, not the Linux SHM allocation crash. - -## Plan - -1. Complete the pre-implementation investigation and contract comparison. -2. Obtain any irreducible user decisions, then implement and validate independently. - -## Execution Log - -### 2026-07-12 - -- Created from SOW-0030 scanner triage. No implementation started. - -## Validation - -Pending; this SOW is open and blocked on its required pre-implementation investigation. - -## Outcome - -Pending. - -## Lessons Extracted - -Pending. - -## Followup - -None yet. - -## Regression Log - -None yet. diff --git a/.agents/sow/pending/SOW-0035-20260728-rust-2024-style-migration.md b/.agents/sow/pending/SOW-0035-20260728-rust-2024-style-migration.md deleted file mode 100644 index fd147d0b..00000000 --- a/.agents/sow/pending/SOW-0035-20260728-rust-2024-style-migration.md +++ /dev/null @@ -1,246 +0,0 @@ -# SOW-0035 - Coordinated Rust 2024 Style Migration - -## Status - -Status: open - -Sub-state: Tracked follow-up to SOW-0034; no implementation may begin while SOW-0034 is active. - -## Requirements - -### Purpose - -Migrate plugin-ipc and Netdata's corresponding vendored NetIPC Rust source to -the Rust 2024 style edition without mixing formatting churn into the semantic -edition migration. - -### User Request - -When rustfmt 1.97.1 exposed a repository-wide style migration during SOW-0034, -the user selected a focused semantic migration first and a separately tracked, -coordinated formatting migration afterward. - -### Assistant Understanding - -Facts: - -- Rust language and style editions can be configured independently. -- With language edition 2024 and no style override, rustfmt 1.97.1 proposes 98 - formatting hunks across 40 standalone files. -- Thirty-six matching Netdata vendored source files also fail the same - rustfmt-2024 check. -- SOW-0034 preserves style edition 2021 explicitly to avoid obscuring semantic - review and creating unplanned vendor drift. - -Inferences: - -- A coordinated formatting-only change can be reviewed and validated more - reliably than a mixed semantic-and-formatting diff. -- Netdata vendoring preflight is mandatory because this follow-up intentionally - changes vendored source formatting. - -Unknowns: - -- The source and Netdata baseline commits at the time this SOW is activated. -- Whether later rustfmt releases or intervening source changes alter the exact - formatting diff. - -### Acceptance Criteria - -- Plugin-ipc selects Rust 2024 style explicitly and all Rust targets pass - formatting checks. -- The formatting diff is behavior-neutral and separated from semantic changes. -- Netdata vendoring preflight passes and its vendored NetIPC source uses the - same formatting without overwriting Netdata-only files. -- Plugin-ipc and Netdata validation pass on their supported POSIX and Windows - paths. - -## Analysis - -Sources checked: - -- `src/crates/netipc/Cargo.toml` -- Rustfmt 1.97.1 formatting diagnostics from SOW-0034 -- `.agents/skills/project-netdata-vendoring/SKILL.md` -- Netdata's vendored `src/crates/netipc/` source - -Current state: - -- SOW-0034 is the only executing SOW and explicitly preserves style edition - 2021. -- This formatting migration is intentionally queued until the semantic Rust - 2024 migration completes. - -Risks: - -- A large formatting diff can hide accidental semantic edits. -- Updating plugin-ipc alone would create avoidable downstream vendor drift. -- Netdata CI/toolchain changes between SOWs may change the expected formatting. - -## Pre-Implementation Gate - -Status: blocked - -Problem / root-cause model: - -- Rustfmt infers style edition 2024 from language edition 2024. That is a - distinct, broad formatting migration affecting standalone and vendored - source, so it must be coordinated separately from SOW-0034. - -Evidence reviewed: - -- SOW-0034 recorded 98 proposed hunks across 40 standalone files. -- A read-only Netdata check recorded 36 vendored source files with proposed - Rust 2024 formatting. - -Affected contracts and surfaces: - -- Rust source formatting, contributor workflow, formatting CI, vendor parity, - and Netdata's vendored NetIPC tree. - -Existing patterns to reuse: - -- Project-local low-priority validation commands. -- The mandatory Netdata vendoring preflight and normalized vendor-diff scripts. - -Risk and blast radius: - -- Medium review blast radius across most Rust source files; intended runtime - behavior impact is none. - -Sensitive data handling plan: - -- Only public source, formatting output, public repository history, and - sanitized validation summaries are required. Durable artifacts will contain - no secrets, credentials, customer data, personal data, or private endpoints. - -Implementation plan: - -1. Reconstruct the current source-to-Netdata baseline and complete the mandatory - CI/scanner and two-way vendoring preflight. -2. Update the style-edition configuration and apply rustfmt 2024 mechanically. -3. Prove the diff contains no semantic changes, synchronize the approved - formatting into Netdata, and validate both repositories. - -Validation plan: - -- Rustfmt 2024 checks in both repositories. -- Token/AST-aware or equivalent review proving formatting-only changes. -- POSIX and native Windows Rust builds/tests. -- Normalized pre/post vendor diff and project SOW audits. - -Artifact impact plan: - -- AGENTS.md: inspect formatting and vendoring commands. -- Runtime project skills: update vendoring guidance only if reusable workflow - knowledge changes. -- Specs: no product behavior change expected; record evidence at close. -- End-user/operator docs: no runtime contract change expected; inspect build - guidance. -- End-user/operator skills: inspect only if formatting commands are exposed. -- SOW lifecycle: remain pending until SOW-0034 completes; complete independently. - -Open-source reference evidence: - -- Netdata baseline commit and relative paths must be refreshed when this SOW is - activated. - -Open decisions: - -- Blocked until SOW-0034 completes and current baseline/CI/scanner evidence is - reconstructed. - -## Implications And Decisions - -1. **Ordering - selected by the user: semantic migration before formatting.** - - Keep SOW-0034 focused and coordinate style changes in this SOW afterward. - -## Plan - -1. Wait for SOW-0034 to complete. -2. Refresh the pre-implementation and Netdata vendoring gates before any edit. -3. Apply, review, validate, and commit the formatting migration independently. - -## Execution Log - -### 2026-07-28 - -- Created as the real tracked follow-up required by the user's SOW-0034 - formatting-scope decision. -- No implementation started. - -## Validation - -Acceptance criteria evidence: - -- Pending activation. - -Tests or equivalent validation: - -- Preliminary read-only rustfmt evidence is recorded above. - -Real-use evidence: - -- Pending activation. - -Reviewer findings: - -- Pending activation. - -Same-failure scan: - -- Pending activation. - -Sensitive data gate: - -- This pending SOW contains only public source paths and sanitized formatting - summaries. - -Artifact maintenance gate: - -- AGENTS.md: pending activation. -- Runtime project skills: pending activation. -- Specs: pending activation. -- End-user/operator docs: pending activation. -- End-user/operator skills: pending activation. -- SOW lifecycle: correctly `open` under `pending/`. - -Specs update: - -- Pending activation. - -Project skills update: - -- Pending activation. - -End-user/operator docs update: - -- Pending activation. - -End-user/operator skills update: - -- Pending activation. - -Lessons: - -- Pending implementation. - -Follow-up mapping: - -- Tracked by this SOW. - -## Outcome - -Pending. - -## Lessons Extracted - -Pending. - -## Followup - -None yet. - -## Regression Log - -None yet. diff --git a/.agents/sow/pending/SOW-0060-20261031-duplication-reduction-next-pair.md b/.agents/sow/pending/SOW-0060-20261031-duplication-reduction-next-pair.md new file mode 100644 index 00000000..15afe9cd --- /dev/null +++ b/.agents/sow/pending/SOW-0060-20261031-duplication-reduction-next-pair.md @@ -0,0 +1,94 @@ +# SOW-0060 - Production duplication reduction, next file pair + +## Status + +Status: open + +`completed` is the successful terminal status. `done` is a directory name, not a status value. Do not use `Status: done` or `Status: complete`. + +Sub-state: waiting; picks up after the pre-vendoring benchmarks and the SOW-0050 vendoring (user ordering 2026-10-31). + +## Requirements + +### Purpose + +Bring Codacy's duplicated-files share on `main` toward the 10% goal, one file pair per SOW (SOW-0057 user decision 1). + +### User Request + +SOW-0057 decision 1 (2026-10-31, user): the Rust transport pair first; each further pair gets its own SOW, picked by re-measuring. + +### Assistant Understanding + +Facts: + +- SOW-0057 moved the Rust transport framing into `transport/framing.rs`. jscpd on `posix.rs`, `windows.rs` and `framing.rs` (`--min-tokens 50`) fell from 587 duplicated lines (12.8%) to 309 (7.2%); the rest is per-transport config types, session accessors, handshake wrappers and test fixtures. +- Other large contributors measured before SOW-0057 (`jscpd --min-tokens 50` over `src/` without tests, duplicated lines per file): the C apps/cgroups lookup codecs (`netipc_protocol_apps_lookup.c` 417, `netipc_protocol_cgroups_lookup.c` 339), the Rust lookup codecs and services (`service/apps_lookup.rs` 428, `service/cgroups_lookup.rs` 314, `protocol/lookup/cgroups_lookup.rs` 299), the Go lookup codecs (`protocol/apps_lookup.go` 286, `protocol/cgroups_lookup.go` 214), and the C Windows transports (`netipc_named_pipe.c` 346, `netipc_win_shm.c` 326). + +Inferences: + +- The lookup codecs exist in all three languages, so a shared-helper refactor there repeats per language; that's one candidate pair per language. + +Unknowns: + +- Which pair the re-measurement ranks first, and whether it touches the per-request path (decides the benchmark need under Test Cadence). + +### Acceptance Criteria + +- The chosen pair loses its duplicated blocks with no behavior change (existing suites, interop). +- Codacy's duplicated-files percentage drops, measured after push. + +## Analysis + +Sources checked: + +- SOW-0057 Analysis and Validation. + +Current state: + +- Not started. First step: re-run jscpd over `src/` without tests, then pick the pair with the user. + +Risks: + +- Lookup codec refactors touch the wire decode path in each language; cross-language interop suites must cover them. + +## Pre-Implementation Gate + +Status: blocked + +Not filled: the target pair is chosen by re-measuring when this SOW starts. + +## Implications And Decisions + +None yet. + +## Plan + +1. Re-measure, present the ranked pairs to the user, record the choice. +2. Fill the gate, implement, validate. + +## Execution Log + +### 2026-10-31 + +Created at SOW-0057 close as its follow-up. + +## Validation + +Pending. + +## Outcome + +Pending. + +## Lessons Extracted + +Pending. + +## Followup + +None yet. + +## Regression Log + +None yet. diff --git a/.agents/sow/pending/SOW-0065-20261103-lookup-client-reusable-stitch-storage.md b/.agents/sow/pending/SOW-0065-20261103-lookup-client-reusable-stitch-storage.md new file mode 100644 index 00000000..175ea03c --- /dev/null +++ b/.agents/sow/pending/SOW-0065-20261103-lookup-client-reusable-stitch-storage.md @@ -0,0 +1,145 @@ +# SOW-0065 - Lookup clients stitch into reusable client storage + +## Status + +Status: open + +Sub-state: created from SOW-0063; not analysed beyond the evidence below; needs a design decision before implementation. + +## Requirements + +### Purpose + +Typed lookup client calls on an established session should not allocate per item, matching the Level 2 rule that variable-size responses borrow reusable client storage. + +### User Request + +Found during SOW-0063 (Go per-request allocations): the Go lookup clients allocate several buffers per call. The user approved tracking it in its own SOW rather than redesigning it inside SOW-0063. + +### Assistant Understanding + +Facts: + +- `docs/level2-typed-api.md:192-193`: "To keep the hot path allocation-free where the typed message shape allows it, Level 2 owns and reuses its internal wire buffers"; `:443-444`: "Variable-size structured responses should return ephemeral typed views that borrow internal reusable client storage". +- Every CGROUPS_LOOKUP and APPS_LOOKUP client call copies each accepted response item into its own heap buffer, even when one subresponse answered every item. The stitched response differs by language (corrected 2026-11-07, SOW-0070): + - Go also encodes the stitched response into a new buffer: `service/raw/cgroups_lookup.go:264-265`; `service/raw/apps_lookup.go:184-185`; item clone `service/raw/lookup_common.go:173` (escape analysis: `go build -gcflags=-m`); + - Rust encodes the stitched response into the client's reusable `transport_buf` (`service/raw/cgroups_lookup.rs:224-229`); its per-call allocations are the item copies (`cgroups_lookup.rs:123`, `:190`; `apps_lookup.rs:85`, `:143`) and the temporary slice list (`cgroups_lookup.rs:225`); + - C encodes the stitched response into the client's reusable `response_buf` (`netipc_service_cgroups_lookup.c:440-452`); its per-call allocations are the item copies (`netipc_service_cgroups_lookup.c:237`, `:392`; `netipc_service_apps_lookup.c:295`) and, for an oversized item only, a one-item marker response (`netipc_service_cgroups_lookup.c:208`). +- The returned view's documented lifetime is already "until the next typed call on this client" in all three languages, so client-owned storage fits the contract. + +Inferences: + +- Reusing client-owned item and stitch buffers (and returning the single subresponse view directly when no split happened) removes the per-call allocations without a public API change. Speculation until analysed: the split/retry/stitch code may need restructuring in all three languages. + +Unknowns: + +- Whether the oversized-item path (`cgroups_lookup.c:208`) can use scratch storage too, or stays a rare-path allocation. + +### Acceptance Criteria + +- An allocation test per language fails before the change and passes after, on single-subresponse and split lookups (Go `testing.AllocsPerRun`; Rust and C equivalents to be designed). +- Lookup interop and lookup-scale suites pass on every platform that runs them. +- The lookup benchmark rows show no regression. + +## Analysis + +Sources checked: + +- The files and lines listed under Facts (SOW-0063 investigation, 2026-11-03). + +Current state: + +- Lookups allocate per item in C, Rust and Go clients; servers do not (SOW-0063 fixed the Go servers). + +Risks: + +- Split/retry/stitch is a cross-language contract (`docs/codec-cgroups-lookup.md:119-122`, `docs/codec-apps-lookup.md:135-138`); a reuse bug would corrupt stitched results across calls. + +## Pre-Implementation Gate + +Status: needs-user-decision + +Problem / root-cause model: + +- See Facts. The stitch code collects owned item copies because the subresponse buffer is reused by the next subcall. + +Evidence reviewed: + +- See Facts. + +Affected contracts and surfaces: + +- C, Rust and Go lookup clients; Level 2 spec wording on lookup ownership if it changes. + +Existing patterns to reuse: + +- Client scratch buffers (`ensureClientScratch` in Go, `request_scratch` in Rust); the SOW-0063 Go allocation tests (`service/raw/alloc_calls_test.go`). + +Risk and blast radius: + +- Lookup correctness across subcalls; all three languages. + +Sensitive data handling plan: + +- Evidence is code paths and test output only; no sensitive data is involved. + +Implementation plan: + +1. To be written after analysis and the user's design decision. + +Validation plan: + +- Allocation tests, lookup unit and interop suites, lookup-scale interop, the lookup benchmark rows. + +Artifact impact plan: + +- AGENTS.md: likely unaffected. +- Runtime project skills: likely unaffected. +- Specs: `docs/level2-typed-api.md` only if the ownership wording changes. +- End-user/operator docs: likely unaffected (no API change intended). +- End-user/operator skills: `docs/netipc-integrator-skill.md` only if lookup lifetimes change. +- SOW lifecycle: open in pending; created from SOW-0063. + +Open-source reference evidence: + +- None; this is project code only. + +Open decisions: + +- Design of the reusable stitch storage, to be presented with options after analysis. + +## Implications And Decisions + +None yet. + +## Plan + +Pending analysis. + +## Execution Log + +### 2026-11-03 + +Created from SOW-0063. + +## Validation + +Pending. + +## Outcome + +Pending. + +## Lessons Extracted + +Pending. + +## Followup + +None yet. + +## Regression Log + +None yet. + +Append regression entries here only after this SOW was completed or closed and later testing or use found broken behavior. Use a dated `## Regression - YYYY-MM-DD` heading at the end of the file. Never prepend regression content above the original SOW narrative. diff --git a/.agents/sow/pending/SOW-0068-20261105-go-coverage-threshold.md b/.agents/sow/pending/SOW-0068-20261105-go-coverage-threshold.md new file mode 100644 index 00000000..fcd923cc --- /dev/null +++ b/.agents/sow/pending/SOW-0068-20261105-go-coverage-threshold.md @@ -0,0 +1,147 @@ +# SOW-0068 - Restore Go Coverage To Its Threshold + +## Status + +Status: open + +`completed` is the successful terminal status. `done` is a directory name, not a status value. Do not use `Status: done` or `Status: complete`. + +Sub-state: queued after the SOW-0050 Netdata vendoring (user decision, 2026-11-05). + +## Requirements + +### Purpose + +The POSIX Go coverage measured by `tests/run-coverage-go.sh` meets its 90% threshold again, through tests of real behaviour, not by lowering the threshold. + +### User Request + +Found during SOW-0050/SOW-0067 CI triage (2026-11-05). The user chose option A ("2a"): open a pending SOW to find the uncovered code and restore 90%, after this vendoring. Lowering the threshold was rejected. + +### Assistant Understanding + +Facts: + +- `tests/run-coverage-go.sh` (threshold 90, `tests/run-coverage-go.sh:170`) reports 88.4% total on `292c6dc` and 88.3% on `d54b22c` (measured from a `git archive` export), so the shortfall predates SOW-0066 and SOW-0067. +- Per package on `292c6dc` (Linux): `protocol` 88.3%, `transport/posix` 84.9%, `service/raw` 90.3%; `service/internal/transportconfig`, `service/apps_lookup`, `service/cgroups_lookup` and `service/cgroups_snapshot` 100%. +- The last recorded pass is 90.0% in SOW-0004 (`.agents/sow/done/SOW-0004-20260525-cgroups-apps-lookup-methods.md:2079`). +- No GitHub workflow runs this threshold; Codacy Coverage uploads the Go profile without a gate (`tests/generate-codacy-coverage.sh`). + +Inferences: + +- SOWs since SOW-0004 added Go code (transport, SHM and lookup work) faster than tests covering its error branches. + +Unknowns: + +- Which functions and branches carry the gap; resolved by `go tool cover -func` at activation. + +### Acceptance Criteria + +- `tests/run-coverage-go.sh` reports at least 90% on Linux. +- Every added test asserts behaviour (an error code, a state, a wire result); none exists only to execute lines. +- Unreachable code found by the analysis is removed rather than covered, with evidence, and the same branch is checked in C and Rust. + +## Analysis + +Sources checked: + +- `.tmp` coverage logs of 2026-11-05 (not durable); the numbers above. + +Current state: + +- Below threshold by about 1.6 points. + +Risks: + +- Tests written only to cover lines add maintenance without value; branches reachable only through OS faults need the existing fault-injection hooks rather than new production switches. + +## Pre-Implementation Gate + +Status: blocked + +Problem / root-cause model: + +- Blocked until SOW-0050 completes (user ordering). The gate is filled at activation from `go tool cover -func` output. + +Evidence reviewed: + +- To be gathered at activation: the per-function coverage report for `protocol` and `transport/posix`. + +Affected contracts and surfaces: + +- Go tests; possibly removal of unreachable Go branches (then C and Rust parity checks). + +Existing patterns to reuse: + +- The test timing and fault hooks in AGENTS.md "Test Cadence" (Go unexported fields), the existing Go fault-injection wrappers. + +Risk and blast radius: + +- Tests only, unless unreachable code is removed; no per-request path changes expected, so no benchmark run. + +Sensitive data handling plan: + +- No sensitive data involved; evidence is coverage percentages, function names and file paths in this repository. + +Implementation plan: + +1. Produce the per-function report and list the uncovered branches by package. +2. Classify each: reachable and worth a behavioural test, reachable only by fault injection, or unreachable. +3. Add tests or remove dead branches; check C and Rust for the same branch. + +Validation plan: + +- `tests/run-coverage-go.sh` at or above 90%; `go test ./...` on Linux, FreeBSD amd64 and 386, macOS and Windows for touched files. + +Artifact impact plan: + +- AGENTS.md: likely unaffected. +- Runtime project skills: likely unaffected. +- Specs: unaffected unless dead branches reveal a spec mismatch. +- End-user/operator docs: unaffected. +- End-user/operator skills: unaffected. +- SOW lifecycle: opens after SOW-0050 closes. + +Open-source reference evidence: + +- None; the evidence is this repository. + +Open decisions: + +- None now; the scope decision (A) is recorded below. + +## Implications And Decisions + +1. Go coverage below threshold (user, 2026-11-05, "2a"): restore 90% in a pending SOW after the SOW-0050 vendoring. Rejected: B, fixing it inside the vendoring; C, lowering the threshold. + +## Plan + +See the implementation plan above. + +## Execution Log + +### 2026-11-05 + +- Created from the SOW-0050 CI triage. + +## Validation + +Pending activation. + +## Outcome + +Pending activation. + +## Lessons Extracted + +Pending activation. + +## Followup + +None yet. + +## Regression Log + +None yet. + +Append regression entries here only after this SOW was completed or closed and later testing or use found broken behavior. Use a dated `## Regression - YYYY-MM-DD` heading at the end of the file. Never prepend regression content above the original SOW narrative. diff --git a/.agents/sow/pending/SOW-0069-20261106-benchmark-host-rebaseline.md b/.agents/sow/pending/SOW-0069-20261106-benchmark-host-rebaseline.md new file mode 100644 index 00000000..b1eea301 --- /dev/null +++ b/.agents/sow/pending/SOW-0069-20261106-benchmark-host-rebaseline.md @@ -0,0 +1,150 @@ +# SOW-0069 - Benchmark Host Drift And Floor Re-Baseline + +## Status + +Status: open + +`completed` is the successful terminal status. `done` is a directory name, not a status value. Do not use `Status: done` or `Status: complete`. + +Sub-state: queued after the SOW-0050 Netdata vendoring (user decision 2A, 2026-11-06). + +## Requirements + +### Purpose + +The committed benchmark reports and their floors describe a known host state again, so a floor failure means a code regression and not a slower host. + +### User Request + +SOW-0050 decision 2 (user, 2026-11-06, option A): keep the June CSVs during the vendoring; track why both benchmark hosts now run at about half their committed numbers, then re-baseline the CSVs and floors. + +### Assistant Understanding + +Facts: + +- `benchmarks-posix.csv` and `benchmarks-windows.csv` date from 2026-06-15 (`95c53ef`). POSIX uds-ping-pong c->c was 156k/s with a 4 us p50. +- On 2026-11-05 the Linux workstation gives 65k to 78k/s for uds-ping-pong on every pair with both `63919ec` and `4261919`. The generator reports 26 floor violations on the candidate CSV and the `63919ec` run misses 31 floors after retries (SOW-0050 Execution Log). +- The Windows test host runs `95c53ef` at 0.35x to 0.45x of its own June numbers (SOW-0050 A/B), so the drop there is the host. +- Workstation state on 2026-11-05: `schedutil` governor, STIBP always on, kernel 7.0.0-42, unrelated workloads (load average 10 to 25 on 112 cores). The June state was not recorded. +- Windows SHM ping-pong and snapshot-shm rows fail the stability gate on every recent run, so `tests/generate-benchmarks-windows.sh` rejects full runs as incomplete. + +Inferences: + +- Host configuration (frequency policy, mitigations, kernel, background load) changed since June; which factor dominates is unknown. + +Unknowns: + +- The June host state; whether a dedicated quiet host or a recorded host configuration is available. + +### Acceptance Criteria + +- The cause of the drop is identified with evidence on each host, or recorded as unrecoverable with the measurements that rule out code. +- Each benchmark report records the host state it was measured on. +- New CSVs pass their floors, and any floor change is justified by the recorded host state, not by fitting the numbers. +- The Windows SHM stability-gate failures are explained and either fixed or handled by the gate with evidence. + +## Analysis + +Sources checked: + +- SOW-0050 Execution Log (A/B runs of 2026-11-05). + +Current state: + +- Floors fail on both hosts for both the old and the new code. + +Risks: + +- Changing host configuration on the production workstation needs user approval; lowering floors without a cause would hide regressions. + +## Pre-Implementation Gate + +Status: blocked + +Problem / root-cause model: + +- Blocked until SOW-0050 completes; filled at activation. + +Evidence reviewed: + +- SOW-0050 A/B evidence; the committed CSVs. + +Affected contracts and surfaces: + +- `benchmarks-posix.*`, `benchmarks-windows.*`, `tests/generate-benchmarks-*.sh` floors, `tests/run-*-bench.sh` host-state capture. + +Existing patterns to reuse: + +- The same-host A/B and row-interleaved methods recorded in SOW-0050. + +Risk and blast radius: + +- Benchmark artifacts and scripts only; no library code. + +Sensitive data handling plan: + +- Host state is recorded by role and configuration (governor, kernel, mitigations); no host names, addresses or accounts. + +Implementation plan: + +1. Record host state (CPU governor, frequency, kernel, mitigations, power plan; by role, never host names) as durable metadata in each benchmark CSV or a committed sidecar, and make `tests/generate-benchmarks-posix.sh` and `tests/generate-benchmarks-windows.sh` require it and print it in the committed reports. +2. Measure the Linux workstation under controlled settings, with user approval for any change, and run an A/B against the June baseline. +3. Windows host: capture the same host state, A/B the current tree against the June numbers under controlled settings, and analyse the SHM ping-pong and snapshot-shm stability-gate failures (per-run spread, scheduling and timer effects) until they are fixed or the gate handles them with evidence. +4. Re-baseline both CSVs and review floors with the user. + +Validation plan: + +- Generators pass on fresh full runs; an A/B shows the floors catch a known slowdown. + +Artifact impact plan: + +- AGENTS.md: possibly the benchmark command notes. +- Runtime project skills: `project-netdata-vendoring` performance step if the method changes. +- Specs: none expected. +- End-user/operator docs: the benchmark reports. +- End-user/operator skills: none expected. +- SOW lifecycle: opens after SOW-0050 closes. + +Open-source reference evidence: + +- None; the evidence is this repository. + +Open decisions: + +- Host configuration changes (later, with evidence). + +## Implications And Decisions + +1. Keep the June CSVs during the SOW-0050 vendoring and track the drop here (user, 2026-11-06, 2A). Rejected: lowering floors to today's numbers; committing a report that fails its own floors. + +## Plan + +See the implementation plan above. + +## Execution Log + +### 2026-11-06 + +- Created from SOW-0050 decision 2A. + +## Validation + +Pending activation. + +## Outcome + +Pending activation. + +## Lessons Extracted + +Pending activation. + +## Followup + +None yet. + +## Regression Log + +None yet. + +Append regression entries here only after this SOW was completed or closed and later testing or use found broken behavior. Use a dated `## Regression - YYYY-MM-DD` heading at the end of the file. Never prepend regression content above the original SOW narrative. diff --git a/.github/workflows/codacy-analysis.yml b/.github/workflows/codacy-analysis.yml index 13aa3c37..2c20519e 100644 --- a/.github/workflows/codacy-analysis.yml +++ b/.github/workflows/codacy-analysis.yml @@ -72,7 +72,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: "1.26.x" + go-version: "1.27.x" cache: false - name: Install Codacy Analysis CLI diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 00000000..10d3a73c --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,79 @@ +name: macOS + +on: + push: + branches: + - main + paths: + - CMakeLists.txt + - src/** + - tests/** + - .github/workflows/macos.yml + pull_request: + branches: + - main + paths: + - CMakeLists.txt + - src/** + - tests/** + - .github/workflows/macos.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + stream-baseline: + # macOS runs the UDS stream baseline (docs/level1-posix-uds.md); it has + # no SHM profile, so the SHM suites are not built. + name: macOS stream baseline (C, Rust, Go, interop) + runs-on: macos-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: src/go/go.mod + + - name: Set up Rust + run: | + rustup toolchain install stable --profile minimal + rustup default stable + + - name: Build + run: | + cmake -S . -B build + cmake --build build -j "$(sysctl -n hw.ncpu)" + + - name: CTest + run: ctest --test-dir build --output-on-failure + + - name: Rust tests + working-directory: src/crates/netipc + run: cargo test --locked + + - name: Go tests + working-directory: src/go + run: go test -count=1 ./... + + - name: Interop + run: | + bash tests/test_uds_interop.sh + bash tests/test_service_interop.sh + bash tests/test_cache_interop.sh + bash tests/interop_codec.sh + + # Lookup-scale interop is an extended suite: run it before vendoring + # with `gh workflow run macos.yml --ref `. + - name: Lookup-scale interop (manual runs only) + if: github.event_name == 'workflow_dispatch' + run: bash tests/test_lookup_scale_interop.sh diff --git a/.github/workflows/runtime-safety.yml b/.github/workflows/runtime-safety.yml index 061adc79..fe10eddb 100644 --- a/.github/workflows/runtime-safety.yml +++ b/.github/workflows/runtime-safety.yml @@ -56,6 +56,92 @@ jobs: rustup target add arm-unknown-linux-musleabihf bash tests/run-rust-shm-timeout-abi.sh + linux-architectures: + name: Linux architectures (Go vet) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: src/go/go.mod + cache: false + + - name: Vet Go on every Linux architecture + working-directory: src/go + run: | + for arch in amd64 386 arm arm64 riscv64 loong64 ppc64le s390x mips64le; do + echo "== GOARCH=$arch" + GOARCH=$arch go vet ./... + done + + linux-targets: + # Every Linux target Netdata ships: C, Rust and Go suites plus interop. + name: Linux target ${{ matrix.target }} + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + target: + - x86_64-musl + - i386-gnu + - i386-musl + - armv7-gnu + - armv7-gnu-time64 + - armv7-musl + - armv6-musl + - aarch64-gnu + - aarch64-musl + env: + TARGET: ${{ matrix.target }} + ZIG_VERSION: 0.17.0 + ZIG_SHA256: 1cbe9df9f27e6b78d14ccbca43b6703a404ef79ef1c463de901d7f088d4e2026 + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: src/go/go.mod + cache: false + + - name: Install cross compilers and emulator + # binfmt is required, not just a runner: tests re-exec their own + # binaries and interop scripts start foreign servers and clients. + run: | + pkgs="cmake ninja-build qemu-user-binfmt" + case "$TARGET" in + i386-*) pkgs="$pkgs gcc-i686-linux-gnu libc6-dev-i386-cross" ;; + armv*) pkgs="$pkgs gcc-arm-linux-gnueabihf libc6-dev-armhf-cross" ;; + aarch64-*) pkgs="$pkgs gcc-aarch64-linux-gnu libc6-dev-arm64-cross" ;; + esac + sudo apt-get update + # shellcheck disable=SC2086 + sudo apt-get install -y --no-install-recommends $pkgs + + - name: Install Zig for musl C builds + if: contains(matrix.target, 'musl') + run: | + archive="zig-x86_64-linux-${ZIG_VERSION}.tar.xz" + curl -fsSLo "$RUNNER_TEMP/$archive" "https://ziglang.org/download/${ZIG_VERSION}/${archive}" + echo "${ZIG_SHA256} $RUNNER_TEMP/$archive" | sha256sum -c - + tar -xJf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/zig-x86_64-linux-${ZIG_VERSION}" >> "$GITHUB_PATH" + + - name: Build and test C, Rust, Go and interop + env: + NIPC_TARGET_RUSTUP_ADD: "1" + run: bash tests/run-linux-target.sh "$TARGET" + asan-ubsan: name: ASAN/UBSAN runs-on: ubuntu-latest @@ -219,6 +305,7 @@ jobs: test_win_shm \ test_win_service \ test_win_service_payload_limits \ + test_win_robustness \ test_win_service_extra \ test_win_stress \ interop_named_pipe_c \ @@ -237,4 +324,4 @@ jobs: - name: Run Windows runtime tests run: | set -euo pipefail - ctest --test-dir build-windows --output-on-failure -j1 -R "^(test_named_pipe|test_win_shm|test_win_service|test_win_service_payload_limits|test_win_service_extra|test_named_pipe_interop|test_win_shm_interop|test_service_win_interop|test_service_win_shm_interop|test_cache_win_interop|test_cache_win_shm_interop|test_win_stress)$" + ctest --test-dir build-windows --output-on-failure -j1 -R "^(test_named_pipe|test_win_shm|test_win_service|test_win_service_payload_limits|test_win_robustness|test_win_service_extra|test_named_pipe_interop|test_win_shm_interop|test_service_win_interop|test_service_win_shm_interop|test_cache_win_interop|test_cache_win_shm_interop)$" diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 8a784967..13bc8576 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -156,11 +156,11 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - # Latest 1.26 patch toolchain, NOT go-version-file: the go.mod - # directive must stay 1.26.0 until netdata (which vendors this + # Latest 1.27 patch toolchain, NOT go-version-file: the go.mod + # directive must stay 1.27.0 until netdata (which vendors this # source) raises its Go toolchain, but govulncheck must scan the # stdlib at the patched version CI actually builds with. - go-version: "1.26.x" + go-version: "1.27.x" cache: false - name: Install Go analysis tools diff --git a/.github/workflows/supply-chain-security.yml b/.github/workflows/supply-chain-security.yml index fc2c5134..99454f47 100644 --- a/.github/workflows/supply-chain-security.yml +++ b/.github/workflows/supply-chain-security.yml @@ -38,10 +38,12 @@ jobs: - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: "3.x" + python-version: "3.12" + # Pinned: semgrep 1.183.0 on Python 3.15 crashes (semgrep-core -11). + # Bump both together and confirm the scan finishes. - name: Install Semgrep - run: python -m pip install --upgrade semgrep + run: python -m pip install semgrep==1.177.0 - name: Run Semgrep run: | @@ -78,7 +80,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: "1.26.x" + go-version: "1.27.x" cache: false - name: Install OSV-Scanner diff --git a/.gitignore b/.gitignore index 27133627..9514ec14 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ build-*/ **/target/ *.o coverage/ +.tmp/ *.profraw *.profdata diff --git a/AGENTS.md b/AGENTS.md index da30bb8f..6a9bd830 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,6 +212,24 @@ A SOW cannot be completed until Validation records: Generic "N/A" is invalid. +### Test Cadence + +Validation has three classes with different costs and triggers. + +1. Correctness tests (unit, robustness, interop, Netdata Linux targets, Windows suites): + - are deterministic and take seconds: they synchronize threads and processes on events (listening signals, handler flags, server test events, READY lines), never on sleeps, polling or retry loops, and pass or fail in one run. A sleep is allowed only as a stimulus whose length the outcome does not depend on (for example a handler slower than a poll slice), and each one is recorded in the SOW that adds it; + - shorten production waits through the per-instance test timings (C `*_for_tests` under `NIPC_INTERNAL_TESTING`, Rust crate-internal setters, Go unexported fields), never by changing production defaults; + - run after every fix and at every SOW close; + - target the affected surface: the touched languages, transports and platforms, plus their interop pairs. A wider run is needed only when the change crosses those boundaries (shared wire format, a cross-language contract, a build system change); + - before triaging a timing failure, check whether the test processes were stalled during the run: sample `/proc/pressure/{cpu,memory,io}` and `/proc/vmstat` (`compact_stall`, `allocstall_*`) while it runs. Memory pressure measures allocator stall time, not free RAM. Rerun once the stalls are gone before treating the failure as a bug, and never attribute it to another workload without evidence. +2. Extended tests (fuzz, stress, randomized chaos, lookup scale) are not correctness tests. Default CTest, `cargo test` and `go test` runs exclude them (`NETIPC_EXTENDED_TESTS=OFF`, Go `netipc_extended` build tag, Rust `#[ignore]`). Run them with `tests/run-extended-fuzz.sh` and `tests/run-extended-stress.sh` only before vendoring, and only when the vendored changes touch what they exercise. +3. Performance tests (`tests/run-posix-bench.sh`, `tests/run-windows-bench.sh`) take hours, so they run only when a change can affect what they measure: + - they measure established-session steady state: per-request encode/decode, dispatch, transport send/receive, SHM signalling, batching, pipelining, snapshot refresh and cache lookup; + - changes outside those paths do not need a benchmark run. Examples: connection setup and handshake, reconnect, error mapping, paths that run only after an idle timeout, shutdown, close-on-exec flags, tests, docs, build scripts; + - every SOW gate records a performance assessment: which changed code, if any, runs per request on an established session, with file:line evidence. When that assessment is uncertain, ask the user before planning a benchmark run. + - When the assessment says a change can affect those paths, the benchmark run waits until every planned fix touching them is in, then the affected suites run once. If they show regressions, fix all of them, then rerun once. Never benchmark between fixes, and never re-benchmark after each regression fix. Each SOW lands as one commit, so bisect a regression by SOW commit. + - A SOW whose purpose is performance (an optimization, a benchmark investigation, a floor change) runs the benchmark rows it needs. + ### Artifact Maintenance Gate Every SOW close must explicitly record whether each durable artifact class was updated or why no update was needed: @@ -259,7 +277,7 @@ Runtime input skills: - `.agents/skills/project-netdata-vendoring/SKILL.md` Trigger: any work that copies, syncs, vendors, merges, or checks NetIPC source changes into a Netdata checkout. - Purpose: require source `plugin-ipc` CI and GitHub code/security scanner preflight before touching Netdata's vendored NetIPC copy. + Purpose: require source `plugin-ipc` CI, GitHub code/security scanner, and performance-assessment preflight before touching Netdata's vendored NetIPC copy. Output/reference skills: @@ -272,15 +290,21 @@ Output/reference skills: - Configure/build C plus available Rust and Go pieces: `make` - Explicit configure/build: `cmake -S . -B build` then `cmake --build build` - The standalone Rust package uses language edition 2024 with MSRV 1.91. - Validate both Rust 1.91.0 and latest stable. Keep style edition 2021 explicit - through `rustfmt.toml` until the coordinated SOW-0035 style migration. + Validate both Rust 1.91.0 and latest stable. `rustfmt.toml` selects style + edition 2024 explicitly; `cargo fmt --check` must pass with both toolchains. - Local validation and benchmark commands must run at low scheduler priority so the workstation desktop stays responsive. Prefer `tests/run-low-priority.sh [args...]` for direct commands; project validation scripts under `tests/*.sh` already self-reexec through this helper. The helper uses `nice -n 19` plus `ionice -c 3` when available, and falls back on platforms without `ionice`. - Run CTest after configure/build: `tests/run-low-priority.sh ctest --test-dir build --output-on-failure` - POSIX coverage: `bash tests/run-coverage-c.sh`, `bash tests/run-coverage-rust.sh`, `bash tests/run-coverage-go.sh` - POSIX interop examples: `bash tests/interop_codec.sh`, `bash tests/test_uds_interop.sh`, `bash tests/test_shm_interop.sh`, `bash tests/test_service_interop.sh` - `tests/interop_codec.sh` configures `build/`; do not run it in parallel with CTest or another build-directory validation command. +- Netdata Linux targets (32-bit, ARM, musl): `bash tests/run-linux-target.sh [c] [rust] [go] [interop]`; targets and required cross toolchains are listed in the script header. C test fixtures use fixed `/tmp` paths: never run two targets in parallel on one host. +- FreeBSD (no CI runner; run manually on a FreeBSD host before vendoring and after any change to POSIX transport, service, build or test code): with `cmake`, `ninja`, `bash`, Rust and Go installed, run `cmake -S . -B build -G Ninja && cmake --build build && ctest --test-dir build --output-on-failure`, then `cargo test` in `src/crates/netipc` and `go test ./...` in `src/go`. FreeBSD runs SHM (Go SHM on amd64 and 386 only, so on amd64 also run `GOARCH=386 go test ./...` in `src/go`); keep the build directory and `/tmp` on UFS or tmpfs, since ZFS rejects the region reservation and the SHM suites need it. +- macOS (CI workflow `.github/workflows/macos.yml`; run on a macOS host after any change to POSIX transport, service, build or test code): with `cmake`, Rust and Go installed, run `cmake -S . -B build && cmake --build build && ctest --test-dir build --output-on-failure`, then `cargo test` in `src/crates/netipc`, `go test ./...` in `src/go`, and `bash tests/test_uds_interop.sh`, `tests/test_service_interop.sh`, `tests/test_cache_interop.sh`, `tests/interop_codec.sh`. Lookup-scale interop is an extended suite there too: before vendoring, run `bash tests/test_lookup_scale_interop.sh` on macOS (`gh workflow run macos.yml --ref ` runs it on a macOS runner; macOS has no SHM, so `tests/run-extended-stress.sh` does not apply there). macOS runs the UDS stream baseline (`docs/level1-posix-uds.md`, "Stream baseline"; FreeBSD uses it too) and has no SHM, so the SHM suites are not built there. Code paths of test scripts that macOS runs must work on its bash 3.2 (no `{fd}` redirections, `mapfile`, associative arrays); the stream helpers are compiled and unit-tested on Linux too. +- Extended (non-correctness) suites, before vendoring only: `bash tests/run-extended-fuzz.sh`, `bash tests/run-extended-stress.sh` +- Interop scripts share `tests/interop-lib.sh` (start a fixture server, block on its READY line, stop it with an identity check). +- Robustness reproduction tests: C `test_robustness` / `test_win_robustness`, Rust `service::raw::tests::robustness` / `service::raw::windows_tests::robustness`, Go `TestRobust*` / `TestWinRobust*`. They always run; there is no skip gate. - Windows validation scripts are under `tests/run-*-windows*.sh` and `tests/test_*_win*.sh`; use the specific script matching the touched transport/API. -- Benchmark generation: `bash tests/generate-benchmarks-posix.sh` or `bash tests/generate-benchmarks-windows.sh` +- Benchmarks (only when Test Cadence calls for them): measure with `bash tests/run-posix-bench.sh [duration]` or `bash tests/run-windows-bench.sh [duration]`, then render the report and check floors with `bash tests/generate-benchmarks-posix.sh` or `bash tests/generate-benchmarks-windows.sh` - Netdata vendor diff: `bash ./diff-netdata-vendor.sh /path/to/netdata` - Netdata vendor copy: `bash ./vendor-to-netdata.sh /path/to/netdata` @@ -290,8 +314,9 @@ Output/reference skills: - Preserve the layer boundaries in `docs/code-organization.md`: Codec owns wire format; transport owns connection/framing/profile mechanics; service owns typed orchestration and snapshot helpers. - Cross-language interoperability is mandatory across C, Rust, and Go. Do not change one language implementation without checking the corresponding contract and other language implementations. - Go must remain pure Go without `cgo`. -- Performance claims require benchmark evidence. Do not adjust benchmark floors or accept parity gaps without a fact-based analysis. -- Before any Netdata vendoring work, load `.agents/skills/project-netdata-vendoring/SKILL.md` and complete its preflight even if the user did not explicitly ask for CI, scanner, or drift checks. Do not modify Netdata's vendored NetIPC copy until the active SOW records: the candidate `plugin-ipc` source commit; GitHub Actions/check-run evidence; GitHub code-scanning, Dependabot, and secret-scanning evidence; the last source-to-Netdata vendoring baseline; a two-way gap analysis of what changed in `plugin-ipc` since that baseline and what changed in Netdata's vendored NetIPC copy since that baseline; and a migration plan for every drift class. Failing, pending, cancelled, timed-out, or untriaged open scanner findings block vendoring unless they are fixed, documented as evidence-backed false positives, or explicitly risk-accepted by the user in the SOW. Unknown baseline, unexplained downstream drift, or no migration plan also blocks vendoring. +- Performance claims require benchmark evidence. When a benchmark run is needed and how it is batched is defined in Test Cadence. Do not adjust benchmark floors or accept parity gaps without a fact-based analysis. +- Before any Netdata vendoring work, load `.agents/skills/project-netdata-vendoring/SKILL.md` and complete its preflight even if the user did not explicitly ask for CI, scanner, or drift checks. Do not modify Netdata's vendored NetIPC copy until the active SOW records: the candidate `plugin-ipc` source commit; GitHub Actions/check-run evidence; GitHub code-scanning, Dependabot, and secret-scanning evidence; the last source-to-Netdata vendoring baseline; a two-way gap analysis of what changed in `plugin-ipc` since that baseline and what changed in Netdata's vendored NetIPC copy since that baseline; a migration plan for every drift class; and the performance assessment of the vendored changes, with the benchmark comparison when that assessment called for a run (Test Cadence). Failing, pending, cancelled, timed-out, or untriaged open scanner findings block vendoring unless they are fixed, documented as evidence-backed false positives, or explicitly risk-accepted by the user in the SOW. Unknown baseline, unexplained downstream drift, or no migration plan also blocks vendoring. +- Zero-known-bug policy: confirmed bugs are fixed, not documented. A reproduction test exists to prove a bug is real and then to prove the fix; never land it behind a skip gate, environment switch, or "known bug" marker, and never close work with a confirmed bug left open. Fix design decisions still go to the user. - Archived TODO history under `.agents/sow/todo-history/TODO-*` is existing project memory. Do not move, delete, or rewrite those archived notes without explicit user approval; track future classification or migration through a SOW. ### Preservation Notes diff --git a/CMakeLists.txt b/CMakeLists.txt index 5cf00069..d88fd409 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,12 +26,43 @@ if(NETIPC_COVERAGE) add_link_options(-fprofile-arcs -ftest-coverage) endif() +# Fuzz, stress, chaos, scale and benchmark-policy suites are not correctness +# tests: they run only from tests/run-extended-*.sh, before Netdata vendoring. +option(NETIPC_EXTENDED_TESTS "Register fuzz, stress, scale and benchmark-policy tests" OFF) + # Detect Windows runtime (native or MSYS/Cygwin) set(NETIPC_WINDOWS_RUNTIME OFF) if(WIN32 OR CYGWIN OR CMAKE_SYSTEM_NAME STREQUAL "MSYS") set(NETIPC_WINDOWS_RUNTIME ON) endif() +# POSIX service stack: every non-Windows system. +set(NETIPC_POSIX_RUNTIME OFF) +if(NOT NETIPC_WINDOWS_RUNTIME) + set(NETIPC_POSIX_RUNTIME ON) +endif() + +# POSIX SHM data plane: Linux and FreeBSD, it needs a futex-style wait (Linux +# futex, FreeBSD _umtx_op). Other POSIX systems build the SHM API as stubs and +# negotiate the baseline profile. +set(NETIPC_POSIX_SHM OFF) +if(NETIPC_POSIX_RUNTIME AND CMAKE_SYSTEM_NAME MATCHES "^(Linux|FreeBSD)$") + set(NETIPC_POSIX_SHM ON) +endif() +# Go builds its SHM data plane on Linux and on FreeBSD amd64 and 386 only, +# where its raw syscall layouts are tested. +set(NETIPC_GO_POSIX_SHM OFF) +if(NETIPC_POSIX_SHM AND (CMAKE_SYSTEM_NAME STREQUAL "Linux" OR + CMAKE_SYSTEM_PROCESSOR MATCHES "^(amd64|AMD64|x86_64|i386|i686)$")) + set(NETIPC_GO_POSIX_SHM ON) +endif() +# Test-only linker wrap of the SHM allocation call, for injected failures. +if(CMAKE_SYSTEM_NAME STREQUAL "FreeBSD") + set(NETIPC_SHM_ALLOCATE_WRAP "LINKER:--wrap=posix_fallocate") +else() + set(NETIPC_SHM_ALLOCATE_WRAP "LINKER:--wrap=fallocate" "LINKER:--wrap=fallocate64") +endif() + # --- C library: protocol (Codec + L1 wire envelope) ---------------------- @@ -64,9 +95,9 @@ if(NOT NETIPC_WINDOWS_RUNTIME) ) target_link_libraries(netipc_uds PUBLIC netipc_protocol Threads::Threads) - # --- C library: L1 POSIX SHM transport (Linux only) ---------------------- + # --- C library: L1 POSIX SHM transport (stubs off Linux) ---------------- - if(NOT APPLE) + if(NETIPC_POSIX_RUNTIME) add_library(netipc_shm STATIC src/libnetdata/netipc/src/transport/posix/netipc_shm.c ) @@ -75,7 +106,7 @@ if(NOT NETIPC_WINDOWS_RUNTIME) ) target_link_libraries(netipc_shm PUBLIC netipc_protocol) - # --- C library: L2 service orchestration (Linux only, needs SHM+UDS) ---- + # --- C library: L2 service orchestration -------------------------------- add_library(netipc_service STATIC src/libnetdata/netipc/src/service/netipc_service_common.c @@ -105,11 +136,12 @@ endif() if(NETIPC_WINDOWS_RUNTIME) add_library(netipc_named_pipe STATIC src/libnetdata/netipc/src/transport/windows/netipc_named_pipe.c + src/libnetdata/netipc/src/transport/windows/netipc_win_security.c ) target_include_directories(netipc_named_pipe PUBLIC src/libnetdata/netipc/include ) - target_link_libraries(netipc_named_pipe PUBLIC netipc_protocol) + target_link_libraries(netipc_named_pipe PUBLIC netipc_protocol advapi32) # --- C library: L1 Windows SHM transport -------------------------------- @@ -187,17 +219,19 @@ add_executable(test_protocol tests/test_protocol.c) target_link_libraries(test_protocol PRIVATE netipc_protocol) add_test(NAME test_protocol COMMAND test_protocol) -add_test(NAME test_windows_bench_stability_policy - COMMAND bash "${CMAKE_SOURCE_DIR}/tests/test_windows_bench_stability_policy.sh" - WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" -) +if(NETIPC_EXTENDED_TESTS) + add_test(NAME test_windows_bench_stability_policy + COMMAND bash "${CMAKE_SOURCE_DIR}/tests/test_windows_bench_stability_policy.sh" + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + ) +endif() add_executable(interop_codec_c tests/fixtures/c/interop_codec.c) target_link_libraries(interop_codec_c PRIVATE netipc_protocol) if(CARGO_EXECUTABLE) add_test(NAME test_protocol_rust - COMMAND ${CARGO_EXECUTABLE} test protocol -- --test-threads=1 + COMMAND ${CARGO_EXECUTABLE} test protocol:: WORKING_DIRECTORY "${NETIPC_RUST_CRATE_DIR}" ) set_tests_properties(test_protocol_rust PROPERTIES @@ -238,12 +272,14 @@ target_link_libraries(fuzz_protocol PRIVATE netipc_protocol) # CTest fuzz targets: short 30-second runs with random input via /dev/urandom. # Each test generates random data and feeds it to the standalone harness. +if(NETIPC_EXTENDED_TESTS) add_test(NAME fuzz_protocol_30s COMMAND bash -c "head -c 65536 /dev/urandom | $" ) set_tests_properties(fuzz_protocol_30s PROPERTIES TIMEOUT 60) +endif() -if(GO_EXECUTABLE) +if(GO_EXECUTABLE AND NETIPC_EXTENDED_TESTS) # Go fuzz targets: short runs with timeout headroom. The extended fuzz # script owns longer fuzz coverage; CTest should fail only on real crashes. foreach(FUZZ_TARGET @@ -279,6 +315,18 @@ endif() if(NOT NETIPC_WINDOWS_RUNTIME) add_executable(test_uds tests/fixtures/c/test_uds.c) target_link_libraries(test_uds PRIVATE netipc_uds netipc_protocol Threads::Threads) + target_compile_definitions(test_uds PRIVATE NIPC_INTERNAL_TESTING=1) + # The stream packet helpers are internal to the transport. + target_include_directories(test_uds PRIVATE + src/libnetdata/netipc/src/transport/posix) + if(NOT APPLE) + # Deterministic EINTR injection; ld64 has no --wrap. + target_sources(test_uds PRIVATE tests/fixtures/c/uds_eintr_wrap.c) + target_compile_definitions(test_uds PRIVATE NIPC_TEST_EINTR_WRAP=1) + target_link_options(test_uds PRIVATE + "LINKER:--wrap=send" "LINKER:--wrap=sendmsg" "LINKER:--wrap=__sendmsg64" + "LINKER:--wrap=recv" "LINKER:--wrap=bind" "LINKER:--wrap=accept4") + endif() add_test(NAME test_uds COMMAND test_uds) set_tests_properties(test_uds PROPERTIES TIMEOUT 30) @@ -287,9 +335,9 @@ if(NOT NETIPC_WINDOWS_RUNTIME) target_link_libraries(interop_uds_c PRIVATE netipc_uds netipc_protocol) endif() -# --- L1 SHM transport tests (Linux only) ----------------------------------- +# --- L1 SHM transport tests (Linux and FreeBSD) ---------------------------- -if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE) +if(NETIPC_POSIX_SHM) add_library(netipc_shm_fallocate_test_wrap OBJECT tests/fixtures/c/shm_fallocate_wrap.c ) @@ -305,7 +353,7 @@ if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE) $ ) target_compile_definitions(test_shm PRIVATE NIPC_INTERNAL_TESTING=1) - target_link_options(test_shm PRIVATE "LINKER:--wrap=fallocate") + target_link_options(test_shm PRIVATE ${NETIPC_SHM_ALLOCATE_WRAP}) target_link_libraries(test_shm PRIVATE netipc_shm netipc_uds netipc_protocol Threads::Threads) add_test(NAME test_shm COMMAND test_shm) set_tests_properties(test_shm PROPERTIES TIMEOUT 30) @@ -320,18 +368,20 @@ if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE) target_link_libraries(interop_shm_c PRIVATE netipc_shm netipc_protocol) endif() -# --- L2 service orchestration tests (Linux only) --------------------------- +# --- L2 service orchestration tests ---------------------------------------- -if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE) +if(NETIPC_POSIX_RUNTIME) add_executable(test_service tests/fixtures/c/test_service.c) - target_sources(test_service PRIVATE - $ - ) target_link_libraries(test_service PRIVATE netipc_service netipc_shm netipc_uds netipc_protocol Threads::Threads ) target_compile_definitions(test_service PRIVATE NIPC_INTERNAL_TESTING=1) - target_link_options(test_service PRIVATE "LINKER:--wrap=fallocate") + if(NETIPC_POSIX_SHM) + target_sources(test_service PRIVATE + $ + ) + target_link_options(test_service PRIVATE ${NETIPC_SHM_ALLOCATE_WRAP}) + endif() add_test(NAME test_service COMMAND test_service) set_tests_properties(test_service PROPERTIES TIMEOUT 60) @@ -384,8 +434,10 @@ if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE) netipc_service netipc_shm netipc_uds netipc_protocol Threads::Threads ) target_compile_definitions(test_stress PRIVATE NIPC_INTERNAL_TESTING=1) - add_test(NAME test_stress COMMAND test_stress) - set_tests_properties(test_stress PROPERTIES TIMEOUT 180) + if(NETIPC_EXTENDED_TESTS) + add_test(NAME test_stress COMMAND test_stress) + set_tests_properties(test_stress PROPERTIES TIMEOUT 180) + endif() # Ping-pong tests: INCREMENT + STRING_REVERSE L2 typed calls add_executable(test_ping_pong tests/fixtures/c/test_ping_pong.c) @@ -402,8 +454,14 @@ if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE) netipc_service netipc_shm netipc_uds netipc_protocol Threads::Threads ) target_compile_definitions(test_chaos PRIVATE NIPC_INTERNAL_TESTING=1) + # Deterministic malformed-input cases are correctness; the randomized + # attacks run with --extended. add_test(NAME test_chaos COMMAND test_chaos) - set_tests_properties(test_chaos PROPERTIES TIMEOUT 120) + set_tests_properties(test_chaos PROPERTIES TIMEOUT 30) + if(NETIPC_EXTENDED_TESTS) + add_test(NAME test_chaos_extended COMMAND test_chaos --extended) + set_tests_properties(test_chaos_extended PROPERTIES TIMEOUT 120) + endif() # Hardening tests (Phase 3: security-critical edge cases) add_executable(test_hardening tests/fixtures/c/test_hardening.c) @@ -413,13 +471,29 @@ if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE) target_compile_definitions(test_hardening PRIVATE NIPC_INTERNAL_TESTING=1) add_test(NAME test_hardening COMMAND test_hardening) set_tests_properties(test_hardening PROPERTIES TIMEOUT 120) + + # Robustness reproduction tests (SOW-0040) + add_executable(test_robustness tests/fixtures/c/test_robustness.c) + target_link_libraries(test_robustness PRIVATE + netipc_service netipc_shm netipc_uds netipc_protocol Threads::Threads + ) + target_compile_definitions(test_robustness PRIVATE NIPC_INTERNAL_TESTING=1) + add_test(NAME test_robustness COMMAND test_robustness) + set_tests_properties(test_robustness PROPERTIES TIMEOUT 180) endif() # --- Rust UDS transport tests --------------------------------------------- if(CARGO_EXECUTABLE) + # Rust test filters are disjoint so the entries can run in parallel. + if(NETIPC_WINDOWS_RUNTIME) + set(NETIPC_RUST_TRANSPORT_FILTER "transport::") + else() + # transport::shm:: has its own entry; framing is shared by both. + set(NETIPC_RUST_TRANSPORT_FILTER "transport::posix::" "transport::framing::") + endif() add_test(NAME test_uds_rust - COMMAND ${CARGO_EXECUTABLE} test transport -- --test-threads=1 + COMMAND ${CARGO_EXECUTABLE} test -- ${NETIPC_RUST_TRANSPORT_FILTER} WORKING_DIRECTORY "${NETIPC_RUST_CRATE_DIR}" ) set_tests_properties(test_uds_rust PROPERTIES @@ -434,14 +508,14 @@ if(CARGO_EXECUTABLE) COMMENT "Building Rust UDS interop binary" ) - # Rust SHM transport tests (Linux only) - if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE) + # Rust SHM transport tests (Linux and FreeBSD) + if(NETIPC_POSIX_SHM) add_test(NAME test_shm_rust - COMMAND ${CARGO_EXECUTABLE} test shm -- --test-threads=1 + COMMAND ${CARGO_EXECUTABLE} test transport::shm:: WORKING_DIRECTORY "${NETIPC_RUST_CRATE_DIR}" ) set_tests_properties(test_shm_rust PROPERTIES - TIMEOUT 30 + TIMEOUT 60 RESOURCE_LOCK rust_posix_transport_tests ) @@ -451,10 +525,14 @@ if(CARGO_EXECUTABLE) WORKING_DIRECTORY "${NETIPC_RUST_CRATE_DIR}" COMMENT "Building Rust SHM interop binary" ) + endif() - # Rust typed L2 service tests + if(NETIPC_POSIX_RUNTIME) + # Rust typed L2 service facades add_test(NAME test_service_typed_rust - COMMAND ${CARGO_EXECUTABLE} test service::cgroups::tests:: -- --test-threads=1 + COMMAND ${CARGO_EXECUTABLE} test -- + service::apps_lookup:: service::cgroups_lookup:: + service::cgroups_snapshot:: WORKING_DIRECTORY "${NETIPC_RUST_CRATE_DIR}" ) set_tests_properties(test_service_typed_rust PROPERTIES @@ -462,16 +540,9 @@ if(CARGO_EXECUTABLE) RESOURCE_LOCK rust_service_tests ) - # Rust raw L2/L3 service tests without the dedicated stress slice + # Rust raw L2/L3 service tests; stress tests are #[ignore]d add_test(NAME test_service_rust - COMMAND ${CARGO_EXECUTABLE} test service::raw::tests:: -- - --test-threads=1 - --skip test_stress_1000_items - --skip test_stress_5000_items - --skip test_stress_cache_concurrent - --skip test_stress_concurrent_clients - --skip test_stress_long_running - --skip test_stress_rapid_connect_disconnect + COMMAND ${CARGO_EXECUTABLE} test service::raw:: WORKING_DIRECTORY "${NETIPC_RUST_CRATE_DIR}" ) set_tests_properties(test_service_rust PROPERTIES @@ -509,8 +580,8 @@ if(GO_EXECUTABLE AND NOT NETIPC_WINDOWS_RUNTIME) COMMENT "Building Go UDS interop binary" ) - # Go SHM transport tests (Linux only) - if(NOT APPLE) + # Go SHM transport tests (Linux, FreeBSD amd64 and 386) + if(NETIPC_GO_POSIX_SHM) add_test(NAME test_shm_go COMMAND ${CMAKE_COMMAND} -E env CGO_ENABLED=0 ${GO_EXECUTABLE} test -count=1 -timeout=60s @@ -569,9 +640,9 @@ if(NOT NETIPC_WINDOWS_RUNTIME AND CARGO_EXECUTABLE) ) endif() -# --- Cross-language SHM interop tests (Linux only) ------------------------- +# --- Cross-language SHM interop tests (Linux and FreeBSD) ------------------ -if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE AND CARGO_EXECUTABLE) +if(NETIPC_POSIX_SHM AND CARGO_EXECUTABLE) set(INTEROP_SHM_RS "${NETIPC_RUST_CRATE_DIR}/target/debug/interop_shm") set(INTEROP_SHM_GO "${CMAKE_BINARY_DIR}/bin/interop_shm_go") @@ -598,9 +669,9 @@ if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE AND CARGO_EXECUTABLE) endif() endif() -# --- Cross-language L2 service interop tests (Linux only) ------------------ +# --- Cross-language L2 service interop tests -------------------------------- -if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE AND CARGO_EXECUTABLE) +if(NETIPC_POSIX_RUNTIME AND CARGO_EXECUTABLE) set(INTEROP_SVC_RS "${NETIPC_RUST_CRATE_DIR}/target/debug/interop_service") set(INTEROP_SVC_GO "${CMAKE_BINARY_DIR}/bin/interop_service_go") @@ -613,19 +684,21 @@ if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE AND CARGO_EXECUTABLE) ENVIRONMENT "INTEROP_SVC_C=$;INTEROP_SVC_RS=${INTEROP_SVC_RS};INTEROP_SVC_GO=${INTEROP_SVC_GO}" ) - add_test(NAME test_lookup_scale_interop - COMMAND bash "${CMAKE_SOURCE_DIR}/tests/test_lookup_scale_interop.sh" - WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" - ) - set_tests_properties(test_lookup_scale_interop PROPERTIES - TIMEOUT 180 - ENVIRONMENT "INTEROP_SVC_C=$;INTEROP_SVC_RS=${INTEROP_SVC_RS};INTEROP_SVC_GO=${INTEROP_SVC_GO};NIPC_LOOKUP_SCALE_ITEMS=8192" - ) + if(NETIPC_EXTENDED_TESTS) + add_test(NAME test_lookup_scale_interop + COMMAND bash "${CMAKE_SOURCE_DIR}/tests/test_lookup_scale_interop.sh" + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + ) + set_tests_properties(test_lookup_scale_interop PROPERTIES + TIMEOUT 180 + ENVIRONMENT "INTEROP_SVC_C=$;INTEROP_SVC_RS=${INTEROP_SVC_RS};INTEROP_SVC_GO=${INTEROP_SVC_GO};NIPC_LOOKUP_SCALE_ITEMS=8192" + ) + endif() endif() -# --- L3 cache tests (Linux only) ------------------------------------------- +# --- L3 cache tests --------------------------------------------------------- -if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE) +if(NETIPC_POSIX_RUNTIME) add_executable(test_cache tests/fixtures/c/test_cache.c) target_link_libraries(test_cache PRIVATE netipc_service netipc_shm netipc_uds netipc_protocol Threads::Threads @@ -644,7 +717,7 @@ endif() # --- Rust L3 cache tests --------------------------------------------------- -if(CARGO_EXECUTABLE AND NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE) +if(CARGO_EXECUTABLE AND NETIPC_POSIX_RUNTIME) # Build the Rust L3 interop binary add_custom_target(interop_cache_rs ALL COMMAND ${CARGO_EXECUTABLE} build --bin interop_cache @@ -679,10 +752,10 @@ endif() # --- Go stress tests (Phase H4) --------------------------------------------- -if(GO_EXECUTABLE AND NOT NETIPC_WINDOWS_RUNTIME) +if(GO_EXECUTABLE AND NOT NETIPC_WINDOWS_RUNTIME AND NETIPC_EXTENDED_TESTS) add_test(NAME test_stress_go COMMAND ${CMAKE_COMMAND} -E env CGO_ENABLED=0 - ${GO_EXECUTABLE} test -count=1 -timeout=180s + ${GO_EXECUTABLE} test -count=1 -timeout=180s -tags netipc_extended -run "^TestStress" ./pkg/netipc/service/raw/ WORKING_DIRECTORY "${NETIPC_GO_PKG_DIR}" ) @@ -694,9 +767,9 @@ endif() # --- Rust stress tests (Phase H4) ------------------------------------------- -if(CARGO_EXECUTABLE AND NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE) +if(CARGO_EXECUTABLE AND NETIPC_POSIX_RUNTIME AND NETIPC_EXTENDED_TESTS) add_test(NAME test_stress_rust - COMMAND ${CARGO_EXECUTABLE} test test_stress -- --test-threads=1 + COMMAND ${CARGO_EXECUTABLE} test test_stress -- --ignored --test-threads=1 WORKING_DIRECTORY "${NETIPC_RUST_CRATE_DIR}" ) set_tests_properties(test_stress_rust PROPERTIES @@ -705,9 +778,9 @@ if(CARGO_EXECUTABLE AND NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE) ) endif() -# --- Cross-language L2 service SHM interop tests (Linux only) --------------- +# --- Cross-language L2 service SHM interop tests (Linux and FreeBSD) -------- -if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE AND CARGO_EXECUTABLE) +if(NETIPC_POSIX_SHM AND CARGO_EXECUTABLE) add_test(NAME test_service_shm_interop COMMAND bash "${CMAKE_SOURCE_DIR}/tests/test_service_shm_interop.sh" WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" @@ -717,19 +790,21 @@ if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE AND CARGO_EXECUTABLE) ENVIRONMENT "INTEROP_SVC_C=$;INTEROP_SVC_RS=${INTEROP_SVC_RS};INTEROP_SVC_GO=${INTEROP_SVC_GO}" ) - add_test(NAME test_lookup_scale_shm_interop - COMMAND bash "${CMAKE_SOURCE_DIR}/tests/test_lookup_scale_interop.sh" - WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" - ) - set_tests_properties(test_lookup_scale_shm_interop PROPERTIES - TIMEOUT 180 - ENVIRONMENT "INTEROP_SVC_C=$;INTEROP_SVC_RS=${INTEROP_SVC_RS};INTEROP_SVC_GO=${INTEROP_SVC_GO};NIPC_LOOKUP_SCALE_ITEMS=8192;NIPC_PROFILE=shm" - ) + if(NETIPC_EXTENDED_TESTS) + add_test(NAME test_lookup_scale_shm_interop + COMMAND bash "${CMAKE_SOURCE_DIR}/tests/test_lookup_scale_interop.sh" + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + ) + set_tests_properties(test_lookup_scale_shm_interop PROPERTIES + TIMEOUT 180 + ENVIRONMENT "INTEROP_SVC_C=$;INTEROP_SVC_RS=${INTEROP_SVC_RS};INTEROP_SVC_GO=${INTEROP_SVC_GO};NIPC_LOOKUP_SCALE_ITEMS=8192;NIPC_PROFILE=shm" + ) + endif() endif() -# --- Cross-language L3 cache interop tests (Linux only) --------------------- +# --- Cross-language L3 cache interop tests ---------------------------------- -if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE AND CARGO_EXECUTABLE) +if(NETIPC_POSIX_RUNTIME AND CARGO_EXECUTABLE) set(INTEROP_CACHE_RS "${NETIPC_RUST_CRATE_DIR}/target/debug/interop_cache") set(INTEROP_CACHE_GO "${CMAKE_BINARY_DIR}/bin/interop_cache_go") @@ -743,14 +818,16 @@ if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE AND CARGO_EXECUTABLE) ) # SHM variant - add_test(NAME test_cache_shm_interop - COMMAND bash "${CMAKE_SOURCE_DIR}/tests/test_cache_shm_interop.sh" - WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" - ) - set_tests_properties(test_cache_shm_interop PROPERTIES - TIMEOUT 120 - ENVIRONMENT "INTEROP_CACHE_C=$;INTEROP_CACHE_RS=${INTEROP_CACHE_RS};INTEROP_CACHE_GO=${INTEROP_CACHE_GO}" - ) + if(NETIPC_POSIX_SHM) + add_test(NAME test_cache_shm_interop + COMMAND bash "${CMAKE_SOURCE_DIR}/tests/test_cache_shm_interop.sh" + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + ) + set_tests_properties(test_cache_shm_interop PROPERTIES + TIMEOUT 120 + ENVIRONMENT "INTEROP_CACHE_C=$;INTEROP_CACHE_RS=${INTEROP_CACHE_RS};INTEROP_CACHE_GO=${INTEROP_CACHE_GO}" + ) + endif() endif() # --- L1 Windows Named Pipe transport tests --------------------------------- @@ -758,6 +835,7 @@ endif() if(NETIPC_WINDOWS_RUNTIME) add_executable(test_named_pipe tests/fixtures/c/test_named_pipe.c) target_link_libraries(test_named_pipe PRIVATE netipc_named_pipe netipc_protocol) + target_compile_definitions(test_named_pipe PRIVATE NIPC_INTERNAL_TESTING=1) add_test(NAME test_named_pipe COMMAND test_named_pipe) set_tests_properties(test_named_pipe PROPERTIES TIMEOUT 30) @@ -852,6 +930,7 @@ if(NETIPC_WINDOWS_RUNTIME) target_link_libraries(test_win_service PRIVATE netipc_service_win netipc_win_shm netipc_named_pipe netipc_protocol ) + target_compile_definitions(test_win_service PRIVATE NIPC_INTERNAL_TESTING=1) add_test(NAME test_win_service COMMAND test_win_service) set_tests_properties(test_win_service PROPERTIES TIMEOUT 120 @@ -876,12 +955,25 @@ if(NETIPC_WINDOWS_RUNTIME) target_link_libraries(test_win_service_payload_limits PRIVATE netipc_service_win netipc_win_shm netipc_named_pipe netipc_protocol ) + target_compile_definitions(test_win_service_payload_limits PRIVATE NIPC_INTERNAL_TESTING=1) add_test(NAME test_win_service_payload_limits COMMAND test_win_service_payload_limits) set_tests_properties(test_win_service_payload_limits PROPERTIES TIMEOUT 120 RUN_SERIAL TRUE ) + # Robustness reproduction tests (SOW-0040) + add_executable(test_win_robustness tests/fixtures/c/test_win_robustness.c) + target_link_libraries(test_win_robustness PRIVATE + netipc_service_win netipc_win_shm netipc_named_pipe netipc_protocol + ) + target_compile_definitions(test_win_robustness PRIVATE NIPC_INTERNAL_TESTING=1) + add_test(NAME test_win_robustness COMMAND test_win_robustness) + set_tests_properties(test_win_robustness PROPERTIES + TIMEOUT 180 + RUN_SERIAL TRUE + ) + # Coverage-only Windows C guard executable. It exercises deterministic # service-side/client-side guard paths without perturbing the normal # non-coverage ctest timing of test_win_service_extra. @@ -902,8 +994,10 @@ if(NETIPC_WINDOWS_RUNTIME) netipc_service_win netipc_win_shm netipc_named_pipe netipc_protocol ) target_compile_definitions(test_win_stress PRIVATE NIPC_INTERNAL_TESTING=1) - add_test(NAME test_win_stress COMMAND test_win_stress) - set_tests_properties(test_win_stress PROPERTIES TIMEOUT 120) + if(NETIPC_EXTENDED_TESTS) + add_test(NAME test_win_stress COMMAND test_win_stress) + set_tests_properties(test_win_stress PROPERTIES TIMEOUT 120) + endif() # C Windows SHM interop binary add_executable(interop_win_shm_c tests/fixtures/c/interop_win_shm.c) @@ -1030,25 +1124,29 @@ if(NETIPC_WINDOWS_RUNTIME AND CARGO_EXECUTABLE) ENVIRONMENT "INTEROP_SVC_C=$;INTEROP_SVC_RS=${INTEROP_SVC_WIN_RS};INTEROP_SVC_GO=${INTEROP_SVC_WIN_GO}" ) - add_test(NAME test_lookup_scale_win_interop - COMMAND bash "${CMAKE_SOURCE_DIR}/tests/test_lookup_scale_win_interop.sh" - WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" - ) - set_tests_properties(test_lookup_scale_win_interop PROPERTIES - TIMEOUT 1800 - RESOURCE_LOCK win_service_interop_tests - ENVIRONMENT "INTEROP_SVC_C=$;INTEROP_SVC_RS=${INTEROP_SVC_WIN_RS};INTEROP_SVC_GO=${INTEROP_SVC_WIN_GO};NIPC_LOOKUP_SCALE_ITEMS=8192" - ) + if(NETIPC_EXTENDED_TESTS) + add_test(NAME test_lookup_scale_win_interop + COMMAND bash "${CMAKE_SOURCE_DIR}/tests/test_lookup_scale_win_interop.sh" + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + ) + set_tests_properties(test_lookup_scale_win_interop PROPERTIES + TIMEOUT 1800 + RESOURCE_LOCK win_service_interop_tests + ENVIRONMENT "INTEROP_SVC_C=$;INTEROP_SVC_RS=${INTEROP_SVC_WIN_RS};INTEROP_SVC_GO=${INTEROP_SVC_WIN_GO};NIPC_LOOKUP_SCALE_ITEMS=8192" + ) + endif() - add_test(NAME test_lookup_scale_win_shm_interop - COMMAND bash "${CMAKE_SOURCE_DIR}/tests/test_lookup_scale_win_shm_interop.sh" - WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" - ) - set_tests_properties(test_lookup_scale_win_shm_interop PROPERTIES - TIMEOUT 1800 - RESOURCE_LOCK win_service_interop_tests - ENVIRONMENT "INTEROP_SVC_C=$;INTEROP_SVC_RS=${INTEROP_SVC_WIN_RS};INTEROP_SVC_GO=${INTEROP_SVC_WIN_GO};NIPC_LOOKUP_SCALE_ITEMS=8192" - ) + if(NETIPC_EXTENDED_TESTS) + add_test(NAME test_lookup_scale_win_shm_interop + COMMAND bash "${CMAKE_SOURCE_DIR}/tests/test_lookup_scale_win_shm_interop.sh" + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + ) + set_tests_properties(test_lookup_scale_win_shm_interop PROPERTIES + TIMEOUT 1800 + RESOURCE_LOCK win_service_interop_tests + ENVIRONMENT "INTEROP_SVC_C=$;INTEROP_SVC_RS=${INTEROP_SVC_WIN_RS};INTEROP_SVC_GO=${INTEROP_SVC_WIN_GO};NIPC_LOOKUP_SCALE_ITEMS=8192" + ) + endif() endif() # --- Cross-language Windows L3 cache interop tests -------------------------- @@ -1080,8 +1178,9 @@ if(NETIPC_WINDOWS_RUNTIME AND CARGO_EXECUTABLE) endif() # --- Benchmarks (Phase 13) ------------------------------------------------ +# POSIX benchmarks cover the SHM profiles, so they build where SHM does. -if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE) +if(NETIPC_POSIX_SHM) # C benchmark binary add_executable(bench_posix_c bench/drivers/c/bench_posix.c) target_include_directories(bench_posix_c PRIVATE @@ -1094,7 +1193,7 @@ if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE) endif() # Rust benchmark binary (release build for accurate performance) -if(CARGO_EXECUTABLE AND NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE) +if(CARGO_EXECUTABLE AND NETIPC_POSIX_SHM) add_custom_target(bench_posix_rs COMMAND ${CARGO_EXECUTABLE} build --release --bin bench_posix WORKING_DIRECTORY "${NETIPC_RUST_CRATE_DIR}" @@ -1103,7 +1202,7 @@ if(CARGO_EXECUTABLE AND NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE) endif() # Go benchmark binary -if(GO_EXECUTABLE AND NOT NETIPC_WINDOWS_RUNTIME) +if(GO_EXECUTABLE AND NETIPC_POSIX_SHM) add_custom_target(bench_posix_go ALL COMMAND ${CMAKE_COMMAND} -E env CGO_ENABLED=0 ${GO_EXECUTABLE} build -o "${CMAKE_BINARY_DIR}/bin/bench_posix_go" . @@ -1113,7 +1212,7 @@ if(GO_EXECUTABLE AND NOT NETIPC_WINDOWS_RUNTIME) endif() # Benchmark runner (not CTest — too slow for CI) -if(NOT NETIPC_WINDOWS_RUNTIME AND NOT APPLE) +if(NETIPC_POSIX_SHM) add_custom_target(run-posix-bench COMMAND bash "${CMAKE_SOURCE_DIR}/tests/run-posix-bench.sh" WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" diff --git a/README.md b/README.md index e1318f60..8a8f6b24 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,9 @@ See: | Platform | Baseline transport | Negotiated fast path | Languages | |---|---|---|---| -| POSIX / Linux | Unix domain `SOCK_SEQPACKET` | POSIX shared memory | C, Rust, Go | +| Linux | Unix domain `SOCK_SEQPACKET` | POSIX shared memory | C, Rust, Go | +| FreeBSD | Unix domain `SOCK_STREAM`, same packets | POSIX shared memory (Go: amd64 and 386 only; run directory on tmpfs or UFS, not ZFS) | C, Rust, Go | +| macOS | Unix domain `SOCK_STREAM`, same packets | none | C, Rust, Go | | Windows | Named Pipes | Windows shared memory | C, Rust, Go | Important facts: @@ -357,8 +359,8 @@ Recommended reading order: The standalone Rust package uses Rust edition 2024 and requires Rust 1.91 or newer. Use the latest stable toolchain for normal development; CI also compiles every target with Rust 1.91.0 to enforce the minimum. The repository -`rustfmt.toml` keeps formatting on style edition 2021 independently of the -language edition. +`rustfmt.toml` selects style edition 2024 explicitly, matching the language +edition and Netdata's Rust workspace. ### Linux / POSIX diff --git a/bench/drivers/c/bench_posix.c b/bench/drivers/c/bench_posix.c index 23478a1f..8436aa6d 100644 --- a/bench/drivers/c/bench_posix.c +++ b/bench/drivers/c/bench_posix.c @@ -99,6 +99,31 @@ static inline void latency_record(latency_recorder_t *lr, uint64_t ns) lr->samples[lr->count++] = ns; } +/* A duration outside the range would wrap the nanosecond deadline + * arithmetic; reject it up front. */ +static int parse_duration_from(const char *arg, long min) +{ + char *end = NULL; + errno = 0; + long v = strtol(arg, &end, 10); + if (errno != 0 || end == arg || *end != '\0' || v < min || v > 86400L * 365) { + fprintf(stderr, "invalid duration '%s'\n", arg); + exit(1); + } + return (int)v; +} + +static int parse_duration(const char *arg) +{ + return parse_duration_from(arg, 1); +} + +/* Zero runs a server without its stop timer. */ +static int parse_server_duration(const char *arg) +{ + return parse_duration_from(arg, 0); +} + static int cmp_u64(const void *a, const void *b) { uint64_t va = *(const uint64_t *)a; @@ -1397,7 +1422,7 @@ static int run_lookup_method_bench(int duration_sec, static void usage(const char *prog) { fprintf(stderr, - "Usage:\n" + "Usage (a server duration_sec of 0 runs without a stop timer):\n" " %s uds-ping-pong-server [duration_sec]\n" " %s uds-ping-pong-client \n" " %s shm-ping-pong-server [duration_sec]\n" @@ -1438,7 +1463,7 @@ int main(int argc, char **argv) if (nipc_test_resolve_run_dir(argv[2], run_dir, sizeof(run_dir)) != 0) return 1; const char *service = argv[3]; - int duration = (argc >= 5) ? atoi(argv[4]) : DEFAULT_DURATION; + int duration = (argc >= 5) ? parse_server_duration(argv[4]) : DEFAULT_DURATION; uint32_t profiles; uint16_t expected_method_code; @@ -1479,7 +1504,7 @@ int main(int argc, char **argv) if (nipc_test_resolve_run_dir(argv[2], run_dir, sizeof(run_dir)) != 0) return 1; const char *service = argv[3]; - int duration = atoi(argv[4]); + int duration = parse_duration(argv[4]); uint64_t target_rps = (uint64_t)strtoull(argv[5], NULL, 10); uint32_t profiles; @@ -1512,7 +1537,7 @@ int main(int argc, char **argv) if (nipc_test_resolve_run_dir(argv[2], run_dir, sizeof(run_dir)) != 0) return 1; const char *service = argv[3]; - int duration = atoi(argv[4]); + int duration = parse_duration(argv[4]); uint64_t target_rps = (uint64_t)strtoull(argv[5], NULL, 10); uint32_t profiles; @@ -1544,7 +1569,7 @@ int main(int argc, char **argv) if (nipc_test_resolve_run_dir(argv[2], run_dir, sizeof(run_dir)) != 0) return 1; const char *service = argv[3]; - int duration = (argc >= 5) ? atoi(argv[4]) : DEFAULT_DURATION; + int duration = (argc >= 5) ? parse_server_duration(argv[4]) : DEFAULT_DURATION; uint32_t profiles; if (strcmp(cmd, "uds-batch-ping-pong-server") == 0) @@ -1570,7 +1595,7 @@ int main(int argc, char **argv) if (nipc_test_resolve_run_dir(argv[2], run_dir, sizeof(run_dir)) != 0) return 1; const char *service = argv[3]; - int duration = atoi(argv[4]); + int duration = parse_duration(argv[4]); uint64_t target_rps = (uint64_t)strtoull(argv[5], NULL, 10); uint32_t profiles; @@ -1600,7 +1625,7 @@ int main(int argc, char **argv) if (nipc_test_resolve_run_dir(argv[2], run_dir, sizeof(run_dir)) != 0) return 1; const char *service = argv[3]; - int duration = atoi(argv[4]); + int duration = parse_duration(argv[4]); uint64_t target_rps = (uint64_t)strtoull(argv[5], NULL, 10); int depth = atoi(argv[6]); if (depth < 1) depth = 1; @@ -1743,7 +1768,7 @@ int main(int argc, char **argv) usage(argv[0]); return 1; } - int duration = atoi(argv[2]); + int duration = parse_duration(argv[2]); return run_lookup_bench(duration); } @@ -1752,7 +1777,7 @@ int main(int argc, char **argv) usage(argv[0]); return 1; } - int duration = atoi(argv[2]); + int duration = parse_duration(argv[2]); const char *scenario = argv[3]; uint64_t target_rps = (uint64_t)strtoull(argv[4], NULL, 10); return run_lookup_method_bench(duration, scenario, target_rps); @@ -1769,7 +1794,7 @@ int main(int argc, char **argv) if (nipc_test_resolve_run_dir(argv[2], run_dir, sizeof(run_dir)) != 0) return 1; const char *service = argv[3]; - int duration = atoi(argv[4]); + int duration = parse_duration(argv[4]); uint64_t target_rps = (uint64_t)strtoull(argv[5], NULL, 10); int depth = atoi(argv[6]); diff --git a/bench/drivers/c/bench_windows.c b/bench/drivers/c/bench_windows.c index 815d751e..9234bfff 100644 --- a/bench/drivers/c/bench_windows.c +++ b/bench/drivers/c/bench_windows.c @@ -40,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -69,6 +70,63 @@ #endif #endif +#ifdef __MSYS__ +#include + +/* The MSYS runtime does not support threads started with CreateThread: + * under load such a thread may never run and the process dies silently. + * Start threads with pthread_create; each duplicates its own handle for the + * creator, so callers keep waiting on and closing a real thread handle. */ +typedef struct { + LPTHREAD_START_ROUTINE fn; + void *arg; + HANDLE started; + HANDLE thread; +} bench_thread_start_t; + +static void *bench_thread_trampoline(void *p) +{ + bench_thread_start_t *start = p; + LPTHREAD_START_ROUTINE fn = start->fn; + void *arg = start->arg; + HANDLE thread = NULL; + if (!DuplicateHandle(GetCurrentProcess(), GetCurrentThread(), GetCurrentProcess(), + &thread, 0, FALSE, DUPLICATE_SAME_ACCESS)) + thread = NULL; + start->thread = thread; + /* start lives on the creator's stack: no access after this. */ + SetEvent(start->started); + if (thread) + fn(arg); + return NULL; +} + +static HANDLE bench_create_thread(LPTHREAD_START_ROUTINE fn, void *arg) +{ + bench_thread_start_t start = { + .fn = fn, + .arg = arg, + .started = CreateEventW(NULL, TRUE, FALSE, NULL), + }; + if (!start.started) + return NULL; + pthread_t thread; + if (pthread_create(&thread, NULL, bench_thread_trampoline, &start) != 0) { + CloseHandle(start.started); + return NULL; + } + pthread_detach(thread); + WaitForSingleObject(start.started, INFINITE); + CloseHandle(start.started); + return start.thread; +} +#else +static HANDLE bench_create_thread(LPTHREAD_START_ROUTINE fn, void *arg) +{ + return CreateThread(NULL, 0, fn, arg, 0, NULL); +} +#endif + /* ------------------------------------------------------------------ */ /* Constants */ /* ------------------------------------------------------------------ */ @@ -234,6 +292,31 @@ static inline void latency_record(latency_recorder_t *lr, uint64_t ns) lr->samples[lr->count++] = ns; } +/* Reject a duration the driver cannot time. The cap keeps the timer + * thread's DWORD millisecond wait from wrapping. */ +static int parse_duration_from(const char *arg, long min) +{ + char *end = NULL; + errno = 0; + long v = strtol(arg, &end, 10); + if (errno != 0 || end == arg || *end != '\0' || v < min || v > 4000000L) { + fprintf(stderr, "invalid duration '%s'\n", arg); + exit(1); + } + return (int)v; +} + +static int parse_duration(const char *arg) +{ + return parse_duration_from(arg, 1); +} + +/* Zero runs a server without its stop timer. */ +static int parse_server_duration(const char *arg) +{ + return parse_duration_from(arg, 0); +} + static int cmp_u64(const void *a, const void *b) { uint64_t va = *(const uint64_t *)a; @@ -535,7 +618,7 @@ static HANDLE start_timer_thread(int duration_sec, bench_timer_ctx_t *ctx) if (!ctx->cancel_event) return NULL; - HANDLE timer = CreateThread(NULL, 0, timer_thread, ctx, 0, NULL); + HANDLE timer = bench_create_thread(timer_thread, ctx); if (!timer) { CloseHandle(ctx->cancel_event); ctx->cancel_event = NULL; @@ -666,7 +749,7 @@ static int run_server(const char *run_dir, const char *service, bench_server_thread_ctx_t thread_ctx = { .server = &server, }; - HANDLE server_thread = CreateThread(NULL, 0, bench_server_thread, &thread_ctx, 0, NULL); + HANDLE server_thread = bench_create_thread(bench_server_thread, &thread_ctx); if (!server_thread) { fprintf(stderr, "server thread create failed: %lu\n", (unsigned long)GetLastError()); g_server = NULL; @@ -752,7 +835,7 @@ static int run_server_batch(const char *run_dir, const char *service, bench_server_thread_ctx_t thread_ctx = { .server = &server, }; - HANDLE server_thread = CreateThread(NULL, 0, bench_server_thread, &thread_ctx, 0, NULL); + HANDLE server_thread = bench_create_thread(bench_server_thread, &thread_ctx); if (!server_thread) { fprintf(stderr, "batch server thread create failed: %lu\n", (unsigned long)GetLastError()); @@ -1524,7 +1607,7 @@ static int run_lookup_bench(int duration_sec) static void usage(const char *prog) { fprintf(stderr, - "Usage:\n" + "Usage (a server duration_sec of 0 runs without a stop timer):\n" " %s np-ping-pong-server [duration_sec]\n" " %s np-ping-pong-client \n" " %s shm-ping-pong-server [duration_sec]\n" @@ -1570,7 +1653,7 @@ int main(int argc, char **argv) const char *run_dir = argv[2]; const char *service = argv[3]; - int duration = (argc >= 5) ? atoi(argv[4]) : DEFAULT_DURATION; + int duration = (argc >= 5) ? parse_server_duration(argv[4]) : DEFAULT_DURATION; CreateDirectoryA(run_dir, NULL); @@ -1611,7 +1694,7 @@ int main(int argc, char **argv) const char *run_dir = argv[2]; const char *service = argv[3]; - int duration = (argc >= 5) ? atoi(argv[4]) : DEFAULT_DURATION; + int duration = (argc >= 5) ? parse_server_duration(argv[4]) : DEFAULT_DURATION; CreateDirectoryA(run_dir, NULL); @@ -1637,7 +1720,7 @@ int main(int argc, char **argv) const char *run_dir = argv[2]; const char *service = argv[3]; - int duration = atoi(argv[4]); + int duration = parse_duration(argv[4]); uint64_t target_rps = (uint64_t)strtoull(argv[5], NULL, 10); uint32_t profiles; @@ -1667,7 +1750,7 @@ int main(int argc, char **argv) const char *run_dir = argv[2]; const char *service = argv[3]; - int duration = atoi(argv[4]); + int duration = parse_duration(argv[4]); uint64_t target_rps = (uint64_t)strtoull(argv[5], NULL, 10); uint32_t profiles; @@ -1697,7 +1780,7 @@ int main(int argc, char **argv) const char *run_dir = argv[2]; const char *service = argv[3]; - int duration = atoi(argv[4]); + int duration = parse_duration(argv[4]); uint64_t target_rps = (uint64_t)strtoull(argv[5], NULL, 10); uint32_t profiles; @@ -1725,7 +1808,7 @@ int main(int argc, char **argv) const char *run_dir = argv[2]; const char *service = argv[3]; - int duration = atoi(argv[4]); + int duration = parse_duration(argv[4]); uint64_t target_rps = (uint64_t)strtoull(argv[5], NULL, 10); int depth = atoi(argv[6]); if (depth < 1) depth = 1; @@ -1880,7 +1963,7 @@ int main(int argc, char **argv) const char *run_dir = argv[2]; const char *service = argv[3]; - int duration = atoi(argv[4]); + int duration = parse_duration(argv[4]); uint64_t target_rps = (uint64_t)strtoull(argv[5], NULL, 10); int depth = atoi(argv[6]); if (depth < 1) depth = 1; @@ -1925,7 +2008,6 @@ int main(int argc, char **argv) uint8_t *req_bufs[128]; uint8_t *recv_buf = NULL; size_t req_lens[128]; - size_t slot_costs[128]; uint32_t batch_sizes[128]; uint64_t slot_msg_ids[128]; bool slot_active[128]; @@ -1948,20 +2030,16 @@ int main(int argc, char **argv) uint64_t cpu_start = cpu_ns(); uint64_t wall_start = now_ns(); ULONGLONG tick_deadline = GetTickCount64() + (ULONGLONG)duration * 1000; - size_t inflight_budget = (size_t)session.packet_size * 2; while (GetTickCount64() < tick_deadline) { rate_limiter_wait(&rl); uint64_t t0 = (total_items & 63) == 0 ? now_ns() : 0; - /* Keep the logical pipeline depth, but drain responses once the - * estimated request+response bytes in flight get too large for a - * blocking named pipe session. */ + /* Send the whole depth, then read the responses: the client + * send reads responses while its write waits for pipe space. */ memset(slot_active, 0, sizeof(slot_active)); int sent = 0; int received = 0; - int outstanding = 0; - size_t inflight_bytes = 0; int cycle_ok = 1; while (received < depth) { @@ -1991,46 +2069,6 @@ int main(int argc, char **argv) hdrs[slot].transport_status = NIPC_STATUS_OK; slot_msg_ids[slot] = hdrs[slot].message_id; - slot_costs[slot] = (NIPC_HEADER_LEN + req_lens[slot]) * 2; - - while (outstanding > 0 && - inflight_bytes + slot_costs[slot] > inflight_budget) { - nipc_header_t resp_hdr; - const void *resp_payload; - size_t resp_len; - nipc_np_error_t uerr = nipc_np_receive(&session, - recv_buf, BENCH_BATCH_BUF_SIZE + NIPC_HEADER_LEN, - &resp_hdr, &resp_payload, &resp_len); - if (uerr != NIPC_NP_OK) { - errors++; - cycle_ok = 0; - break; - } - - int matched = -1; - for (int i = 0; i < depth; i++) { - if (slot_active[i] && slot_msg_ids[i] == resp_hdr.message_id) { - matched = i; - break; - } - } - if (matched < 0) { - fprintf(stderr, "pipeline-batch client: unknown response id %llu\n", - (unsigned long long)resp_hdr.message_id); - errors++; - cycle_ok = 0; - break; - } - - inflight_bytes -= slot_costs[matched]; - total_items += batch_sizes[matched]; - slot_active[matched] = false; - outstanding--; - received++; - } - if (!cycle_ok) - break; - nipc_np_error_t uerr = nipc_np_send(&session, &hdrs[slot], req_bufs[slot], req_lens[slot]); if (uerr != NIPC_NP_OK) { @@ -2040,8 +2078,6 @@ int main(int argc, char **argv) } slot_active[slot] = true; - inflight_bytes += slot_costs[slot]; - outstanding++; counter += bs; sent++; continue; @@ -2074,10 +2110,8 @@ int main(int argc, char **argv) break; } - inflight_bytes -= slot_costs[matched]; total_items += batch_sizes[matched]; slot_active[matched] = false; - outstanding--; received++; } @@ -2122,7 +2156,7 @@ int main(int argc, char **argv) usage(argv[0]); return 1; } - return run_lookup_bench(atoi(argv[2])); + return run_lookup_bench(parse_duration(argv[2])); } fprintf(stderr, "Unknown subcommand: %s\n", cmd); diff --git a/bench/drivers/go/duration.go b/bench/drivers/go/duration.go new file mode 100644 index 00000000..a7ca51d8 --- /dev/null +++ b/bench/drivers/go/duration.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + "os" + "strconv" +) + +// maxDurationSec matches the C POSIX driver's one-year bound. +const maxDurationSec = 86400 * 365 + +// parseDurationArg exits on a duration outside 1..maxDurationSec: a value +// outside it would wrap the deadline and timer arithmetic. +func parseDurationArg(arg string) int { + d, err := strconv.Atoi(arg) + if err != nil || d <= 0 || d > maxDurationSec { + fmt.Fprintf(os.Stderr, "invalid duration %q: want a positive number of seconds\n", arg) + os.Exit(1) + } + return d +} diff --git a/bench/drivers/go/go.mod b/bench/drivers/go/go.mod index 5d28ce72..da936c1f 100644 --- a/bench/drivers/go/go.mod +++ b/bench/drivers/go/go.mod @@ -1,7 +1,9 @@ module github.com/netdata/plugin-ipc/bench/drivers/go -go 1.26.0 +go 1.27.0 require github.com/netdata/plugin-ipc/go v0.0.0 +require golang.org/x/sys v0.49.0 // indirect + replace github.com/netdata/plugin-ipc/go => ../../../src/go diff --git a/bench/drivers/go/go.sum b/bench/drivers/go/go.sum new file mode 100644 index 00000000..d61accff --- /dev/null +++ b/bench/drivers/go/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/bench/drivers/go/main.go b/bench/drivers/go/main.go index 069f8604..6f49de52 100644 --- a/bench/drivers/go/main.go +++ b/bench/drivers/go/main.go @@ -90,8 +90,30 @@ func durationNanos(d time.Duration) uint64 { return uint64(d.Nanoseconds()) // #nosec G115 -- d is checked non-negative above. } -func recordLatency(lr *latencyRecorder, start, end time.Time) { - lr.record(durationNanos(end.Sub(start))) +func recordLatency(lr *latencyRecorder, start, end uint64) { + if end > start { + lr.record(end - start) + } else { + lr.record(0) + } +} + +// monoBase anchors nowNs; time.Since on a time carrying a monotonic reading +// reads only the monotonic clock, one read per call like the C harness. +var monoBase = time.Now() + +// nowNs returns monotonic nanoseconds since process start. +func nowNs() uint64 { + return durationNanos(time.Since(monoBase)) +} + +// loopNow is a client loop's clock: the end time of the request the +// iteration just recorded, or a fresh reading when it recorded none. +func loopNow(end uint64) uint64 { + if end != 0 { + return end + } + return nowNs() } func estimateSamples(defaultSamples int, targetRPS uint64, durationSec int) int { @@ -167,8 +189,8 @@ func (lr *latencyRecorder) percentile(pct float64) uint64 { // --------------------------------------------------------------------------- type rateLimiter struct { - interval time.Duration - next time.Time + interval uint64 // nanoseconds + next uint64 // nowNs time of the next send limited bool } @@ -178,8 +200,8 @@ func newRateLimiter(targetRPS uint64) *rateLimiter { } intervalNs := 1_000_000_000 / targetRPS return &rateLimiter{ - interval: time.Duration(intervalNs) * time.Nanosecond, // #nosec G115 -- intervalNs is bounded by 1e9. - next: time.Now(), + interval: intervalNs, + next: nowNs(), limited: true, } } @@ -188,11 +210,11 @@ func (rl *rateLimiter) wait() { if !rl.limited { return } - now := time.Now() - if now.Before(rl.next) { - time.Sleep(rl.next.Sub(now)) + now := nowNs() + if now < rl.next { + time.Sleep(time.Duration(rl.next - now)) // #nosec G115 -- the wait is below one interval, at most 1e9 ns. } - rl.next = rl.next.Add(rl.interval) + rl.next += rl.interval } // --------------------------------------------------------------------------- @@ -482,10 +504,16 @@ func runBatchPingPongClient(runDir, service string, profiles uint32, durationSec var counter, totalItems, errors, msgSeq uint64 cpuStart := cpuNS() - wallStart := time.Now() - deadline := time.Duration(durationSec) * time.Second + wallStart := nowNs() + deadline := wallStart + uint64(durationSec)*1_000_000_000 // #nosec G115 -- durationSec is a small positive CLI value. + var end uint64 - for time.Since(wallStart) < deadline { + // Declared once: Send takes &hdr, so it escapes to the heap; inside the + // loop that would be one allocation per request. + var hdr protocol.Header + + for now := wallStart; now < deadline; now = loopNow(end) { + end = 0 rl.wait() // Random batch size 2-1000 (server treats itemCount==1 as non-batch) @@ -514,7 +542,7 @@ func runBatchPingPongClient(runDir, service string, profiles uint32, durationSec reqLen, _ := bb.Finish() msgSeq++ - hdr := protocol.Header{ + hdr = protocol.Header{ Kind: protocol.KindRequest, Code: protocol.MethodIncrement, Flags: protocol.FlagBatch, @@ -523,7 +551,7 @@ func runBatchPingPongClient(runDir, service string, profiles uint32, durationSec TransportStatus: protocol.StatusOK, } - t0 := time.Now() + t0 := nowNs() if shm != nil { // SHM path @@ -605,7 +633,8 @@ func runBatchPingPongClient(runDir, service string, profiles uint32, durationSec } } - t1 := time.Now() + t1 := nowNs() + end = t1 recordLatency(lr, t0, t1) _ = batchOK totalItems += uint64(batchSize) @@ -662,7 +691,8 @@ func runBatchPingPongClient(runDir, service string, profiles uint32, durationSec } } - t1 := time.Now() + t1 := nowNs() + end = t1 recordLatency(lr, t0, t1) _ = batchOK totalItems += uint64(batchSize) @@ -671,7 +701,7 @@ func runBatchPingPongClient(runDir, service string, profiles uint32, durationSec counter += uint64(batchSize) } - wallSec := time.Since(wallStart).Seconds() + wallSec := float64(nowNs()-wallStart) / 1e9 cpuEnd := cpuNS() cpuSec := float64(cpuEnd-cpuStart) / 1e9 throughput := float64(totalItems) / wallSec @@ -720,16 +750,22 @@ func runPipelineClient(runDir, service string, durationSec int, targetRPS uint64 var counter, requests, errors, msgSeq uint64 cpuStart := cpuNS() - wallStart := time.Now() - deadline := time.Duration(durationSec) * time.Second + wallStart := nowNs() + deadline := wallStart + uint64(durationSec)*1_000_000_000 // #nosec G115 -- durationSec is a small positive CLI value. + var end uint64 recvBuf := make([]byte, 256) var reqPayload [8]byte - for time.Since(wallStart) < deadline { + // Declared once: Send takes &hdr, so it escapes to the heap; inside the + // loop that would be one allocation per request. + var hdr protocol.Header + + for now := wallStart; now < deadline; now = loopNow(end) { + end = 0 rl.wait() - t0 := time.Now() + t0 := nowNs() // Send `depth` requests with unique message IDs sendOK := true @@ -738,7 +774,7 @@ func runPipelineClient(runDir, service string, durationSec int, targetRPS uint64 binary.NativeEndian.PutUint64(reqPayload[:], val) msgSeq++ - hdr := protocol.Header{ + hdr = protocol.Header{ Kind: protocol.KindRequest, Code: protocol.MethodIncrement, ItemCount: 1, @@ -777,7 +813,8 @@ func runPipelineClient(runDir, service string, durationSec int, targetRPS uint64 } } - t1 := time.Now() + t1 := nowNs() + end = t1 recordLatency(lr, t0, t1) depth64, ok := u64FromNonNegativeInt(depth) @@ -789,7 +826,7 @@ func runPipelineClient(runDir, service string, durationSec int, targetRPS uint64 requests += depth64 } - wallSec := time.Since(wallStart).Seconds() + wallSec := float64(nowNs()-wallStart) / 1e9 cpuEnd := cpuNS() cpuSec := float64(cpuEnd-cpuStart) / 1e9 throughput := float64(requests) / wallSec @@ -848,13 +885,19 @@ func runPipelineBatchClient(runDir, service string, durationSec int, targetRPS u var counter, totalItems, errors, msgSeq uint64 cpuStart := cpuNS() - wallStart := time.Now() - deadline := time.Duration(durationSec) * time.Second + wallStart := nowNs() + deadline := wallStart + uint64(durationSec)*1_000_000_000 // #nosec G115 -- durationSec is a small positive CLI value. + var end uint64 - for time.Since(wallStart) < deadline { + // Declared once: Send takes &hdr, so it escapes to the heap; inside the + // loop that would be one allocation per request. + var hdr protocol.Header + + for now := wallStart; now < deadline; now = loopNow(end) { + end = 0 rl.wait() - t0 := time.Now() + t0 := nowNs() // Build and send `depth` batch requests sendOK := true @@ -880,7 +923,7 @@ func runPipelineBatchClient(runDir, service string, durationSec int, targetRPS u reqLen, _ := bb.Finish() msgSeq++ - hdr := protocol.Header{ + hdr = protocol.Header{ Kind: protocol.KindRequest, Code: protocol.MethodIncrement, Flags: protocol.FlagBatch, @@ -912,11 +955,12 @@ func runPipelineBatchClient(runDir, service string, durationSec int, targetRPS u totalItems += uint64(batchSizes[d]) } - t1 := time.Now() + t1 := nowNs() + end = t1 recordLatency(lr, t0, t1) } - wallSec := time.Since(wallStart).Seconds() + wallSec := float64(nowNs()-wallStart) / 1e9 cpuEnd := cpuNS() cpuSec := float64(cpuEnd-cpuStart) / 1e9 throughput := float64(totalItems) / wallSec @@ -994,15 +1038,21 @@ func runPingPongClient(runDir, service string, profiles uint32, durationSec int, recvBuf := make([]byte, 256) cpuStart := cpuNS() - wallStart := time.Now() - deadline := time.Duration(durationSec) * time.Second + wallStart := nowNs() + deadline := wallStart + uint64(durationSec)*1_000_000_000 // #nosec G115 -- durationSec is a small positive CLI value. + var end uint64 + + // Declared once: Send takes &hdr, so it escapes to the heap; inside the + // loop that would be one allocation per request. + var hdr protocol.Header - for time.Since(wallStart) < deadline { + for now := wallStart; now < deadline; now = loopNow(end) { + end = 0 rl.wait() binary.NativeEndian.PutUint64(reqPayload[:], counter) - hdr := protocol.Header{ + hdr = protocol.Header{ Kind: protocol.KindRequest, Code: protocol.MethodIncrement, Flags: 0, @@ -1012,7 +1062,7 @@ func runPingPongClient(runDir, service string, profiles uint32, durationSec int, PayloadLen: 8, } - t0 := time.Now() + t0 := nowNs() if shm != nil { // SHM path @@ -1062,14 +1112,15 @@ func runPingPongClient(runDir, service string, profiles uint32, durationSec int, } } - t1 := time.Now() + t1 := nowNs() + end = t1 recordLatency(lr, t0, t1) counter++ requests++ } - wallSec := time.Since(wallStart).Seconds() + wallSec := float64(nowNs()-wallStart) / 1e9 cpuEnd := cpuNS() cpuSec := float64(cpuEnd-cpuStart) / 1e9 throughput := float64(requests) / wallSec @@ -1119,15 +1170,18 @@ func runSnapshotClient(runDir, service string, profiles uint32, durationSec int, var requests, errors uint64 cpuStart := cpuNS() - wallStart := time.Now() - deadline := time.Duration(durationSec) * time.Second - for time.Since(wallStart) < deadline { + wallStart := nowNs() + deadline := wallStart + uint64(durationSec)*1_000_000_000 // #nosec G115 -- durationSec is a small positive CLI value. + var end uint64 + for now := wallStart; now < deadline; now = loopNow(end) { + end = 0 rl.wait() - t0 := time.Now() + t0 := nowNs() view, err := client.CallSnapshot() - t1 := time.Now() + t1 := nowNs() + end = t1 if err != nil { errors++ @@ -1144,7 +1198,7 @@ func runSnapshotClient(runDir, service string, profiles uint32, durationSec int, requests++ } - wallSec := time.Since(wallStart).Seconds() + wallSec := float64(nowNs()-wallStart) / 1e9 cpuEnd := cpuNS() cpuSec := float64(cpuEnd-cpuStart) / 1e9 throughput := float64(requests) / wallSec @@ -1219,10 +1273,12 @@ func runLookupBench(durationSec int) int { var lookups, hits uint64 cpuStart := cpuNS() - wallStart := time.Now() - deadline := time.Duration(durationSec) * time.Second + wallStart := nowNs() + deadline := wallStart + uint64(durationSec)*1_000_000_000 // #nosec G115 -- durationSec is a small positive CLI value. + var end uint64 - for time.Since(wallStart) < deadline { + for now := wallStart; now < deadline; now = loopNow(end) { + end = 0 guard := cache.ReadLock() for _, it := range items { if guard.Get(it.hash, it.name) != nil { @@ -1233,7 +1289,7 @@ func runLookupBench(durationSec int) int { guard.Unlock() } - wallSec := time.Since(wallStart).Seconds() + wallSec := float64(nowNs()-wallStart) / 1e9 cpuEnd := cpuNS() cpuSec := float64(cpuEnd-cpuStart) / 1e9 throughput := float64(lookups) / wallSec @@ -1341,12 +1397,14 @@ func runLookupMethodBench(durationSec int, scenario string, targetRPS uint64) in var requests, errors uint64 cpuStart := cpuNS() - wallStart := time.Now() - deadline := time.Duration(durationSec) * time.Second + wallStart := nowNs() + deadline := wallStart + uint64(durationSec)*1_000_000_000 // #nosec G115 -- durationSec is a small positive CLI value. + var end uint64 - for time.Since(wallStart) < deadline { + for now := wallStart; now < deadline; now = loopNow(end) { + end = 0 rl.wait() - t0 := time.Now() + t0 := nowNs() var err error var respLen int @@ -1441,11 +1499,13 @@ func runLookupMethodBench(durationSec int, scenario string, targetRPS uint64) in continue } - recordLatency(lr, t0, time.Now()) + t1 := nowNs() + end = t1 + recordLatency(lr, t0, t1) requests++ } - wallSec := time.Since(wallStart).Seconds() + wallSec := float64(nowNs()-wallStart) / 1e9 cpuEnd := cpuNS() cpuSec := float64(cpuEnd-cpuStart) / 1e9 throughput := float64(requests) / wallSec @@ -1486,6 +1546,13 @@ func main() { cmd := os.Args[1] rc := 0 + // Without Go SHM the handshake falls back to baseline, so an SHM row + // would silently measure UDS. + if !goShmSupported && strings.Contains(cmd, "shm-") { + fmt.Fprintf(os.Stderr, "%s: Go has no POSIX SHM transport on this platform\n", cmd) + os.Exit(1) + } + switch cmd { case "uds-ping-pong-client", "shm-ping-pong-client", "uds-batch-ping-pong-client", "shm-batch-ping-pong-client", @@ -1508,9 +1575,7 @@ func main() { service := os.Args[3] duration := defaultDuration if len(os.Args) >= 5 { - if d, err := strconv.Atoi(os.Args[4]); err == nil { - duration = d - } + duration = parseDurationArg(os.Args[4]) } if err := os.MkdirAll(runDir, 0700); err != nil { // #nosec G703 -- benchmark driver uses caller-provided temporary run directory. @@ -1546,9 +1611,7 @@ func main() { service := os.Args[3] duration := defaultDuration if len(os.Args) >= 5 { - if d, err := strconv.Atoi(os.Args[4]); err == nil { - duration = d - } + duration = parseDurationArg(os.Args[4]) } if err := os.MkdirAll(runDir, 0700); err != nil { // #nosec G703 -- benchmark driver uses caller-provided temporary run directory. @@ -1573,7 +1636,7 @@ func main() { } runDir := os.Args[2] service := os.Args[3] - duration, _ := strconv.Atoi(os.Args[4]) + duration := parseDurationArg(os.Args[4]) targetRPS, _ := strconv.ParseUint(os.Args[5], 10, 64) var profiles uint32 @@ -1596,7 +1659,7 @@ func main() { } runDir := os.Args[2] service := os.Args[3] - duration, _ := strconv.Atoi(os.Args[4]) + duration := parseDurationArg(os.Args[4]) targetRPS, _ := strconv.ParseUint(os.Args[5], 10, 64) var profiles uint32 @@ -1619,7 +1682,7 @@ func main() { } runDir := os.Args[2] service := os.Args[3] - duration, _ := strconv.Atoi(os.Args[4]) + duration := parseDurationArg(os.Args[4]) targetRPS, _ := strconv.ParseUint(os.Args[5], 10, 64) var profiles uint32 @@ -1642,7 +1705,7 @@ func main() { } runDir := os.Args[2] service := os.Args[3] - duration, _ := strconv.Atoi(os.Args[4]) + duration := parseDurationArg(os.Args[4]) targetRPS, _ := strconv.ParseUint(os.Args[5], 10, 64) depth, _ := strconv.Atoi(os.Args[6]) if depth < 1 { @@ -1658,7 +1721,7 @@ func main() { } runDir := os.Args[2] service := os.Args[3] - duration, _ := strconv.Atoi(os.Args[4]) + duration := parseDurationArg(os.Args[4]) targetRPS, _ := strconv.ParseUint(os.Args[5], 10, 64) depth, _ := strconv.Atoi(os.Args[6]) if depth < 1 { @@ -1672,7 +1735,7 @@ func main() { fmt.Fprintf(os.Stderr, "Usage: %s lookup-bench \n", os.Args[0]) os.Exit(1) } - duration, _ := strconv.Atoi(os.Args[2]) + duration := parseDurationArg(os.Args[2]) rc = runLookupBench(duration) case "lookup-method-bench": @@ -1680,7 +1743,7 @@ func main() { fmt.Fprintf(os.Stderr, "Usage: %s lookup-method-bench \n", os.Args[0]) os.Exit(1) } - duration, _ := strconv.Atoi(os.Args[2]) + duration := parseDurationArg(os.Args[2]) targetRPS, _ := strconv.ParseUint(os.Args[4], 10, 64) rc = runLookupMethodBench(duration, os.Args[3], targetRPS) diff --git a/bench/drivers/go/main_windows.go b/bench/drivers/go/main_windows.go index 6d887bac..520556a6 100644 --- a/bench/drivers/go/main_windows.go +++ b/bench/drivers/go/main_windows.go @@ -838,6 +838,10 @@ func runBatchPingPongClientWin(runDir, service string, profiles uint32, duration wallStart := nowNS() tickDeadline := tickMSWin() + uint64(durationSec)*1000 + // Declared once: Send takes &hdr, so it escapes to the heap; inside the + // loop that would be one allocation per request. + var hdr protocol.Header + for tickMSWin() < tickDeadline { rl.wait() @@ -867,7 +871,7 @@ func runBatchPingPongClientWin(runDir, service string, profiles uint32, duration reqLen, _ := bb.Finish() msgSeq++ - hdr := protocol.Header{ + hdr = protocol.Header{ Kind: protocol.KindRequest, Code: protocol.MethodIncrement, Flags: protocol.FlagBatch, @@ -1076,6 +1080,10 @@ func runPipelineClientWin(runDir, service string, durationSec int, targetRPS uin runtime.GC() + // Declared once: Send takes &hdr, so it escapes to the heap; inside the + // loop that would be one allocation per request. + var hdr protocol.Header + for tickMSWin() < tickDeadline { rl.wait() @@ -1088,7 +1096,7 @@ func runPipelineClientWin(runDir, service string, durationSec int, targetRPS uin binary.NativeEndian.PutUint64(reqPayload[:], val) msgSeq++ - hdr := protocol.Header{ + hdr = protocol.Header{ Kind: protocol.KindRequest, Code: protocol.MethodIncrement, ItemCount: 1, @@ -1204,21 +1212,17 @@ func warmPipelineClientWin(session *windows.Session, recvBuf []byte, reqPayload // --------------------------------------------------------------------------- type pipelineBatchSlotWin struct { - messageID uint64 - batchSize uint32 - inflightBytes uint64 - active bool + messageID uint64 + batchSize uint32 + active bool } func warmPipelineBatchClientWin(session *windows.Session, reqBufs [][]byte, recvBuf []byte, itemBuf []byte, depth int) error { slots := make([]pipelineBatchSlotWin, depth) var counter, msgSeq uint64 - inflightBudget := uint64(session.PacketSize) * 2 sent := 0 received := 0 - outstanding := 0 - inflightBytes := uint64(0) for received < depth { if sent < depth { @@ -1247,42 +1251,15 @@ func warmPipelineBatchClientWin(session *windows.Session, reqBufs [][]byte, recv TransportStatus: protocol.StatusOK, } - slotBytes := uint64((protocol.HeaderSize + reqLen) * 2) - for outstanding > 0 && inflightBytes+slotBytes > inflightBudget { - respHdr, _, err := session.Receive(recvBuf) - if err != nil { - return fmt.Errorf("pipeline-batch warmup recv: %w", err) - } - - matched := -1 - for i := range slots { - if slots[i].active && slots[i].messageID == respHdr.MessageID { - matched = i - break - } - } - if matched < 0 { - return fmt.Errorf("pipeline-batch warmup unknown response id %d", respHdr.MessageID) - } - - inflightBytes -= slots[matched].inflightBytes - slots[matched].active = false - outstanding-- - received++ - } - if err := session.Send(&hdr, reqBufs[slot][:reqLen]); err != nil { return fmt.Errorf("pipeline-batch warmup send: %w", err) } slots[slot] = pipelineBatchSlotWin{ - messageID: hdr.MessageID, - batchSize: bs, - inflightBytes: slotBytes, - active: true, + messageID: hdr.MessageID, + batchSize: bs, + active: true, } - inflightBytes += slotBytes - outstanding++ counter += uint64(bs) sent++ continue @@ -1304,9 +1281,7 @@ func warmPipelineBatchClientWin(session *windows.Session, reqBufs [][]byte, recv return fmt.Errorf("pipeline-batch warmup unknown response id %d", respHdr.MessageID) } - inflightBytes -= slots[matched].inflightBytes slots[matched].active = false - outstanding-- received++ } @@ -1351,7 +1326,6 @@ func runPipelineBatchClientWin(runDir, service string, durationSec int, targetRP recvBuf := make([]byte, batchBufSizeWin+protocol.HeaderSize) var counter, totalItems, errors, msgSeq uint64 - inflightBudget := uint64(session.PacketSize) * 2 if err := warmPipelineBatchClientWin(session, reqBufs, recvBuf, itemBuf, depth); err != nil { fmt.Fprintf(os.Stderr, "pipeline-batch warmup: %v\n", err) @@ -1365,6 +1339,10 @@ func runPipelineBatchClientWin(runDir, service string, durationSec int, targetRP tickDeadline := tickMSWin() + uint64(durationSec)*1000 rng := newXorShift32Win() + // Declared once: Send takes &hdr, so it escapes to the heap; inside the + // loop that would be one allocation per request. + var hdr protocol.Header + for tickMSWin() < tickDeadline { rl.wait() @@ -1375,8 +1353,6 @@ func runPipelineBatchClientWin(runDir, service string, durationSec int, targetRP sent := 0 received := 0 - outstanding := 0 - inflightBytes := uint64(0) cycleOK := true for received < depth { @@ -1395,7 +1371,7 @@ func runPipelineBatchClientWin(runDir, service string, durationSec int, targetRP reqLen, _ := bb.Finish() msgSeq++ - hdr := protocol.Header{ + hdr = protocol.Header{ Kind: protocol.KindRequest, Code: protocol.MethodIncrement, Flags: protocol.FlagBatch, @@ -1404,40 +1380,6 @@ func runPipelineBatchClientWin(runDir, service string, durationSec int, targetRP TransportStatus: protocol.StatusOK, } - slotBytes := uint64((protocol.HeaderSize + reqLen) * 2) - - for outstanding > 0 && inflightBytes+slotBytes > inflightBudget { - respHdr, _, rerr := session.Receive(recvBuf) - if rerr != nil { - errors++ - cycleOK = false - break - } - - matched := -1 - for i := range slots { - if slots[i].active && slots[i].messageID == respHdr.MessageID { - matched = i - break - } - } - if matched < 0 { - fmt.Fprintf(os.Stderr, "pipeline-batch client: unknown response id %d\n", respHdr.MessageID) - errors++ - cycleOK = false - break - } - - inflightBytes -= slots[matched].inflightBytes - totalItems += uint64(slots[matched].batchSize) - slots[matched].active = false - outstanding-- - received++ - } - if !cycleOK { - break - } - if serr := session.Send(&hdr, reqBufs[slot][:reqLen]); serr != nil { errors++ cycleOK = false @@ -1445,13 +1387,10 @@ func runPipelineBatchClientWin(runDir, service string, durationSec int, targetRP } slots[slot] = pipelineBatchSlotWin{ - messageID: hdr.MessageID, - batchSize: bs, - inflightBytes: slotBytes, - active: true, + messageID: hdr.MessageID, + batchSize: bs, + active: true, } - inflightBytes += slotBytes - outstanding++ counter += uint64(bs) sent++ continue @@ -1478,10 +1417,8 @@ func runPipelineBatchClientWin(runDir, service string, durationSec int, targetRP break } - inflightBytes -= slots[matched].inflightBytes totalItems += uint64(slots[matched].batchSize) slots[matched].active = false - outstanding-- received++ } @@ -1629,12 +1566,16 @@ func runPingPongClientWin(runDir, service string, profiles uint32, durationSec i wallStart := nowNS() tickDeadline := tickMSWin() + uint64(durationSec)*1000 + // Declared once: Send takes &hdr, so it escapes to the heap; inside the + // loop that would be one allocation per request. + var hdr protocol.Header + for tickMSWin() < tickDeadline { rl.wait() binary.NativeEndian.PutUint64(reqPayload[:], counter) - hdr := protocol.Header{ + hdr = protocol.Header{ Kind: protocol.KindRequest, Code: protocol.MethodIncrement, Flags: 0, @@ -1916,9 +1857,7 @@ func main() { service := os.Args[3] duration := defaultDurationWin if len(os.Args) >= 5 { - if d, err := strconv.Atoi(os.Args[4]); err == nil { - duration = d - } + duration = parseDurationArg(os.Args[4]) } os.MkdirAll(runDir, 0700) @@ -1951,9 +1890,7 @@ func main() { service := os.Args[3] duration := defaultDurationWin if len(os.Args) >= 5 { - if d, err := strconv.Atoi(os.Args[4]); err == nil { - duration = d - } + duration = parseDurationArg(os.Args[4]) } os.MkdirAll(runDir, 0700) @@ -1975,7 +1912,7 @@ func main() { } runDir := os.Args[2] service := os.Args[3] - duration, _ := strconv.Atoi(os.Args[4]) + duration := parseDurationArg(os.Args[4]) targetRPS, _ := strconv.ParseUint(os.Args[5], 10, 64) var profiles uint32 @@ -1998,7 +1935,7 @@ func main() { } runDir := os.Args[2] service := os.Args[3] - duration, _ := strconv.Atoi(os.Args[4]) + duration := parseDurationArg(os.Args[4]) targetRPS, _ := strconv.ParseUint(os.Args[5], 10, 64) var profiles uint32 @@ -2021,7 +1958,7 @@ func main() { } runDir := os.Args[2] service := os.Args[3] - duration, _ := strconv.Atoi(os.Args[4]) + duration := parseDurationArg(os.Args[4]) targetRPS, _ := strconv.ParseUint(os.Args[5], 10, 64) var profiles uint32 @@ -2044,7 +1981,7 @@ func main() { } runDir := os.Args[2] service := os.Args[3] - duration, _ := strconv.Atoi(os.Args[4]) + duration := parseDurationArg(os.Args[4]) targetRPS, _ := strconv.ParseUint(os.Args[5], 10, 64) depth, _ := strconv.Atoi(os.Args[6]) if depth < 1 { @@ -2060,7 +1997,7 @@ func main() { } runDir := os.Args[2] service := os.Args[3] - duration, _ := strconv.Atoi(os.Args[4]) + duration := parseDurationArg(os.Args[4]) targetRPS, _ := strconv.ParseUint(os.Args[5], 10, 64) depth, _ := strconv.Atoi(os.Args[6]) if depth < 1 { diff --git a/bench/drivers/go/osv-scanner.toml b/bench/drivers/go/osv-scanner.toml index 7f2a1225..9e02749b 100644 --- a/bench/drivers/go/osv-scanner.toml +++ b/bench/drivers/go/osv-scanner.toml @@ -1,6 +1,5 @@ -# CI/dev toolchains build with the latest Go 1.26.x patch release. The go -# directive in go.mod must stay 1.26.0 until netdata (which vendors this -# source) raises its own Go toolchain; bumping it would break netdata builds. -# This override scans the stdlib at the toolchain version actually used. -# Remove when netdata moves past Go 1.26.0 and go.mod is bumped. -GoVersionOverride = "1.26.4" +# Scan the Go standard library Netdata ships. go.mod keeps Netdata's +# `go 1.27.0` directive, while Netdata builds with the patch release pinned +# in netdata/netdata packaging/check-for-go-toolchain.sh. Keep this value +# equal to that pin and bump it whenever Netdata bumps it. +GoVersionOverride = "1.27.3" diff --git a/bench/drivers/go/shm_supported.go b/bench/drivers/go/shm_supported.go new file mode 100644 index 00000000..ddd07d53 --- /dev/null +++ b/bench/drivers/go/shm_supported.go @@ -0,0 +1,7 @@ +//go:build linux || (freebsd && (amd64 || 386)) + +package main + +// goShmSupported reports whether the Go POSIX transport implements SHM on +// this platform (the build tags match transport/posix/shm_posix.go). +const goShmSupported = true diff --git a/bench/drivers/go/shm_unsupported.go b/bench/drivers/go/shm_unsupported.go new file mode 100644 index 00000000..5f63e71f --- /dev/null +++ b/bench/drivers/go/shm_unsupported.go @@ -0,0 +1,7 @@ +//go:build unix && !linux && !(freebsd && (amd64 || 386)) + +package main + +// goShmSupported reports whether the Go POSIX transport implements SHM on +// this platform (the build tags match transport/posix/shm_other.go). +const goShmSupported = false diff --git a/bench/drivers/rust/src/bench_windows.rs b/bench/drivers/rust/src/bench_windows.rs index f907f734..1b563bf9 100644 --- a/bench/drivers/rust/src/bench_windows.rs +++ b/bench/drivers/rust/src/bench_windows.rs @@ -19,24 +19,24 @@ fn main() { #[cfg(windows)] use netipc::protocol::{ - self, batch_item_get, increment_decode, increment_encode, BatchBuilder, Header, FLAG_BATCH, - HEADER_SIZE, INCREMENT_PAYLOAD_SIZE, KIND_REQUEST, KIND_RESPONSE, MAGIC_MSG, - METHOD_CGROUPS_SNAPSHOT, METHOD_INCREMENT, PROFILE_BASELINE, STATUS_OK, VERSION, + self, BatchBuilder, FLAG_BATCH, HEADER_SIZE, Header, INCREMENT_PAYLOAD_SIZE, KIND_REQUEST, + KIND_RESPONSE, MAGIC_MSG, METHOD_CGROUPS_SNAPSHOT, METHOD_INCREMENT, PROFILE_BASELINE, + STATUS_OK, VERSION, batch_item_get, increment_decode, increment_encode, }; #[cfg(windows)] use netipc::service::cgroups::{CgroupsClient, ClientConfig as TypedClientConfig}; #[cfg(windows)] use netipc::service::raw::{ - increment_dispatch, snapshot_dispatch, CgroupsCache, CgroupsCacheItem, DispatchHandler, - ManagedServer, + CgroupsCache, CgroupsCacheItem, DispatchHandler, ManagedServer, increment_dispatch, + snapshot_dispatch, }; #[cfg(windows)] -use netipc::transport::win_shm::{WinShmContext, PROFILE_HYBRID as WIN_SHM_PROFILE_HYBRID}; +use netipc::transport::win_shm::{PROFILE_HYBRID as WIN_SHM_PROFILE_HYBRID, WinShmContext}; #[cfg(windows)] use netipc::transport::windows::{ClientConfig, NpSession, ServerConfig}; #[cfg(windows)] -use std::sync::{atomic::Ordering, OnceLock}; +use std::sync::{OnceLock, atomic::Ordering}; #[cfg(windows)] use std::time::{Duration, Instant}; #[cfg(windows)] @@ -938,11 +938,7 @@ fn run_ping_pong_client( eprintln!("client: {} errors", errors); } - if errors > 0 { - 1 - } else { - 0 - } + if errors > 0 { 1 } else { 0 } } #[cfg(windows)] @@ -1098,11 +1094,7 @@ fn run_snapshot_client( eprintln!("client: {} errors", errors); } - if errors > 0 { - 1 - } else { - 0 - } + if errors > 0 { 1 } else { 0 } } // --------------------------------------------------------------------------- @@ -1331,11 +1323,7 @@ fn run_batch_ping_pong_client( ); } - if errors > 0 { - 1 - } else { - 0 - } + if errors > 0 { 1 } else { 0 } } // --------------------------------------------------------------------------- @@ -1477,11 +1465,7 @@ fn run_pipeline_client( eprintln!("pipeline client: {} errors", errors); } - if errors > 0 { - 1 - } else { - 0 - } + if errors > 0 { 1 } else { 0 } } #[cfg(windows)] @@ -1538,7 +1522,6 @@ fn warm_pipeline_client( struct PipelineBatchSlot { message_id: u64, batch_size: u32, - inflight_bytes: usize, active: bool, } @@ -1584,7 +1567,6 @@ fn run_pipeline_batch_client( .collect(); let mut slots = vec![PipelineBatchSlot::default(); depth]; let mut recv_buf = vec![0u8; BENCH_BATCH_BUF_SIZE + HEADER_SIZE]; - let inflight_budget = session.packet_size as usize * 2; let cpu_start = cpu_ns(); let wall_start = Instant::now(); @@ -1604,8 +1586,6 @@ fn run_pipeline_batch_client( let mut sent = 0usize; let mut received = 0usize; - let mut outstanding = 0usize; - let mut inflight_bytes = 0usize; let mut cycle_ok = true; while received < depth { @@ -1628,7 +1608,6 @@ fn run_pipeline_batch_client( } let (len, _out_count) = bb.finish(); - let slot_bytes = (HEADER_SIZE + len) * 2; let mut hdr = Header { kind: KIND_REQUEST, @@ -1640,38 +1619,6 @@ fn run_pipeline_batch_client( ..Header::default() }; - while outstanding > 0 && inflight_bytes + slot_bytes > inflight_budget { - match session.receive(&mut recv_buf) { - Ok((resp_hdr, _payload)) => { - if let Some(matched) = slots.iter().position(|pending| { - pending.active && pending.message_id == resp_hdr.message_id - }) { - inflight_bytes -= slots[matched].inflight_bytes; - total_items += slots[matched].batch_size as u64; - slots[matched].active = false; - outstanding -= 1; - received += 1; - } else { - eprintln!( - "pipeline-batch client: unknown response id {}", - resp_hdr.message_id - ); - cycle_ok = false; - errors += 1; - break; - } - } - Err(_) => { - cycle_ok = false; - errors += 1; - break; - } - } - } - if !cycle_ok { - break; - } - if session.send(&mut hdr, &req_bufs[slot][..len]).is_err() { cycle_ok = false; errors += 1; @@ -1681,11 +1628,8 @@ fn run_pipeline_batch_client( slots[slot] = PipelineBatchSlot { message_id: hdr.message_id, batch_size: bs, - inflight_bytes: slot_bytes, active: true, }; - inflight_bytes += slot_bytes; - outstanding += 1; counter += bs as u64; sent += 1; continue; @@ -1696,10 +1640,8 @@ fn run_pipeline_batch_client( if let Some(matched) = slots.iter().position(|pending| { pending.active && pending.message_id == resp_hdr.message_id }) { - inflight_bytes -= slots[matched].inflight_bytes; total_items += slots[matched].batch_size as u64; slots[matched].active = false; - outstanding -= 1; received += 1; } else { eprintln!( @@ -1750,11 +1692,7 @@ fn run_pipeline_batch_client( eprintln!("pipeline-batch client: {} errors", errors); } - if errors > 0 { - 1 - } else { - 0 - } + if errors > 0 { 1 } else { 0 } } // --------------------------------------------------------------------------- diff --git a/bench/drivers/rust/src/main.rs b/bench/drivers/rust/src/main.rs index da4f2006..9bb42b8a 100644 --- a/bench/drivers/rust/src/main.rs +++ b/bench/drivers/rust/src/main.rs @@ -15,26 +15,28 @@ fn main() { mod posix_only { use netipc::protocol::{ - self, batch_item_get, dispatch_apps_lookup, dispatch_cgroups_lookup, - encode_apps_lookup_request, encode_cgroups_lookup_request, increment_decode, - increment_encode, AppsLookupBuilder, AppsLookupResponseView, BatchBuilder, - CgroupsLookupBuilder, CgroupsLookupResponseView, Header, APPS_CGROUP_KNOWN, - CGROUP_LOOKUP_KNOWN, CGROUP_LOOKUP_UNKNOWN_RETRY_LATER, FLAG_BATCH, HEADER_SIZE, - INCREMENT_PAYLOAD_SIZE, KIND_REQUEST, KIND_RESPONSE, MAGIC_MSG, METHOD_CGROUPS_SNAPSHOT, - METHOD_INCREMENT, NIPC_UID_UNSET, ORCHESTRATOR_DOCKER, ORCHESTRATOR_K8S, PID_LOOKUP_KNOWN, - PID_LOOKUP_UNKNOWN, PROFILE_BASELINE, PROFILE_SHM_HYBRID, STATUS_OK, VERSION, + self, APPS_CGROUP_KNOWN, AppsLookupBuilder, AppsLookupResponseView, BatchBuilder, + CGROUP_LOOKUP_KNOWN, CGROUP_LOOKUP_UNKNOWN_RETRY_LATER, CgroupsLookupBuilder, + CgroupsLookupResponseView, FLAG_BATCH, HEADER_SIZE, Header, INCREMENT_PAYLOAD_SIZE, + KIND_REQUEST, KIND_RESPONSE, METHOD_CGROUPS_SNAPSHOT, METHOD_INCREMENT, NIPC_UID_UNSET, + ORCHESTRATOR_DOCKER, ORCHESTRATOR_K8S, PID_LOOKUP_KNOWN, PID_LOOKUP_UNKNOWN, + PROFILE_BASELINE, PROFILE_SHM_HYBRID, STATUS_OK, batch_item_get, dispatch_apps_lookup, + dispatch_cgroups_lookup, encode_apps_lookup_request, encode_cgroups_lookup_request, + increment_decode, increment_encode, }; + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + use netipc::protocol::{MAGIC_MSG, VERSION}; use netipc::service::cgroups::{CgroupsClient, ClientConfig as TypedClientConfig}; use netipc::service::raw::{ - increment_dispatch, snapshot_dispatch, CgroupsCache, CgroupsCacheItem, DispatchHandler, - ManagedServer, + CgroupsCache, CgroupsCacheItem, DispatchHandler, ManagedServer, increment_dispatch, + snapshot_dispatch, }; use netipc::transport::posix::{ClientConfig, ServerConfig, UdsSession}; - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] use netipc::transport::shm::ShmContext; - use std::sync::{atomic::Ordering, OnceLock}; + use std::sync::{OnceLock, atomic::Ordering}; use std::time::{Duration, Instant}; // --------------------------------------------------------------------------- @@ -426,11 +428,12 @@ mod posix_only { let mut req_buf = vec![0u8; BENCH_BATCH_BUF_SIZE]; let mut recv_buf = vec![0u8; BENCH_BATCH_BUF_SIZE + HEADER_SIZE]; + #[cfg(any(target_os = "linux", target_os = "freebsd"))] let mut msg_buf = vec![0u8; BENCH_BATCH_BUF_SIZE + HEADER_SIZE]; let mut expected = vec![0u64; BENCH_MAX_BATCH_ITEMS as usize]; // SHM upgrade if negotiated - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] let mut shm: Option = { let sp = session.selected_profile; if sp == PROFILE_SHM_HYBRID || sp == protocol::PROFILE_SHM_FUTEX { @@ -447,19 +450,17 @@ mod posix_only { } if shm_ctx.is_none() { eprintln!( - "batch client: shm attach failed after retries (profile=0x{:x}, session={})", - sp, session.session_id - ); + "batch client: shm attach failed after retries (profile=0x{:x}, session={})", + sp, session.session_id + ); } shm_ctx } else { None } }; - #[cfg(not(target_os = "linux"))] - let shm: Option<()> = None; - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] if (session.selected_profile == PROFILE_SHM_HYBRID || session.selected_profile == protocol::PROFILE_SHM_FUTEX) && shm.is_none() @@ -515,7 +516,7 @@ mod posix_only { let t0 = Instant::now(); // Send + receive via SHM or UDS - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] let io_result = if let Some(ref mut shm_ctx) = shm { // SHM path: assemble header + payload into one message let msg_len = HEADER_SIZE + req_len; @@ -566,7 +567,7 @@ mod posix_only { } }; - #[cfg(not(target_os = "linux"))] + #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] let io_result = match session.send(&mut hdr, &req_buf[..req_len]) { Ok(_) => session.receive(&mut recv_buf).map_err(|_| ()), Err(_) => Err(()), @@ -642,7 +643,7 @@ mod posix_only { scenario, server_lang, throughput, p50, p95, p99, cpu_pct, cpu_pct ); - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] if let Some(mut shm_ctx) = shm { shm_ctx.close(); } @@ -655,11 +656,7 @@ mod posix_only { ); } - if errors > 0 { - 1 - } else { - 0 - } + if errors > 0 { 1 } else { 0 } } // --------------------------------------------------------------------------- @@ -794,11 +791,7 @@ mod posix_only { eprintln!("pipeline client: {} errors", errors); } - if errors > 0 { - 1 - } else { - 0 - } + if errors > 0 { 1 } else { 0 } } // --------------------------------------------------------------------------- @@ -943,11 +936,7 @@ mod posix_only { eprintln!("pipeline-batch client: {} errors", errors); } - if errors > 0 { - 1 - } else { - 0 - } + if errors > 0 { 1 } else { 0 } } // --------------------------------------------------------------------------- @@ -1000,7 +989,7 @@ mod posix_only { let deadline = Duration::from_secs(duration_sec as u64); // SHM upgrade if negotiated - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] let mut shm: Option = { let sp = session.selected_profile; if sp == PROFILE_SHM_HYBRID || sp == protocol::PROFILE_SHM_FUTEX { @@ -1021,10 +1010,7 @@ mod posix_only { } }; - #[cfg(not(target_os = "linux"))] - let shm: Option<()> = None; - - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] if (session.selected_profile == PROFILE_SHM_HYBRID || session.selected_profile == protocol::PROFILE_SHM_FUTEX) && shm.is_none() @@ -1034,7 +1020,9 @@ mod posix_only { return 1; } + #[cfg(any(target_os = "linux", target_os = "freebsd"))] let mut msg_buf = vec![0u8; HEADER_SIZE + 8]; + #[cfg(any(target_os = "linux", target_os = "freebsd"))] let mut resp_shm_buf = vec![0u8; HEADER_SIZE + 64]; let mut recv_buf = vec![0u8; 256]; @@ -1055,7 +1043,7 @@ mod posix_only { let t0 = Instant::now(); - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] let send_ok = if let Some(ref mut shm_ctx) = shm { // SHM path let msg_len = HEADER_SIZE + 8; @@ -1124,7 +1112,7 @@ mod posix_only { } }; - #[cfg(not(target_os = "linux"))] + #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] let send_ok = { if session.send(&mut hdr, &req_payload).is_err() { errors += 1; @@ -1177,7 +1165,7 @@ mod posix_only { ); // Cleanup - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] { if let Some(mut shm_ctx) = shm { shm_ctx.close(); @@ -1189,11 +1177,7 @@ mod posix_only { eprintln!("client: {} errors", errors); } - if errors > 0 { - 1 - } else { - 0 - } + if errors > 0 { 1 } else { 0 } } // --------------------------------------------------------------------------- @@ -1282,11 +1266,7 @@ mod posix_only { eprintln!("client: {} errors", errors); } - if errors > 0 { - 1 - } else { - 0 - } + if errors > 0 { 1 } else { 0 } } // --------------------------------------------------------------------------- diff --git a/docs/code-organization.md b/docs/code-organization.md index 91d0c303..e48190c8 100644 --- a/docs/code-organization.md +++ b/docs/code-organization.md @@ -83,6 +83,7 @@ src/ cgroups_lookup.rs # cgroups lookup codec apps_lookup.rs # apps lookup codec transport/ + framing.rs # L1: shared send/receive chunking and handshake posix.rs # L1: UDS, SHM windows.rs # L1: Named Pipe, SHM service/ # L2/L3: typed client/server helpers diff --git a/docs/level1-posix-shm.md b/docs/level1-posix-shm.md index 066a06b8..cdfd36e5 100644 --- a/docs/level1-posix-shm.md +++ b/docs/level1-posix-shm.md @@ -3,12 +3,29 @@ ## Purpose This document defines the shared memory region layout, synchronization -protocol, and lifecycle rules for the POSIX SHM transport on Linux. +protocol, and lifecycle rules for the POSIX SHM transport on Linux and +FreeBSD. All implementations (C, Rust, Go) must use this exact layout and synchronization protocol to interoperate on the same shared region. -This transport is Linux-only. FreeBSD and macOS fall back to UDS -baseline. +This transport runs on Linux and FreeBSD. C and Rust support it on every +FreeBSD architecture; Go supports it on FreeBSD amd64 and 386 only. macOS, +and Go on other FreeBSD architectures, fall back to UDS baseline. Where SHM +is not supported the C and Go SHM APIs remain available and return an +"unsupported" error (`NIPC_SHM_ERR_UNSUPPORTED`, `ErrShmUnsupported`); the +Rust crate compiles the POSIX SHM module only on Linux and FreeBSD. + +The platforms differ only in the system calls behind three operations; the +region layout and the protocol are the same: + +| Operation | Linux | FreeBSD | +|---|---|---| +| Wait on a signal word | `futex(FUTEX_WAIT)` | `_umtx_op(UMTX_OP_WAIT_UINT)` | +| Wake waiters | `futex(FUTEX_WAKE)` | `_umtx_op(UMTX_OP_WAKE)` | +| Reserve the region file | `fallocate(fd, 0, 0, size)` | `posix_fallocate(fd, 0, size)` | + +On FreeBSD the shared (not `_PRIVATE`) umtx operations are required: they +key on the mapped object, so a wake reaches waiters in other processes. ## Region file path @@ -24,7 +41,8 @@ Each session gets its own SHM region. Multiple concurrent clients each have independent regions with independent request/response areas, sequence numbers, and futex signal words. -Created by the server via `open` + native Linux `fallocate` on a filesystem path. +Created by the server via `open` + a reserving allocation (Linux `fallocate`, +FreeBSD `posix_fallocate`) on a filesystem path. The complete range is allocated before it is mapped, so capacity exhaustion is reported as a normal creation error instead of a later `SIGBUS` during mapped access. The client opens the same path after the handshake negotiates an SHM @@ -175,7 +193,14 @@ kernel-assisted blocking: 3. The publisher always calls `futex(FUTEX_WAKE)` after advancing the sequence, regardless of whether the consumer is spinning or waiting. -Futex timeout arguments must use the selected Linux syscall's ABI, not assume +On FreeBSD the wait is `_umtx_op(UMTX_OP_WAIT_UINT)` with a relative +`struct _umtx_time` on `CLOCK_MONOTONIC` (its size passed in the `uaddr` +argument), and the wake is `_umtx_op(UMTX_OP_WAKE)`. A wait whose expected +value is already stale returns success at once, where a Linux futex returns +`EAGAIN`. Both are spurious-wakeup-safe because the consumer re-checks the +sequence number after every return. + +Linux futex timeout arguments must use the selected syscall's ABI, not assume that libc's `struct timespec` has the same layout. In particular, 32-bit libc may use 64-bit seconds while the legacy syscall expects two 32-bit fields. All receive budgets are unsigned 32-bit milliseconds: their relative seconds @@ -205,15 +230,24 @@ protocol prohibition. ### Server creates a per-session region -The server creates one SHM region per accepted session, after the -handshake negotiates an SHM profile. The region path is derived from -the `session_id` assigned during the handshake. +The server creates one SHM region per accepted session, inside the +handshake: after HELLO validation, authorization, profile selection and +the limit checks pick an SHM profile, and before HELLO_ACK. Baseline +sessions and rejected handshakes create no region. The region is sized +from the final request and response payload limits plus the message +header. The region path is derived from the `session_id` assigned to +the handshake. 1. Derive the region path: `{run_dir}/{service_name}-{session_id:016x}.ipcshm`. 2. Create the file via `open(O_RDWR | O_CREAT | O_EXCL, 0600)`. - `O_EXCL` ensures no collision with an existing region. + `O_EXCL` ensures no collision with an existing region. Then `fchmod` + the descriptor to 0600: the file takes `0600 & ~umask`, and a umask + without owner read or write would stop the client from attaching + read-write. 3. Allocate the required size (header + request area + response area) with - native Linux `fallocate(fd, 0, 0, size)`. Retry `EINTR`; any other failure + native Linux `fallocate(fd, 0, 0, size)` or FreeBSD + `posix_fallocate(fd, 0, size)` (which returns its error number instead of + setting `errno`). Retry `EINTR`; any other failure aborts SHM preparation, closes and unlinks the new region, and reports the allocation-stage transport error. Do not fall back to sparse `ftruncate` or an allocation mechanism without the same reservation guarantee. @@ -232,6 +266,13 @@ in their error values. Managed services remove SHM from that session's negotiati continue over baseline when baseline is configured. An explicitly SHM-only service has no fallback transport and may reject or retry the session. +FreeBSD ZFS does not support `posix_fallocate` and rejects it with `EINVAL`, so +a server whose run directory is on ZFS (for example Netdata's default +`/var/run/netdata` on a ZFS-root install) never prepares SHM and serves every +session over baseline. tmpfs and UFS reserve the space and report exhaustion as +`ENOSPC`. To get SHM on such a system, point the run directory at tmpfs or UFS +(Netdata: `NETDATA_RUN_DIR`). + Linux runtime sandboxes must allow the `fallocate` system call or arrange for it to return an ordinary error such as `EPERM`, `ENOSYS`, or `EOPNOTSUPP`. NetIPC can convert an error return into SHM disablement and baseline fallback. Seccomp `KILL_PROCESS` and @@ -262,12 +303,42 @@ place. 3. Validate the file size (must be >= header_len). 4. `mmap` the region. 5. Validate the header: magic, version, header_len. -6. Read offsets and capacities from the header. +6. Read offsets and capacities from the header and validate them: both + offsets at or after the aligned header end, both capacities non-zero, + all four values 64-byte aligned, the response area starting at or after + the aligned end of the request area, and both areas ending within the + file. Area ends are computed without wrapping (for example in 64-bit + arithmetic), so a forged header cannot pass on 32-bit platforms. A + violation is a bad-size error. 7. The client is now ready to publish requests and consume responses. If the file does not exist or is undersized (server has not yet finished creating it), the client treats this as a retryable -protocol-not-ready condition. +protocol-not-ready condition. Between retries the client probes the +session socket (see "Peer liveness"); if the server already closed it +(refused at worker capacity, or died), the client stops retrying, reports +the connect as failed, and keeps SHM enabled for its next connect. + +### Peer liveness + +The UDS session socket stays open for the whole SHM session. SHM waits +cannot see a peer that exits, but the kernel closes that process's socket +end, so the socket is the dead-peer signal for both sides: + +- The probe is a zero-wait `poll()` for `POLLHUP`, `POLLERR`, `POLLNVAL` + (and `POLLRDHUP` where available); readable data is checked with a + non-blocking `MSG_PEEK` receive, where 0 bytes means end of stream. No + data flows on the socket during an SHM session. +- It runs only after a wait slice expires with no message, never while + messages flow: the server after each idle receive poll (100 ms), the + client after each idle slice of its response wait (100 ms). +- A server that sees the peer gone ends the session (freeing the worker + and the region). A client fails the in-flight call with a disconnected + error. +- Process liveness (`kill(pid, 0)`) is not used: it is wrong across PID + namespaces and after PID reuse. +- Session sockets are close-on-exec, so a child that a consumer forks and + execs cannot keep a dead peer's socket open. ### Server destroys a per-session region @@ -320,6 +391,11 @@ behind by a previous server instance that crashed or was killed: 3. Stale files are unlinked only in a safe `{run_dir}`. Live files and files in unsafe shared directories are left in place. +The scan and its stat checks must work with 64-bit inode numbers and directory +offsets on 32-bit platforms; the C transport sources enable large-file +interfaces themselves, so 32-bit builds with the legacy `off_t` layout do not +silently skip the scan. + This cleanup runs once at server startup, before the listener begins accepting connections. It is the safety net for crashes and hard reboots. @@ -363,5 +439,5 @@ diagnostic to report 4-byte pointers, 4/8-byte seconds fields and 8/16-byte timespecs for time32/time64 respectively; a missing or unexpected layout fails the run even when the timeout assertions pass. Runtime Safety CI checks both C ARM glibc layouts and both Rust ARM musl layouts. Emulation validates syscall ABI -behavior; it does not establish production CPU usage or full C/Rust/Go support -for that architecture. +behavior; it does not establish production CPU usage. Full C/Rust/Go suites and +interop for each Netdata Linux target run through `tests/run-linux-target.sh`. diff --git a/docs/level1-posix-uds.md b/docs/level1-posix-uds.md index 5ac89c66..10f514d4 100644 --- a/docs/level1-posix-uds.md +++ b/docs/level1-posix-uds.md @@ -11,6 +11,16 @@ must follow this contract to interoperate on the same socket. `AF_UNIX` / `SOCK_SEQPACKET` — message-oriented, reliable, ordered, connection-based Unix domain sockets with preserved message boundaries. +macOS and FreeBSD run the baseline over `SOCK_STREAM` with the same bytes +on the wire; see "Stream baseline". + +Every socket and pipe the transport creates (listener, accepted and +connected sessions, the client abort pipe, SHM region descriptors) is +close-on-exec (`SOCK_CLOEXEC`, `accept4(..., SOCK_CLOEXEC)`, `O_CLOEXEC`, +`F_DUPFD_CLOEXEC`; platforms without them set `FD_CLOEXEC` right after +creation). A session descriptor inherited by a child that a consumer forks +and execs would keep a dead peer's socket open and hide the disconnect. + ## Socket path derivation ``` @@ -19,29 +29,109 @@ connection-based Unix domain sockets with preserved message boundaries. Example: `/run/netdata/cgroups-snapshot.sock` -- `run_dir` is caller-supplied (typically `/run/netdata` on Linux) +- `run_dir` is caller-supplied (typically `/run/netdata` on Linux) and must + not be empty; an empty `run_dir` is rejected, since it would place the + endpoint at the filesystem root - `service_name` is the service identity string - The path must not exceed the platform's `sun_path` limit +## Socket permissions + +The listener socket file is owner-only: mode `0600`, narrowed further by the +process umask. C, Rust and Go set it the same way: `fchmod(fd, 0600)` on the +unbound socket, then `bind()`. Linux and FreeBSD create the socket file with +the unbound socket's mode, so the file never exists with a wider mode, no +path-based `chmod` runs, and the process-global umask is never changed (other +threads of the embedding process may be creating files at the same time). + +Consequences: + +- clients must run as the server's user (or as a user the kernel lets + bypass file permissions, such as root); +- `run_dir` must not be writable by untrusted users: whoever can write it + can remove or replace the endpoint; +- a platform whose kernel rejects `fchmod` on an unbound socket fails + `listen` with a socket error instead of creating a wider socket. + +macOS rejects `fchmod` on an unbound socket and creates the socket file +with mode `0777 & ~umask`, so it binds in a private directory instead: + +1. Under the endpoint lock, a leftover `{run_dir}/{service_name}.d` from a + crashed server is removed: a directory holding only `s` is emptied and + removed, any other non-directory at that name is unlinked. A directory + with other content is never deleted; `listen` fails with a socket error. +2. `mkdir {service_name}.d` (mode 0700), set 0700 again with `fchmodat` + (`AT_SYMLINK_NOFOLLOW`) so a umask without owner bits cannot make it + unopenable or unusable, then open it with `O_DIRECTORY | O_NOFOLLOW`, + refuse it unless the process's effective user owns it, and `fchmod` the + descriptor to 0700. +3. `bind()` to `{service_name}.d/s`, then, relative to the directory + descriptor, `fchmodat` the socket file to its created mode `& 0600` + (which keeps the umask narrowing) and `renameat` it to + `{service_name}.sock`. Remove the directory. + +No other user can reach the socket while its mode is wider, and the +process-global umask is never changed. `fchmodat` and `open` still resolve +the directory by path, but neither follows a symlink, and the ownership +check on the opened descriptor refuses a directory another account swapped +in. After that check every step except `bind()` goes through the directory +descriptor, so a directory swapped in after it makes `listen` fail instead +of placing a socket another account can reach. The temporary path must also +fit `sun_path`. + ## Profile bit UDS SEQPACKET is profile bit 0 (`0x01`), named `UDS_SEQPACKET`. This is the baseline profile — always supported, always available as fallback. +A POSIX endpoint offers and accepts only the profiles it implements: the +baseline plus `SHM_HYBRID` and `SHM_FUTEX` on Linux and FreeBSD (Go on +FreeBSD only on amd64 and 386), the baseline alone elsewhere. Configured bits outside that set are dropped +before the handshake, so a peer can never select an unassigned profile. + ## Connection lifecycle -1. **Server**: creates and binds the socket, listens for connections. +1. **Server**: takes the endpoint lock (see "Stale endpoint recovery"), + then creates and binds the socket and listens for connections. If a stale socket file exists from a dead process, the server unlinks it and recreates. If the socket is actively held by a live process, the server fails with address-in-use. - If two servers start concurrently for the same service name, one may - lose the probe/bind race and fail with address-in-use. The contract is - one owner per service endpoint. + If two servers start concurrently for the same service name, exactly + one gets the lock; the other fails with address-in-use. The contract + is one owner per service endpoint. 2. **Client**: connects to the socket path. The OS delivers a connected SEQPACKET file descriptor. + The bounded connect (C `nipc_uds_connect_timeout()`, Rust + `UdsSession::connect_with_timeout()`, Go `ConnectTimeout()`) takes a + timeout (0 = none) and an abort signal (C a descriptor, Rust an + `AtomicBool`, Go a channel). A blocking connect waits while the + listener's backlog is full; on Linux the bounded connect waits in + `SO_SNDTIMEO` slices of at most 100 ms, so an abort ends it promptly, + and resets the send timeout once connected. Other systems refuse a full + backlog at once. The wait for HELLO_ACK ends at the same deadline. + Expiry returns a timeout error, an abort an aborted error. `connect()` + is the bounded connect without a deadline or abort. + + On the server, accept is split: `nipc_uds_accept_pending()` (C), + `UdsListener::accept_pending()` (Rust) and `Listener.AcceptPending()` + (Go) take the connection without reading; `nipc_uds_accept_handshake()`, + `PendingConnection::handshake()` and `PendingConn.Handshake()` then wait + for HELLO with a timeout (0 = none) and, in Rust and Go, an abort signal. + Expiry returns a timeout error, an abort an aborted error, in all three + languages. A failed handshake closes the connection. `nipc_uds_accept_handshake_prepared()`, + `PendingConnection::handshake_prepared()` and `PendingConn.HandshakePrepared()` + also take a prepare callback. The server calls it with the selected profile + and the final request and response payload limits after the HELLO checks + and before HELLO_ACK; on false it drops that profile and selects again from + the rest of the intersection, rejecting with UNSUPPORTED when none is left. + Level 2 servers create the session's SHM region in this callback. + `nipc_uds_accept()` / + `accept()` / `Accept()` do both steps with the 5000 ms server handshake + deadline. + 3. **Handshake**: immediately after connection, the client sends a HELLO control message and the server responds with HELLO_ACK, as defined in the wire envelope spec. The handshake determines the @@ -63,8 +153,24 @@ fallback. ## Packet size -SEQPACKET has a kernel-imposed maximum message size. The transport -determines its packet size from the socket's `SO_SNDBUF` option. +SEQPACKET has a kernel-imposed maximum message size. On Linux a datagram +larger than the sender's `SO_SNDBUF` value (as reported by +`getsockopt`) minus 32 bytes of kernel accounting overhead fails with +`EMSGSIZE`, and very large buffers also fail with `ENOBUFS` near 1 MiB. +The stream baseline (macOS, FreeBSD) has its own rule; see "Stream +baseline". + +Each side derives the packet size it proposes as: + +``` +limit = min(SO_SNDBUF - overhead, 262144) (65536 if SO_SNDBUF cannot be read) +proposal = configured packet size, if non-zero and below limit; else limit +``` + +`overhead` is 32 (Linux). + +A configured packet size above the limit is clamped silently; it is a +preference, not a requirement. The effective packet size for the connection is negotiated during handshake: @@ -73,6 +179,10 @@ handshake: agreed_packet_size = min(client_packet_size, server_packet_size) ``` +The client must reject a HELLO_ACK whose `agreed_packet_size` exceeds its +own proposal (or is not greater than the 32-byte header) as a protocol +error: a larger size could exceed what the client's kernel accepts. + Both sides use `agreed_packet_size` for chunking decisions. ## Chunking @@ -104,10 +214,14 @@ The timeout-aware receive form must: caller-provided timeout - apply the same deadline to the first packet and all continuation packets that make up one logical message -- poll an explicit abort file descriptor at the same time as the socket - file descriptor +- observe the caller's abort signal while waiting, either by polling an + abort file descriptor at the same time as the socket file descriptor or + by waiting in slices of at most 100 ms and checking an abort flag + between slices; an abort is observed within 100 ms +- treat the end of an abort-check slice as "keep waiting", never as the + deadline - return a distinct timeout error when the deadline expires -- return a distinct aborted error when the abort descriptor is signaled +- return a distinct aborted error when the abort is observed Level 2 is responsible for deciding whether a timeout or abort should break the session. Level 1 only reports the condition precisely. @@ -134,17 +248,179 @@ client must close that session and reconnect baseline-only. If a later reconnect larger capacities, it establishes a new session with a new `session_id` and therefore a different SHM file path. +## Stream baseline + +macOS and FreeBSD run the baseline over `SOCK_STREAM`. The handshake, +profile bit and packets are unchanged; only how a receiver finds packet +boundaries differs. C, Rust and Go implement it the same way. + +- **Why.** macOS has no `AF_UNIX` `SOCK_SEQPACKET`. FreeBSD 15 keeps + `SOCK_SEQPACKET` record boundaries only where the sender sets `MSG_EOR` + (`socket(2)`, "SEQUENCED PACKET SOCKET TYPE"), lets a non-blocking send + write part of a record, and delivers a record larger than the receive + buffer in parts; FreeBSD 14 keeps whole datagrams. A stream behaves the + same on every FreeBSD release, so FreeBSD uses it on all of them. + +- **Packets.** A packet is the 32-byte header plus the bytes it declares, + so no framing bytes are added. After an outer header (`NIPC`) the body is + `min(payload_len, packet_size - 32)` bytes, after a continuation header + (`NCHK`) it is `chunk_payload_len` bytes. A handshake packet is read + with a packet size of 0, which caps the body at the receive buffer. + Bytes that do not fit the buffer are not read, as a SEQPACKET receive + into a short buffer drops them; the length checks then fail. +- **First header first.** A session's first packet header is checked + before its body is read. A header that is not an outer header, or that + declares more payload or items than the negotiated limits, is passed to + the framing alone, so `LIMIT_EXCEEDED` or a protocol error is reported + without waiting for bytes the peer may never send. +- **End of stream.** EOF before a packet's first byte is a disconnect. EOF + inside a packet is a protocol error, inside a continuation a chunk error. + Bytes left after a message's last packet are read as the next packet. +- **Packet size.** A stream has no datagram limit: the proposal is the + configured packet size, or 262144 when it is 0 or larger. `SO_SNDBUF` is + not consulted. Messages larger than the agreed size are chunked as on + SEQPACKET. +- **Writes.** A packet may take several `sendmsg()` calls; the sender + resumes after a partial write. Header and body go out without copying. + A write to a closed peer fails with `EPIPE` instead of raising + `SIGPIPE`: FreeBSD sends with `MSG_NOSIGNAL`; macOS sets `SO_NOSIGPIPE` + on every session socket, also in Go, whose darwin `syscall` package has + no `MSG_NOSIGNAL`. +- **Partial receive.** A receive timeout or abort before the first byte of + a message leaves the session usable. Any receive failure after it + (timeout, abort, malformed or over-limit packet) leaves unread bytes of + the message queued, so the session marks its receive side failed and + every later receive fails at once, without reading the socket. It does + not call `shutdown(SHUT_RD)`: on FreeBSD 15 that fails the peer's sends, + and on FreeBSD 14 later receives still return the queued bytes. Sends + stay valid, so a server can still answer a rejected request with + `LIMIT_EXCEEDED`. +- **Socket buffers.** The default `AF_UNIX` stream buffers are 8 KiB per + direction on macOS and FreeBSD 14 and 64 KiB on FreeBSD 15 + (`net.local.stream.sendspace`, `recvspace`), far below Linux's, so a + pipelining client's sends wait for buffer space sooner. "Pipelined sends" + below keeps that from deadlocking. +- **Connect.** A full listen backlog refuses a connect at once + (`ECONNREFUSED`), as on FreeBSD; Linux waits for room. +- **SHM.** FreeBSD offers SHM profiles over the stream baseline, as Linux + does over SEQPACKET (`level1-posix-shm.md`); the handshake that selects + them is the same packets. macOS has no SHM: its servers and clients offer + the baseline only. + +## Pipelined sends + +The pipelining guarantee in `level1-transport.md` ("Pipelining") works like +this, the same in C, Rust and Go: + +- **Who.** A client session sending a packet while another request is in + flight (the in-flight set holds more than the request being sent), and a + client send with a deadline or abort (`nipc_uds_send_timeout`, Rust + `UdsSession::send_with_control`, Go `Session.SendTimeout`; every typed + call passes both). Any other client send and every server session send + use a plain blocking call. The deadlock needs both peers blocked in send, + and client progress alone breaks it. +- **Send.** Each packet goes out with `MSG_DONTWAIT` (on the stream + baseline the descriptor is also made `O_NONBLOCK` for that send and + restored after it, because macOS ignores the flag on a stream send short + of buffer space and blocks; FreeBSD shares the stream code). On + `EAGAIN` the client polls for readable and writable. When readable, it reads what is there + without blocking into a per-session pending queue (SEQPACKET: whole + datagrams; stream: raw bytes), then retries the send. A stream send + resumes after partial writes. A send without a deadline or abort waits as + long as a blocking send would. +- **Deadline and abort.** A bounded send polls with the time left before + its deadline; C also polls the abort descriptor, Rust and Go recheck the + abort flag at least every 100 ms. When the deadline passes or the abort + is raised before the message is sent, the send returns the timeout or + aborted error, shuts the session down and fails every in-flight request: + part of the message may already be on the wire. +- **Receive.** Every receive takes packets from the pending queue before the + socket, so reassembly, limits and in-flight validation are unchanged. +- **Bound.** The queue may hold at most what the in-flight responses can + occupy on the wire: in-flight count × (maximum response payload + one + 32-byte header per packet), plus the length the queue keeps with each + SEQPACKET datagram. More fails the send with a protocol error; the + session is shut down and every in-flight request fails, since the dropped + bytes cannot be recovered. +- **Peer close.** At EOF or a socket error the client stops reading and + waits only for writability; the retried send then fails with the existing + send error, so a closed peer never leaves the client waiting or spinning. +- **Polling the fd.** Queued responses no longer show on the session fd. + A caller that waits on the fd (`nipc_uds_session_fd`, Rust + `UdsSession::fd`, Go `Session.Fd`) before receiving must first check + `nipc_uds_session_has_pending` (Rust `UdsSession::has_pending`, Go + `Session.HasPending`) and receive without waiting while it is true. + Callers that only call receive need nothing: receive reads the queue + first. Only a pipelining client fills the queue; server sessions never do. +- **Session close** frees the queue. + +## Interrupted system calls + +`send()`, `sendmsg()` and `recv()` calls that fail with `EINTR` are +retried inside the transport. A SEQPACKET call moves a whole message or +nothing, so a retry can neither duplicate nor split a message. On the +stream baseline an interrupted call moved no bytes, and partial transfers are +resumed, so the same holds there. A server's `accept()` that fails with +`EINTR` is retried too, so a signal never ends an accept loop. Signals +delivered to the process therefore never surface as accept, send or receive +failures. The Go runtime installs its signal handlers with `SA_RESTART`, so +the kernel restarts these calls for Go before they can return `EINTR`. + ## Stale endpoint recovery +### Endpoint lock + +- Before it touches the socket path, the server takes an exclusive, + non-blocking `flock` on `{run_dir}/{service_name}.lock`, opened with + `O_CLOEXEC | O_NOFOLLOW`. +- A missing lock file gets its name only once it is 0600, so a umask without + owner bits and a crash cannot leave an unopenable lock: the server creates + `{service_name}.lock.{pid}.{sequence}.tmp`, `fchmod`s it to 0600, hard-links + it to the lock name and unlinks the temporary name. Losing the link to + another starter is success. A crash can leave the temporary file behind; + it is harmless and nothing reads it. If any of these steps fails, for + example on a filesystem without hard links, the server creates the lock + file directly. +- A lock file whose mode lacks owner read or write is opened write-only, or + else read-only (`flock` works on any access mode), and every open + `fchmod`s the descriptor back to 0600. A lock file at mode 000 is not + repaired: the listen fails until an operator removes it, unless the + process can bypass file permissions. +- If another listener holds the lock, the server fails with + address-in-use. This closes the window between one server's `bind()` + and `listen()`, where a connect probe would see a refused socket and + unlink a live endpoint. +- The listener holds the lock for its whole life and releases it after + it unlinks the socket at close. The kernel releases it when the + process dies. A listener can stop listening first (C + `nipc_uds_stop_listening()`, Rust `UdsListener::stop_listening()`, Go + `Listener.StopListening()`): that closes the socket and unlinks its file + but keeps the lock until close, which a server uses to keep the endpoint + while it finishes its sessions. +- Every release, at close and on a failed listen, unlocks (`LOCK_UN`) + before it closes the fd. A child forked by another thread shares the + lock's open file description until it execs, so a plain close would + leave the lock held by the child's copy. The lock file itself is never deleted: deleting it would + let a server that already opened the old file and a server that + creates a new one both hold "the" lock. +- All three implementations use the same lock file, so C, Rust and Go + servers exclude each other. + ### Socket file -- Server checks if the socket path exists before bind. +- Server checks if the socket path exists before bind. The connect + probe below stays as a guard against servers of older versions, + which take no lock. - If it exists: attempt to connect to it. If connection succeeds, a live server owns it — fail with address-in-use. If connection fails with ENOENT, nothing is there. Any other connect failure (ECONNREFUSED, EACCES, a foreign file squatting on the socket path, etc.) means the endpoint is not a live server — it is silently - unlinked and recreated. + unlinked and recreated. An empty directory at the path is removed + too; a non-empty one is kept and the server fails with + address-in-use. Unlinking a directory fails with `EISDIR` on Linux + and `EPERM` on FreeBSD and macOS, so both errors lead to the + directory removal. - The only exception is fd exhaustion (EMFILE/ENFILE) while creating the probe socket: liveness cannot be evaluated, so the endpoint is kept and the server fails with address-in-use rather than risk diff --git a/docs/level1-transport.md b/docs/level1-transport.md index a5876c7c..a987647e 100644 --- a/docs/level1-transport.md +++ b/docs/level1-transport.md @@ -85,6 +85,20 @@ message_id. If a caller needs ordered results for dependent work, it should use batching (one message, ordered items). Pipelining is for independent work. +A pipelining client may send all its requests before reading any response, +whatever the bytes in flight. Once those bytes exceed what the socket buffers +hold, the server blocks sending responses the client is not reading, and stops +reading requests. A client send that blocks then must not wait for the server: +while it waits for buffer space, it reads the responses that arrive and keeps +them for later receives. Both peers keep making progress, so pipelining cannot +deadlock on buffer size. The in-flight set bounds those kept bytes: more than +the in-flight responses can occupy is a protocol violation that fails the +session. A client with one request in flight sends exactly as before. + +Baseline sessions implement this in all three languages: POSIX in +`level1-posix-uds.md`, "Pipelined sends", and Windows Named Pipes in +`level1-windows-np.md`, "Pipelining". + ### 4. Batching Batching is a Level 1 framing feature, not a typed-method feature. @@ -185,7 +199,12 @@ Level 1 manages the full lifecycle of transport connections: clients. One listener serves one service endpoint. - **Accept**: accept incoming client connections on a listener. Each accepted connection becomes an independent session after completing the handshake. - A listener must support multiple concurrent sessions. + A listener must support multiple concurrent sessions. Accept is split in + two steps: taking the connection (no bytes read), then running the server + handshake with a deadline and an abort signal. This lets a server run the + handshake outside its accept loop, so one stalled client cannot block + others. The one-step accept remains as a convenience; it applies the + server handshake deadline below without an abort signal. - **Close**: tear down a session or a listener. Closing a session releases its transport resources. Closing a listener stops accepting new connections but does not forcibly terminate existing sessions (that is a higher-level @@ -194,8 +213,15 @@ Level 1 manages the full lifecycle of transport connections: ### Handshake Every new session begins with a handshake over the baseline transport. The -handshake is a single request-response exchange using control messages. It -establishes: +handshake is a single request-response exchange using control messages. + +The server bounds its wait for HELLO. The managed server (Level 2) drops a +connection that has not delivered HELLO within 5000 ms +(`NIPC_SERVER_HANDSHAKE_TIMEOUT_MS` in C, `SERVER_HANDSHAKE_TIMEOUT_MS` in +Rust, `ServerHandshakeTimeoutMs` in Go), and abandons the wait at once when +it is stopped. + +The handshake establishes: - **Authorization**: the client presents a caller-supplied auth token. The server verifies it. If verification fails, the session is rejected. The @@ -219,6 +245,9 @@ establishes: server). Both sides use this single negotiated packet size for chunking on the connection. The negotiated packet size must be strictly greater than the header size (32 bytes); if it is not, the server rejects the handshake. + Each side's proposal must not exceed the largest message its own transport + can send in one packet, and the client rejects a negotiated size larger + than its proposal. ### Handshake strategy @@ -280,8 +309,8 @@ sequenceDiagram S->>S: Validate HELLO and auth S->>S: Choose selected_profile = SHM S->>S: Compute final session limits - S->>S: Provision SHM resources for this session - S->>S: Allocate session_id tied to this SHM session + S->>S: Allocate session_id for this session + S->>S: Provision SHM resources named by session_id S-->>C: HELLO_ACK(status=OK,\nselected_profile=SHM,\nfinal limits, packet_size, session_id) Note over C,S: Server-side SHM is already prepared for immediate client attach ``` @@ -376,7 +405,9 @@ message size. Level 1 handles this transparently: metadata to detect wrong packet order, wrong message identity, and accidental desync. The sender reuses offsets over the original message buffer — no copying or splitting of the payload data. The send blocks until - all chunks of the single logical message are transmitted. + all chunks of the single logical message are transmitted, or until a + client send's deadline or abort ends it (the transport documents describe + how). - **Receiver**: reads the first chunk and its outer header. Allocates the full logical message buffer once from the metadata in the first chunk. Receives @@ -441,8 +472,13 @@ Level 1 monitors connection health at the transport level: Level 1 exposes native OS wait objects so callers can integrate sessions and listeners into their own event loops: -- POSIX: file descriptor (fd) -- Windows: HANDLE +- POSIX: file descriptor (fd). A UDS session may already hold received + responses that do not show on the fd, so check for them before waiting: + C `nipc_uds_session_has_pending()`, Rust `UdsSession::has_pending()`, Go + `Session.HasPending()`. +- Windows: HANDLE. A Named Pipe session handle is signalled by I/O + completion, not by arriving data, so session read-readiness comes from + the wait-readable call; see `level1-windows-np.md`, "Pipelining". Wait objects are available for: @@ -460,9 +496,15 @@ Level 1 supports multiple transport backends behind the same API surface: | Transport | Platform | Characteristics | |-----------|----------|-----------------| -| UDS SEQPACKET | Linux, FreeBSD, macOS | Message-oriented, packet-limited, always available | +| UDS SEQPACKET | Linux | Message-oriented, packet-limited, always available | +| UDS stream | macOS, FreeBSD | Same packets over `SOCK_STREAM`, delimited by their headers; always available | | Named Pipe | Windows | Message-oriented, always available | +macOS has no `AF_UNIX` `SOCK_SEQPACKET`, and FreeBSD 15 `SOCK_SEQPACKET` +does not keep NetIPC's packet boundaries. Their baseline carries the same +bytes under the same profile bit, so peers cannot tell the difference; see +`level1-posix-uds.md`, "Stream baseline". + Baseline transports are always available and require no negotiation beyond the standard handshake. They support full pipelining (many in-flight messages). They may require chunking for large messages. @@ -472,6 +514,7 @@ They may require chunking for large messages. | Transport | Platform | Synchronization | |-----------|----------|-----------------| | SHM | Linux | Futex-based | +| SHM | FreeBSD | `_umtx_op`-based (Go: amd64 and 386 only) | | SHM | Windows | WaitOnAddress / named events | SHM transports are negotiated upgrades from the corresponding baseline @@ -496,8 +539,15 @@ prohibition. Callers that need pipelining over SHM should pipeline at the batch level (one batch message in flight containing multiple items) or fall back to the baseline transport. -FreeBSD and macOS do not have SHM backends. They always use the baseline UDS -transport. +macOS, and Go on FreeBSD architectures other than amd64 and 386, do not have +SHM backends. They always use the baseline UDS transport: there the POSIX +handshake masks both the client's offered profiles and the server's supported +and preferred profiles to the baseline bit, whatever the caller configured, so +a misconfigured peer can never negotiate an SHM profile. The SHM API still +exists in C and Go and reports "unsupported"; the Rust crate compiles the POSIX +SHM module only on Linux and FreeBSD. On FreeBSD a server whose run directory +is on ZFS cannot reserve region files and serves baseline (see +`level1-posix-shm.md`). ### Transport selection diff --git a/docs/level1-windows-np.md b/docs/level1-windows-np.md index d9105b50..68d75f71 100644 --- a/docs/level1-windows-np.md +++ b/docs/level1-windows-np.md @@ -42,14 +42,104 @@ identical pipe names from the same inputs. Named Pipe is profile bit 0 (`0x01`), named `NAMED_PIPE`. This is the Windows baseline profile. +## Access policy + +NetIPC services are local, and server and clients run as the same account +(Netdata runs its agent and plugins as LocalSystem). + +- **Pipe DACL**: every pipe instance is created with an explicit, protected + security descriptor, `D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;)`, + where `` is the user SID of the server process token. Only + SYSTEM, Administrators and the server's own account can open the pipe; + Everyone and Anonymous get no access, not even read-only. A client running + as another non-admin account fails to open the pipe with + `ERROR_ACCESS_DENIED`, which the client reports as a connect failure. +- **Remote clients**: pipes are created with `PIPE_REJECT_REMOTE_CLIENTS`, + so opens through SMB (`\\host\pipe\...`, including `\\127.0.0.1`) are + refused. +- **Client impersonation level**: clients open the pipe with + `SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION`. A server can identify + the client's account but never act as it. +- **Server identity**: whoever creates a pipe name first owns it. Right after + opening the pipe, before HELLO, the client reads the pipe owner + (`GetSecurityInfo`, `OWNER_SECURITY_INFORMATION`) and continues only when + it is the client's own account, SYSTEM (`S-1-5-18`) or Administrators + (`S-1-5-32-544`). A non-admin account cannot assign any of those as owner, + so a pipe it squats is refused and receives neither HELLO nor the auth + token. The refusal is a distinct L1 error (C + `NIPC_NP_ERR_UNTRUSTED_SERVER`, Rust `NpError::UntrustedServer`, Go + `ErrUntrustedServer`); Level 2 clients report it as `AUTH_FAILED`. + +An elevated administrator creates objects owned by Administrators; a +non-elevated user or a service account creates objects owned by its own SID. +Both pass the client check when the client runs as the same account. + ## Connection lifecycle 1. **Server**: creates a Named Pipe instance with `CreateNamedPipeW`. - The pipe is created in message mode with overlapped I/O capability. - The server calls `ConnectNamedPipe` to wait for a client connection. - -2. **Client**: connects to the pipe with `CreateFileW`. The connection - is established when the server's `ConnectNamedPipe` completes. + The pipe is created in message mode for synchronous I/O. The server + calls `ConnectNamedPipe` to wait for a client connection. + +2. **Client**: connects to the pipe with `CreateFileW` and + `FILE_FLAG_OVERLAPPED`, so a pipelining send can read while its write + waits (see "Pipelining"). The connection is established when the + server's `ConnectNamedPipe` completes. + + Between accepting one client and creating the next instance a server + has no free instance, and `CreateFileW` fails with `ERROR_PIPE_BUSY`. + The client then waits with `WaitNamedPipeW`, for at most 1000 ms + (C `NIPC_NP_CONNECT_BUSY_WAIT_MS`, Rust `CONNECT_BUSY_WAIT_MS`, Go + `ConnectBusyWaitMs`). `WaitNamedPipeW` does not reserve the instance it + reports, so another client may take it first: the client retries + `CreateFileW` after each successful wait, and waits again on + `ERROR_PIPE_BUSY`, until that bound ends. Any other failure fails the + connect. + + The bounded connect (C `nipc_np_connect_timeout()`, Rust + `NpSession::connect_with_timeout()`, Go `ConnectTimeout()`) takes a + timeout (0 = none) and an abort signal (C an event handle, Rust an + `AtomicBool`, Go a channel). The busy wait then also ends at that + deadline, in `WaitNamedPipeW` slices of at most 100 ms so an abort ends + it promptly, and the wait for HELLO_ACK ends at the same deadline. Expiry + returns a timeout error, an abort an aborted error; both are checked + before the pipe is opened, so a connect that starts expired or aborted + never takes a pipe instance. `connect()` is the + bounded connect without a deadline or abort. A pipe that disconnected + while the client waits for HELLO_ACK is still read, because a server + that rejects the client writes its reply and then closes. + + On the server, accept is split: `nipc_np_accept_pending()` (C), + `NpListener::accept_pending()` (Rust) and `Listener.AcceptPending()` + (Go) return the connected pipe instance without reading, after + creating the next instance for the next client. When that creation + fails, accept returns a create-pipe error, disconnects the client and + keeps its instance as the listener's, so the next accept listens on it + again; + `nipc_np_accept_handshake()`, `PendingPipe::handshake()` and + `PendingConn.Handshake()` then wait for HELLO with a timeout (0 = none) + and, in Rust and Go, an abort signal. Expiry returns a timeout error, + an abort an aborted error, in all three languages. A failed handshake + closes the instance without `DisconnectNamedPipe`, which would discard a rejection + reply the client has not read yet. `nipc_np_accept_handshake_prepared()`, + `PendingPipe::handshake_prepared()` and `PendingConn.HandshakePrepared()` + also take a prepare callback with the selected profile and the final + payload limits, called after the HELLO checks and before HELLO_ACK; on + false the server drops that profile and selects again, rejecting with + UNSUPPORTED when none is left. Level 2 servers create the session's SHM + objects for the selected profile only in this callback. + `nipc_np_accept()` / `accept()` / `Accept()` do both + steps with the 5000 ms server handshake deadline. A server wakes a + blocked `ConnectNamedPipe` by connecting to its own pipe name and + closing at once (Rust + `NpListenerWaker`, Go `Listener.Wake()`); it never closes a handle the + accepting thread may still use. + + Closing a session only closes its handle, in both roles. The peer still + reads every message written before the close, then sees + `ERROR_BROKEN_PIPE`. Sessions do not call `FlushFileBuffers` or + `DisconnectNamedPipe` on close: a flush blocks while the peer is not + reading, and on a synchronous pipe handle neither `CancelSynchronousIo` + nor `DisconnectNamedPipe` from another thread releases it. 3. **Handshake**: immediately after connection, the client sends a HELLO control message and the server responds with HELLO_ACK, as @@ -89,6 +179,68 @@ sequentially. See the wire envelope spec for chunk header layout and validation. +## Pipelining + +The pipelining guarantee in `level1-transport.md` ("Pipelining") works like +this, the same in C, Rust and Go. Each pipe direction buffers the larger of +the packet size and 64 KiB. + +- **Handles.** Client handles are overlapped; server handles are + synchronous. Every client read and write passes an `OVERLAPPED` with its + own manual-reset event and waits for completion before the call returns: + no I/O stays posted between calls. +- **Who.** A client session sending a packet while another request is in + flight (the in-flight set holds more than the request being sent). A + client with one request in flight (every typed call) waits for each write + to complete, bounded by its deadline and abort when it has them (below), + and every server session sends with a blocking `WriteFile`. + The deadlock needs both peers blocked in send, and client progress alone + breaks it. +- **Send.** The client posts the write, then loops until it completes: + while `PeekNamedPipe` reports bytes, it reads whole messages into a + per-session pending queue; otherwise it yields (`SwitchToThread`) for up + to 1 ms after the last progress, then waits on the write event in short + slices (1 ms requested; a slice lasts a system timer tick, and only the + write completing ends one early). + A send without a deadline or abort waits as long as a blocking write + would. +- **Deadline and abort.** A client send with a deadline or abort + (`nipc_np_send_timeout`, Rust `NpSession::send_with_control`, Go + `Session.SendTimeout`; every typed call passes both) also ends when the + deadline passes or the abort is raised before its write completes. C + waits on the write and abort events together; Rust and Go recheck the + abort flag at least every 100 ms. The send returns the timeout or aborted + error and fails as below: part of the message may already be on the + wire. +- **Receive.** Every receive takes messages from the pending queue before + the pipe, so reassembly, limits and in-flight validation are unchanged. A + message larger than the receive buffer fails the receive, from the queue + (which drops that message) as from the pipe (`ERROR_MORE_DATA`, which + leaves its rest in the pipe). +- **Bound.** The queue may hold at most what the in-flight responses can + occupy on the wire: in-flight count × (maximum response payload + one + 32-byte header per packet), plus the 4-byte length the queue keeps with + each packet. More fails the send with a protocol error. +- **Failure.** A send that fails while its write is posted (the bound, a + failed peek, read or wait, the deadline or an abort) cancels the write with `CancelIoEx`, waits until the + kernel releases it, closes the pipe and fails every in-flight request: a + cancelled write leaves the message stream unknown and the dropped bytes + cannot be recovered. Later calls on the session fail; closing it still + releases the rest. +- **Peer close.** When `PeekNamedPipe` or a read reports the pipe broken or + disconnected, the client stops reading and only waits for the write; the write then + fails with the existing send error. +- **Waiting.** Queued responses no longer show on the pipe. Use + `nipc_np_wait_readable` (Rust `NpSession::wait_readable`, Go + `Session.WaitReadable`), which reports them, or check + `nipc_np_session_has_pending` (Rust `NpSession::has_pending`, Go + `Session.HasPending`) and receive without waiting while it is true. The + pipe handle (`nipc_np_session_handle`, Rust `NpSession::handle`, Go + `Session.Handle`) is not a readability signal: a pipe handle is signalled + by I/O completion, not by arriving data. Only a pipelining client fills + the queue; server sessions never do. +- **Session close** frees the queue and the events. + ## Receive timeout and abort The baseline receive primitive remains a blocking Level 1 operation for @@ -101,9 +253,14 @@ The timeout-aware receive form must: caller-provided timeout - apply the same deadline to the first packet and all continuation packets that make up one logical message -- observe an explicit abort event while waiting for pipe readability +- observe the caller's abort signal while waiting for pipe readability, + either through an abort event or by waiting in slices of at most 100 ms + and checking an abort flag between slices; an abort is observed within + 100 ms +- treat the end of an abort-check slice as "keep waiting", never as the + deadline - return a distinct timeout error when the deadline expires -- return a distinct aborted error when the abort event is signaled +- return a distinct aborted error when the abort is observed Level 2 is responsible for deciding whether a timeout or abort should break the session. Level 1 reports timeout and abort distinctly; peer diff --git a/docs/level1-windows-shm.md b/docs/level1-windows-shm.md index 90ade044..9face950 100644 --- a/docs/level1-windows-shm.md +++ b/docs/level1-windows-shm.md @@ -210,6 +210,22 @@ must periodically yield so an infinite wait does not monopolize a core. - On peer close detection, the detecting side must report the session as disconnected (graceful close, equivalent to `io.EOF`). +### Detecting a crashed peer + +A crashed process never sets its close flag. The named-pipe session stays +open for the whole SHM session and the kernel breaks it when the peer +process exits, so the pipe is the crash signal for both sides: + +- The probe is a zero-wait `PeekNamedPipe`; a disconnect error (see + `level1-windows-np.md`, "Disconnect detection") means the peer is gone. + No data flows on the pipe during an SHM session. +- It runs only after a wait slice expires with no message: the server + after each idle receive poll (100 ms), the client after each idle slice + of its response wait (100 ms). +- A server that sees the peer gone ends the session (freeing the worker + and the region). A client fails the in-flight call with a disconnected + error. + ## Memory ordering - Sequence number and length stores use interlocked (atomic) operations @@ -232,9 +248,13 @@ throughput. ### Server creates a per-session region -The server creates one SHM region per accepted session, after the -handshake negotiates an SHM profile. The kernel object names are -derived from the `session_id` assigned during the handshake. +The server creates one SHM region per accepted session, inside the +handshake: after HELLO validation, authorization, profile selection and +the limit checks pick an SHM profile, and before HELLO_ACK. It creates the +objects for the selected profile only; baseline sessions and rejected +handshakes create none. The region is sized from the final request and +response payload limits plus the message header. The kernel object names +are derived from the `session_id` assigned to the handshake. 1. Derive kernel object names using `session_id` (see naming section). 2. Create a page-file-backed mapping via `CreateFileMappingW` using @@ -245,16 +265,19 @@ derived from the `session_id` assigned during the handshake. 3. Map the view via `MapViewOfFile`. 4. Write the header: magic, version, header_len, profile, offsets, capacities, spin_tries. Initialize all volatile fields to zero. + The mapping takes the same protected owner-only DACL as the pipe + (SYSTEM, Administrators and the server's account; see the Named Pipe + access policy), so other accounts cannot open it. 5. If SHM_HYBRID: create `req_event` and `resp_event` as auto-reset kernel events (`CreateEventW` with `bManualReset = FALSE`). Auto-reset is required: `SetEvent` wakes exactly one waiter and resets automatically, which matches the one-writer/one-reader-per- - direction model. + direction model. Both events take the owner-only DACL too. 6. The region is now ready for the client. `CreateFileMappingW` and `MapViewOfFile` failures are SHM preparation errors. Managed -servers remove Windows SHM profiles and continue over the Named Pipe baseline when it -is configured. Windows does not use the Linux file-backed `fallocate` lifecycle. +servers remove the failed Windows SHM profile from that handshake and select again, +continuing over the Named Pipe baseline when it is configured. Windows does not use the Linux file-backed `fallocate` lifecycle. The server must track all active per-session SHM regions so they can be cleaned up on session close and server shutdown. @@ -275,6 +298,16 @@ objects in place. 6. If SHM_HYBRID: open `req_event` and `resp_event` kernel events. 7. The client is now ready to publish requests and consume responses. +If the objects cannot be opened yet, the client retries. Between retries +it probes the session pipe; if the server already broke it (refused at +worker capacity, or died), the client stops retrying, reports the connect +as failed, and keeps SHM enabled for its next connect: the session failed, +not the attach. A call deadline or an abort that ends the wait also keeps +SHM enabled. When the attach itself fails, or its own timeout ends the +wait, with the pipe still connected, the client closes the session, +removes SHM from its later proposals, and reconnects over the Named Pipe +baseline (see `level1-windows-np.md`). + ### Server destroys a per-session region When a session closes (graceful or broken): diff --git a/docs/level1-wire-envelope.md b/docs/level1-wire-envelope.md index a0b1525a..2295e11d 100644 --- a/docs/level1-wire-envelope.md +++ b/docs/level1-wire-envelope.md @@ -215,6 +215,10 @@ The server does not return partial success. A successful handshake means: - all session-operational fields in `HELLO_ACK` are final for that session - `selected_profile` is final and locked for that session +The client must reject, as a protocol error, a successful `HELLO_ACK` whose +`selected_profile` is not exactly one profile bit or is a profile the client +did not offer in `supported_profiles`. + If `selected_profile` is an SHM profile, the server must not send successful `HELLO_ACK` until the SHM resources for that session are already ready on the server side. If the client later cannot attach the negotiated SHM transport, @@ -249,6 +253,16 @@ Clarifications: - all packed item payloads - `max_request_batch_items` and `max_response_batch_items` are item-count ceilings, enforced independently from payload-byte ceilings. +- On every transport, a client rejects a HELLO_ACK whose + `agreed_packet_size` exceeds its own `packet_size` proposal, or is not + greater than the 32-byte header, as a protocol error: it sizes its + buffers, and on SEQPACKET its datagrams, from its proposal. +- HELLO carries exactly 44 payload bytes and HELLO_ACK exactly 48. A + receiver rejects any other `payload_len` as a protocol error, after the + layout check, so a future layout is still reported as incompatible. On + message-oriented transports (SEQPACKET, Named Pipe) the packet must also + be exactly the header plus that payload; on the stream baseline the bytes + after the payload are read as the next packet. ### Handshake rejection semantics @@ -301,9 +315,15 @@ Bit 0 is always the baseline profile for the platform. `(intersection & client_preferred & server_preferred)` - fallback choice: highest bit in `intersection` 6. Server computes the final session limits using the matrix above. -7. If the final profile is SHM, the server makes SHM for that session ready - before sending successful `HELLO_ACK`. -8. Server allocates `session_id`. +7. Server allocates `session_id`. +8. If the final profile is SHM, the server makes SHM for that session ready + before sending successful `HELLO_ACK`, sized for the final limits. The + server creates SHM only for the selected profile and only after steps 2-6 + succeed, so baseline sessions and rejected handshakes create none. If SHM + preparation fails, the server removes that profile from its supported set + and the intersection and repeats step 5 in the same handshake, keeping the + final limits and `session_id`; with no profile left it rejects with + `UNSUPPORTED`. 9. Server sends `HELLO_ACK` with `transport_status = OK`. 10. Both sides use the returned `HELLO_ACK` values for the rest of the session. diff --git a/docs/level2-typed-api.md b/docs/level2-typed-api.md index d5782027..526c536c 100644 --- a/docs/level2-typed-api.md +++ b/docs/level2-typed-api.md @@ -241,7 +241,20 @@ once. Duplicate requests are acceptable by contract. There are two important cases: - For ordinary transport / peer failures, Level 2 reconnects and retries - once. + once. When the peer is gone (end of stream, `EPIPE`, `ECONNRESET`, broken + pipe, or an SHM session whose session socket/pipe the peer closed) and + the retry also fails, the reported error is a distinct disconnected + error (C `NIPC_ERR_DISCONNECTED`, Rust `NipcError::Disconnected`, Go + `protocol.ErrDisconnected`). Protocol violations keep their own errors + even if the peer closes right after sending them. +- Any other send/receive I/O failure, with the peer still present, is a + distinct transport error (C `NIPC_ERR_TRANSPORT`, Rust + `NipcError::Transport`, Go `protocol.ErrTransport`). The same error + covers an SHM send or receive that fails for a reason other than + message size, and a READY client that has no session. An SHM response + larger than the negotiated capacity is an overflow, as a baseline + response over the limit is; an SHM message shorter than a header + (including zero length) is a truncated message. - Timeout and caller-requested abort are terminal for the current call: Level 2 reports the explicit error, disconnects or marks the current session broken, and does not reconnect/retry that same request. @@ -254,22 +267,40 @@ There are two important cases: powers of 2. If the session was NOT previously READY, the call fails immediately -without attempting reconnection. +without attempting reconnection, with a distinct not-ready error (C +`NIPC_ERR_NOT_READY`, Rust `NipcError::NotReady`, Go +`protocol.ErrNotReady`). If recovery fails, Level 2 reports failure to the caller. Every synchronous Level 2 client call is bounded. A per-call timeout of zero means "use the client context default"; the default is 30000 ms. -The deadline applies to the complete logical response, including baseline -transport chunk continuations and SHM response waits. A timeout returns a -distinct timeout error rather than being collapsed into disconnect or -protocol failure. +One deadline covers the whole call: every attempt, the reconnect and +handshake between attempts, any SHM attach after a reconnect, and every +overflow-driven reconnect. The deadline applies to the request send, which +ends at the deadline when the server stops reading, and to the complete +logical response, including baseline transport chunk continuations and SHM +response waits; the response waits only for what the send left of it. A timeout returns a distinct timeout error rather than +being collapsed into disconnect or protocol failure; a reconnect that ends +because the deadline passed reports the timeout too. + +`refresh()` is bounded the same way, by the client context call timeout: +its connect, handshake and SHM attach end once that timeout passes, so a +server that accepts but never answers cannot hold the caller's loop. When +the call deadline, not the SHM attach timeout, ends an SHM attach wait, +SHM stays enabled for the next connect. Every Level 2 client context also exposes an abort signal that is safe to trigger from another thread while one thread is blocked in a typed call. Abort is sticky until the caller explicitly clears it or closes the client. A call that observes the abort signal returns a distinct aborted -error and is not retried. +error and is not retried. While an abort is pending, `refresh()` does not +connect, and an abort raised during a connect or handshake ends it. A call +or refresh blocked waiting for its peer observes an abort within 100 ms on +every transport, including a request send blocked on a full socket or pipe +and SHM response waits; an implementation may +notice it sooner (for example C polls an abort descriptor together with +the socket), but callers must not rely on faster than 100 ms. Negotiated request and response payload ceilings are configuration controlled. The library may provide documented defaults, but the effective @@ -491,9 +522,17 @@ one service kind only. The managed server internally: 1. Creates a Level 1 listener for the service endpoint -2. Runs an acceptor loop that accepts incoming Level 1 sessions -3. Spawns a thread (C, Rust) or goroutine (Go) per accepted session, - up to the configured maximum concurrent sessions. In Go, each +2. Runs an acceptor loop that accepts incoming Level 1 connections + without reading from them +3. Spawns a thread (C, Rust) or goroutine (Go) per accepted connection, + up to the configured maximum concurrent sessions. The worker runs the + handshake with the server handshake deadline (5000 ms, see Level 1), + so a client that never sends HELLO holds its worker only until then. + When all workers are busy, the server waits up to 200 ms (two poll + slices) for one to finish, so a client that reconnects right after + closing is not refused while its old worker exits. If none frees, the + server closes the new connection before HELLO_ACK: the client's connect + fails, and it retries on its next refresh. In Go, each session goroutine recovers from panics so that a single malformed request cannot crash the entire server process. In Rust, thread panics are contained by default — a panicking session thread dies @@ -505,6 +544,24 @@ The managed server internally: 6. Per-session isolation: each session has its own internal reusable buffers and builders, with no cross-session coordination +### Readiness + +Clients can connect once the listener exists: + +- C: `nipc_server_init()` creates the listener before it returns (POSIX + binds and listens on the socket; Windows creates the first pipe + instance). A client may connect as soon as init returns, even before + `nipc_server_run()` starts; the connection is accepted when the run + loop starts. +- Rust and Go create the listener inside `run()` / `Run()`. Callers that + start a server and then start clients wait for the listening signal: + Rust `listening_signal()` returns a `ListeningSignal` whose + `wait(timeout)` reports whether the server listens; Go `Listening()` + returns a channel that closes once the server listens. Both exist on + the raw server and every typed server. The signal never fires when + `run()` / `Run()` fails before listening, so wait together with the + run result. + ### Handler contract The public managed-server contract is typed. @@ -563,12 +620,41 @@ gets `transport_status = INTERNAL_ERROR` with empty payload. ### Shutdown -The caller signals shutdown explicitly. The managed server stops accepting -new connections and cleans up resources. The exact drain/abort mechanics -are implementation details. +The caller signals shutdown explicitly (C `nipc_server_stop()`, +`nipc_server_destroy()` or `nipc_server_drain()`; Rust `stop()`; Go +`Stop()`). The managed server stops accepting new connections and cleans +up resources: + +- A stop is remembered: a stop issued before the run loop starts makes it + return at once. A managed server is single-use. +- Stop only signals; the run loop owns the listener and closes it when it + exits. On POSIX that closes the listening socket and removes the socket + file at once, while the endpoint lock stays held until every worker of + the server has exited (C releases it in `nipc_server_drain()` or + `nipc_server_destroy()` after joining the workers), so no other server + takes the endpoint while this one still serves sessions. +- Handshakes in progress observe the stop and end at once. +- Sessions get a 1000 ms grace period to finish on their own. After it, + the server interrupts blocking session I/O, so a worker blocked sending + to a client that stopped reading cannot hold shutdown: POSIX shuts the + session socket down (`shutdown(SHUT_RDWR)`), Windows cancels the worker + thread's pipe I/O (`CancelSynchronousIo`), repeated until every worker + has exited. +- C `nipc_server_drain()` keeps its own caller-supplied wait for in-flight + requests before this step. ## Testing requirements +Correctness tests are deterministic: they synchronize on events (server +listening, handshake started, session exited, flags raised by handlers), +never on sleeps or retry loops, and a test passes or fails in one run. +Production timings (poll slice, stop grace, handshake deadline, client SHM +attach window) are per-instance fields that tests shorten through +test-only entrypoints (C `*_for_tests` functions under +`NIPC_INTERNAL_TESTING`, Rust crate-internal setters, Go unexported +fields). Fuzz, stress and randomized tests are not correctness tests; they +run from `tests/run-extended-fuzz.sh` and `tests/run-extended-stress.sh`. + Level 2 must have: - **High test coverage** (90%+ enforced): every client state transition, every call path, diff --git a/docs/netipc-integrator-skill.md b/docs/netipc-integrator-skill.md index 94538606..90f91a47 100644 --- a/docs/netipc-integrator-skill.md +++ b/docs/netipc-integrator-skill.md @@ -37,6 +37,19 @@ The Rust ABI runner also asserts the compiled layout for each requested mode; both timeout behavior and the expected ABI must pass. The C ABI fixture does not replace building and testing each language consumer for the target architecture or measuring the deployed service's CPU use. +The C POSIX transport sources define `_FILE_OFFSET_BITS 64` themselves, so a +32-bit consumer built with the legacy `off_t`/`time_t` layout needs no extra +flags; no public header exposes `off_t` or `struct stat`. +Runtime Safety CI vets the Go packages on amd64, 386, arm, arm64, riscv64, +loong64, ppc64le, s390x and mips64le Linux. It also builds and runs the C and Rust suites +plus C/Rust/Go interop on every Linux target Netdata ships: x86_64 musl, i386 +glibc and musl, armv7 glibc (legacy and time64 layouts) and musl, armv6 musl and +aarch64 glibc and musl, using QEMU for ARM. The Go suites run on the glibc +targets and on armv6 musl; the other musl targets skip them, because pure Go +does not link libc and the same architecture's glibc run covers them (for +x86_64, the host job). Run one target +locally with `bash tests/run-linux-target.sh `; the script header lists +targets and required toolchains. ## Core Reality @@ -74,7 +87,8 @@ Before adding anything, internalize these facts: Current implemented layers: - L1 transport - - POSIX: UDS baseline + POSIX SHM fast path + - POSIX: UDS baseline + POSIX SHM fast path (SHM on Linux and FreeBSD; + Go on FreeBSD amd64 and 386 only) - Windows: Named Pipe baseline + Win SHM fast path - Codec - typed encode/decode @@ -181,6 +195,17 @@ Non-negotiable rule: - raw payload encode/decode - batch and chunk rules - single-owner or externally locked access to each raw session object + - pipelining limits: POSIX client sessions may send every request before + reading any response (a send that waits for buffer space reads the + responses that arrive; see `level1-posix-uds.md`, "Pipelined sends"). + A caller that waits on the session fd before receiving must first check + `nipc_uds_session_has_pending` (Rust `UdsSession::has_pending`, Go + `Session.HasPending`): responses read during a send no longer show on + the fd. + Windows Named Pipe sessions do the same (`level1-windows-np.md`, + "Pipelining"); wait with `nipc_np_wait_readable` (Rust + `NpSession::wait_readable`, Go `Session.WaitReadable`), which reports + queued responses, not on the pipe handle Use L1 for: @@ -362,6 +387,12 @@ Netdata-specific integration rules: - stale socket/SHM paths from dead processes are reclaimed automatically on server start, regardless of run-dir permissions; only a live server's endpoint blocks a new server (address-in-use) +- on POSIX each server also holds an exclusive lock on + `{run_dir}/{service}.lock` for its whole life; the file stays after the + server exits and must not be deleted while a server may be starting, so + two servers that start at the same time cannot both claim the endpoint +- on POSIX the listener socket is owner-only (`0600`), so the provider and + its consumers must run as the same user (Netdata's plugins do) - if provider and consumer do not agree on auth token or run dir, the client will stay in `AUTH_FAILED` or `NOT_FOUND` @@ -383,6 +414,21 @@ as explicit assumptions, not as implementation details they can reinterpret. - Managed servers are concurrent. - The server accept loop can run multiple client sessions concurrently up to `worker_count`. + - When all workers are busy, a new connection waits up to 200 ms for a + worker to finish, then is closed before HELLO_ACK, so the client's + connect fails and it retries on its next refresh; size + `worker_count` for the number of long-lived clients you expect. + - A connection that never sends HELLO holds a worker for at most 5000 ms. + - Clients can connect once the server listens. C `nipc_server_init()` + creates the listener before it returns; Rust and Go create it inside + `run()` / `Run()`, so code that starts a server and then its clients + waits for Rust `listening_signal()` / Go `Listening()` (see + [level2-typed-api.md](level2-typed-api.md#readiness)) instead of + polling or retrying connects. + - On Windows, a connect that finds every pipe instance busy waits up to + 1000 ms for one, never past the call or refresh deadline, then fails. + - Stop/destroy give sessions 1000 ms to finish, then interrupt their + blocking I/O, so a client that stopped reading cannot hold shutdown. - Handler code must therefore assume concurrent execution. - Evidence: - [netipc_service.h](../src/libnetdata/netipc/include/netipc/netipc_service.h) @@ -623,9 +669,10 @@ Operational meaning: - connection was previously READY and failed - `refresh()` will reconnect - `AUTH_FAILED` - - handshake auth check failed + - handshake auth check failed, or on Windows the pipe is owned by an + account other than the client's own, SYSTEM or Administrators - this is not a transient network condition - - fix config or auth token + - fix config or auth token, or run server and client as the same account - `INCOMPATIBLE` - protocol/layout/negotiation mismatch - this is not a transient network condition @@ -660,6 +707,15 @@ L2 typed calls are also bounded: - a per-call timeout of zero uses the client context default - the default client context timeout is 30000 ms +- one deadline covers the whole call, including the reconnect, handshake and + SHM attach between attempts; a reconnect cut short by the deadline reports + the timeout error +- `refresh()` is bounded by the client context call timeout, so a server that + accepts but never answers cannot stall the caller's loop longer than that; + while an abort is pending `refresh()` does not connect +- a call on a client that is not READY fails at once with the not-ready error + (C `NIPC_ERR_NOT_READY`, Rust `NipcError::NotReady`, Go + `protocol.ErrNotReady`) - payload budgets and logical-call ceilings come from initialization config or documented defaults, not scattered hardcoded literals - named defaults are not protocol hard limits; explicit initialization config @@ -675,6 +731,26 @@ L2 typed calls are also bounded: - timeout and abort have distinct errors, so shutdown handling does not need to guess whether the peer disconnected - abort is sticky until the caller clears it or closes the client/cache helper +- a call blocked waiting for its response notices an abort within 100 ms on + every transport +- a peer that is gone (closed, crashed, or killed) is reported as a distinct + disconnected error (C `NIPC_ERR_DISCONNECTED`, Rust + `NipcError::Disconnected`, Go `protocol.ErrDisconnected`) after the one + reconnect retry also fails; earlier releases reported it as `TRUNCATED` / + "buffer too short" +- any other send/receive I/O failure is a distinct transport error (C + `NIPC_ERR_TRANSPORT`, Rust `NipcError::Transport`, Go + `protocol.ErrTransport`); earlier releases reported it as `TRUNCATED` or + `NOT_READY`. `TRUNCATED` now means a short or corrupt message only +- SHM calls notice a crashed server within about 100 ms instead of waiting for + the call timeout, and SHM servers free the worker of a crashed client within + about 100 ms; both rely on the session socket/pipe, so do not close or + duplicate it outside NetIPC +- NetIPC sockets, pipes and SHM descriptors are close-on-exec, so consumers + that fork and exec children do not need to close them in the child +- the transport-level `nipc_shm_owner_alive` / `ShmContext::owner_alive` / + `OwnerAlive` helper was removed: it could not detect a replaced region and + was wrong across PID namespaces Implication: @@ -692,8 +768,11 @@ Current required behavior: - if backing-store allocation fails, including `ENOSPC`, SHM preparation returns a normal allocation-stage error; it must not reach a mapped write or terminate the host process with `SIGBUS` -- managed servers remove SHM from that session's supported and preferred profiles and - continue over baseline when baseline is configured +- managed servers create SHM only for a handshake that selected an SHM profile, after + authorization and limit checks and before HELLO_ACK, sized from the final limits; + baseline sessions and rejected clients create no region +- on preparation failure, managed servers drop that profile within the same handshake + and select again, continuing over baseline when baseline is configured - an explicitly SHM-only configuration has no fallback transport and may reject or retry the session - Linux sandbox/seccomp policy must allow `fallocate` or arrange an ordinary errno @@ -1221,13 +1300,37 @@ Before you define a new service or integrate one into a plugin, answer these: ### Linux / POSIX -- baseline transport is UDS `SOCK_SEQPACKET` -- fast path is POSIX SHM +- baseline transport is UDS `SOCK_SEQPACKET` on Linux and UDS `SOCK_STREAM` + carrying the same packets on macOS and FreeBSD +- fast path is POSIX SHM, on Linux and FreeBSD (Go: FreeBSD amd64 and 386 + only) +- on macOS, and for Go on other FreeBSD architectures, every session uses the + UDS baseline: the handshake drops SHM profiles from any configuration, so the + same caller configuration works everywhere +- on FreeBSD, SHM needs a run directory on tmpfs or UFS: ZFS rejects the + region reservation (`posix_fallocate` returns `EINVAL`), so a server with + its run directory on ZFS serves baseline only +- macOS and FreeBSD use a `SOCK_STREAM` baseline that carries the same + packets, with the same API, handshake and profile bit (macOS has no + `SOCK_SEQPACKET`; FreeBSD 15 `SOCK_SEQPACKET` does not keep NetIPC's packet + boundaries). FreeBSD keeps SHM; macOS has none. The packet size defaults + to 256 KiB regardless of `SO_SNDBUF`; a smaller configured packet size + still applies. A receive that + fails after reading part of a message (timeout, abort, oversized or + malformed message) ends further receives on that session; sends still work, + and the L2 client reconnects as for any broken session. A full listen backlog + refuses a connect at once (macOS and FreeBSD), so size `backlog` for bursts + of clients. On macOS the listener binds through a private `{service}.d` + directory in the run directory; a leftover one from a crashed server is + removed on start. ### Windows - baseline transport is Named Pipe - fast path is Windows SHM +- pipes and SHM objects grant access only to SYSTEM, Administrators and the + server's own account, and refuse remote (SMB) clients: run server and + clients as the same account (Netdata: LocalSystem) ### Cross-platform rule @@ -1310,6 +1413,10 @@ it should: 7. Add the public typed facade, not just raw helpers. 8. Add L3 only if the dataset needs local owned cache + lookup. 9. Add tests for codec, L2, interop, and L3 if applicable. + - Correctness tests synchronize on events (listening signal, handler + flags, session exits), never on sleeps or retry loops; fuzz and + stress tests go behind `tests/run-extended-fuzz.sh` / + `tests/run-extended-stress.sh`. 10. Add operational logs at the plugin boundary. 11. Validate Linux and Windows behavior before claiming the integration is ready. - Run long local validation and benchmarks through diff --git a/rustfmt.toml b/rustfmt.toml index 8153a3d0..35011368 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1 +1 @@ -style_edition = "2021" +style_edition = "2024" diff --git a/src/crates/netipc/src/lib.rs b/src/crates/netipc/src/lib.rs index 08255266..516009bd 100644 --- a/src/crates/netipc/src/lib.rs +++ b/src/crates/netipc/src/lib.rs @@ -3,3 +3,6 @@ pub mod protocol; pub mod service; pub mod transport; + +#[cfg(all(test, unix))] +mod test_locks; diff --git a/src/crates/netipc/src/protocol/cgroups_snapshot.rs b/src/crates/netipc/src/protocol/cgroups_snapshot.rs index 55e56b4c..34c09b99 100644 --- a/src/crates/netipc/src/protocol/cgroups_snapshot.rs +++ b/src/crates/netipc/src/protocol/cgroups_snapshot.rs @@ -1,6 +1,6 @@ //! Cgroups snapshot codec -- request, response view, builder, dispatch. -use super::{align8, NipcError, ALIGNMENT}; +use super::{ALIGNMENT, NipcError, align8}; const CGROUPS_REQ_SIZE: usize = 4; const CGROUPS_RESP_HDR_SIZE: usize = 24; diff --git a/src/crates/netipc/src/protocol/lookup.rs b/src/crates/netipc/src/protocol/lookup.rs index 84abf972..05092b75 100644 --- a/src/crates/netipc/src/protocol/lookup.rs +++ b/src/crates/netipc/src/protocol/lookup.rs @@ -7,7 +7,7 @@ mod common; pub use apps_lookup::*; pub use cgroups_lookup::*; pub use common::{ - LookupLabelView, LOOKUP_DIR_ENTRY_SIZE, LOOKUP_LABEL_ENTRY_SIZE, ORCHESTRATOR_DOCKER, + LOOKUP_DIR_ENTRY_SIZE, LOOKUP_LABEL_ENTRY_SIZE, LookupLabelView, ORCHESTRATOR_DOCKER, ORCHESTRATOR_K8S, ORCHESTRATOR_KVM, ORCHESTRATOR_LXC, ORCHESTRATOR_NSPAWN, ORCHESTRATOR_PODMAN, ORCHESTRATOR_SYSTEMD, ORCHESTRATOR_UNKNOWN, }; diff --git a/src/crates/netipc/src/protocol/lookup/apps_lookup.rs b/src/crates/netipc/src/protocol/lookup/apps_lookup.rs index a6bb6ba4..1b6da839 100644 --- a/src/crates/netipc/src/protocol/lookup/apps_lookup.rs +++ b/src/crates/netipc/src/protocol/lookup/apps_lookup.rs @@ -1,7 +1,7 @@ //! APPS_LOOKUP codec. use super::common::*; -use crate::protocol::{align8, NipcError, StrView}; +use crate::protocol::{NipcError, StrView, align8}; pub const NIPC_UID_UNSET: u32 = u32::MAX; @@ -813,7 +813,7 @@ where #[cfg(test)] mod tests { - use super::super::common::{put_u16, put_u32, response_item_bounds, LOOKUP_DIR_ENTRY_SIZE}; + use super::super::common::{LOOKUP_DIR_ENTRY_SIZE, put_u16, put_u32, response_item_bounds}; use super::*; use crate::protocol::ORCHESTRATOR_DOCKER; @@ -958,8 +958,8 @@ mod tests { fn apps_lookup_comm_boundary() { let mut buf = [0u8; 256]; let mut b = AppsLookupBuilder::new(&mut buf, 1, 0); - assert!(b - .add( + assert!( + b.add( PID_LOOKUP_KNOWN, APPS_CGROUP_HOST_ROOT, 0, @@ -972,7 +972,8 @@ mod tests { b"", &[], ) - .is_ok()); + .is_ok() + ); let mut b = AppsLookupBuilder::new(&mut buf, 1, 0); assert_eq!( b.add( diff --git a/src/crates/netipc/src/protocol/lookup/cgroups_lookup.rs b/src/crates/netipc/src/protocol/lookup/cgroups_lookup.rs index d49749b0..7307a813 100644 --- a/src/crates/netipc/src/protocol/lookup/cgroups_lookup.rs +++ b/src/crates/netipc/src/protocol/lookup/cgroups_lookup.rs @@ -1,7 +1,7 @@ //! CGROUPS_LOOKUP codec. use super::common::*; -use crate::protocol::{align8, NipcError, StrView}; +use crate::protocol::{NipcError, StrView, align8}; pub const CGROUP_LOOKUP_KNOWN: u16 = 0; pub const CGROUP_LOOKUP_UNKNOWN_RETRY_LATER: u16 = 1; @@ -689,10 +689,10 @@ where #[cfg(test)] mod tests { use super::super::common::{ - put_u16, put_u32, response_item_bounds, u32_at, LOOKUP_DIR_ENTRY_SIZE, + LOOKUP_DIR_ENTRY_SIZE, put_u16, put_u32, response_item_bounds, u32_at, }; use super::*; - use crate::protocol::{align8, ORCHESTRATOR_K8S}; + use crate::protocol::{ORCHESTRATOR_K8S, align8}; #[test] fn cgroups_lookup_request_roundtrip() { diff --git a/src/crates/netipc/src/protocol/lookup/common.rs b/src/crates/netipc/src/protocol/lookup/common.rs index 8405ba15..de22c48e 100644 --- a/src/crates/netipc/src/protocol/lookup/common.rs +++ b/src/crates/netipc/src/protocol/lookup/common.rs @@ -1,6 +1,6 @@ //! Shared helpers for lookup codecs. -use crate::protocol::{align8, NipcError, StrView, ALIGNMENT}; +use crate::protocol::{ALIGNMENT, NipcError, StrView, align8}; pub const LOOKUP_DIR_ENTRY_SIZE: usize = 8; pub const LOOKUP_LABEL_ENTRY_SIZE: usize = 16; diff --git a/src/crates/netipc/src/protocol/mod.rs b/src/crates/netipc/src/protocol/mod.rs index f8943b7e..217ee954 100644 --- a/src/crates/netipc/src/protocol/mod.rs +++ b/src/crates/netipc/src/protocol/mod.rs @@ -48,6 +48,10 @@ pub const STATUS_INTERNAL_ERROR: u16 = 6; pub const CODE_HELLO: u16 = 1; pub const CODE_HELLO_ACK: u16 = 2; +/// How long an accepted connection may take to send HELLO before the server +/// drops it. Bounds what a stalled client can hold on the server. +pub const SERVER_HANDSHAKE_TIMEOUT_MS: u32 = 5000; + // Method codes pub const METHOD_INCREMENT: u16 = 1; pub const METHOD_CGROUPS_SNAPSHOT: u16 = 2; @@ -110,6 +114,13 @@ pub enum NipcError { Timeout, /// Synchronous call was aborted by the caller. Aborted, + /// The peer closed the session or its process exited. + Disconnected, + /// The client is not connected; refresh it first. + NotReady, + /// The transport failed to send or receive (an I/O error that is not a + /// peer disconnect). + Transport, } impl core::fmt::Display for NipcError { @@ -129,6 +140,9 @@ impl core::fmt::Display for NipcError { NipcError::HandlerFailed => write!(f, "dispatch handler failed"), NipcError::Timeout => write!(f, "synchronous call timed out"), NipcError::Aborted => write!(f, "synchronous call aborted"), + NipcError::Disconnected => write!(f, "peer disconnected"), + NipcError::NotReady => write!(f, "client not ready"), + NipcError::Transport => write!(f, "transport I/O failed"), } } } @@ -408,7 +422,7 @@ pub fn batch_item_get( // Batch builder // --------------------------------------------------------------------------- -/// Builds a batch payload: [directory] [align-pad] [packed items]. +/// Builds a batch payload: `directory` [align-pad] [packed items]. pub struct BatchBuilder<'a> { buf: &'a mut [u8], item_count: u32, diff --git a/src/crates/netipc/src/protocol/tests.rs b/src/crates/netipc/src/protocol/tests.rs index 8fd7447e..06d1c782 100644 --- a/src/crates/netipc/src/protocol/tests.rs +++ b/src/crates/netipc/src/protocol/tests.rs @@ -1111,31 +1111,37 @@ mod proptests { proptest! { #[test] + #[ignore = "fuzz: run by tests/run-extended-fuzz.sh"] fn decode_header_never_panics(data: Vec) { let _ = Header::decode(&data); } #[test] + #[ignore = "fuzz: run by tests/run-extended-fuzz.sh"] fn decode_chunk_header_never_panics(data: Vec) { let _ = ChunkHeader::decode(&data); } #[test] + #[ignore = "fuzz: run by tests/run-extended-fuzz.sh"] fn decode_hello_never_panics(data: Vec) { let _ = Hello::decode(&data); } #[test] + #[ignore = "fuzz: run by tests/run-extended-fuzz.sh"] fn decode_hello_ack_never_panics(data: Vec) { let _ = HelloAck::decode(&data); } #[test] + #[ignore = "fuzz: run by tests/run-extended-fuzz.sh"] fn decode_cgroups_request_never_panics(data: Vec) { let _ = CgroupsRequest::decode(&data); } #[test] + #[ignore = "fuzz: run by tests/run-extended-fuzz.sh"] fn decode_cgroups_response_never_panics(data: Vec) { let result = CgroupsResponseView::decode(&data); if let Ok(view) = result { @@ -1150,6 +1156,7 @@ mod proptests { } #[test] + #[ignore = "fuzz: run by tests/run-extended-fuzz.sh"] fn decode_cgroups_lookup_request_never_panics(data: Vec) { let result = CgroupsLookupRequestView::decode(&data); if let Ok(view) = result { @@ -1162,6 +1169,7 @@ mod proptests { } #[test] + #[ignore = "fuzz: run by tests/run-extended-fuzz.sh"] fn decode_cgroups_lookup_response_never_panics(data: Vec) { let result = CgroupsLookupResponseView::decode(&data); if let Ok(view) = result { @@ -1180,6 +1188,7 @@ mod proptests { } #[test] + #[ignore = "fuzz: run by tests/run-extended-fuzz.sh"] fn decode_apps_lookup_request_never_panics(data: Vec) { let result = AppsLookupRequestView::decode(&data); if let Ok(view) = result { @@ -1192,6 +1201,7 @@ mod proptests { } #[test] + #[ignore = "fuzz: run by tests/run-extended-fuzz.sh"] fn decode_apps_lookup_response_never_panics(data: Vec) { let result = AppsLookupResponseView::decode(&data); if let Ok(view) = result { @@ -1210,6 +1220,7 @@ mod proptests { } #[test] + #[ignore = "fuzz: run by tests/run-extended-fuzz.sh"] fn batch_dir_decode_never_panics( data: Vec, item_count in 0u32..128, @@ -1219,6 +1230,7 @@ mod proptests { } #[test] + #[ignore = "fuzz: run by tests/run-extended-fuzz.sh"] fn batch_item_get_never_panics( data: Vec, item_count in 0u32..128, @@ -1232,6 +1244,7 @@ mod proptests { proptest! { #[test] + #[ignore = "fuzz: run by tests/run-extended-fuzz.sh"] fn encode_decode_header_roundtrip( kind in 1u16..=3, flags in any::(), @@ -1261,6 +1274,7 @@ mod proptests { } #[test] + #[ignore = "fuzz: run by tests/run-extended-fuzz.sh"] fn encode_decode_hello_roundtrip( supported in any::(), preferred in any::(), @@ -1312,6 +1326,7 @@ fn nipc_error_display_all_variants() { (NipcError::BadItemCount, "item count inconsistent"), (NipcError::Overflow, "builder out of space"), (NipcError::HandlerFailed, "dispatch handler failed"), + (NipcError::Transport, "transport I/O failed"), ]; for (err, expected) in cases { let msg = format!("{}", err); @@ -1566,7 +1581,7 @@ fn cgroups_resp_decode_bad_dir_alignment() { let mut buf = [0u8; 128]; buf[0..2].copy_from_slice(&1u16.to_ne_bytes()); // layout_version buf[4..8].copy_from_slice(&1u32.to_ne_bytes()); // item_count = 1 - // Dir entry at offset 24: offset=3 (unaligned), length=32 + // Dir entry at offset 24: offset=3 (unaligned), length=32 buf[24..28].copy_from_slice(&3u32.to_ne_bytes()); buf[28..32].copy_from_slice(&32u32.to_ne_bytes()); assert_eq!( diff --git a/src/crates/netipc/src/service/apps_lookup.rs b/src/crates/netipc/src/service/apps_lookup.rs index 0f643d12..ab2a4a87 100644 --- a/src/crates/netipc/src/service/apps_lookup.rs +++ b/src/crates/netipc/src/service/apps_lookup.rs @@ -1,7 +1,7 @@ //! L2 apps-lookup service facade. use super::raw; -use crate::protocol::{AppsLookupResponseView, NipcError, METHOD_APPS_LOOKUP, PROFILE_BASELINE}; +use crate::protocol::{AppsLookupResponseView, METHOD_APPS_LOOKUP, NipcError, PROFILE_BASELINE}; #[cfg(unix)] use crate::transport::posix::{ @@ -13,8 +13,8 @@ use crate::transport::windows::{ ClientConfig as TransportClientConfig, ServerConfig as TransportServerConfig, }; -use std::sync::atomic::AtomicBool; use std::sync::Arc; +use std::sync::atomic::AtomicBool; pub use raw::{AppsLookupHandler, ClientAbortHandle, ClientState, ClientStatus}; @@ -208,6 +208,11 @@ impl ManagedServer { self.inner.stop(); } + /// Returns a signal that is set once `run()` accepts connections. + pub fn listening_signal(&self) -> raw::ListeningSignal { + self.inner.listening_signal() + } + pub fn running_flag(&self) -> Arc { self.inner.running_flag() } @@ -217,8 +222,8 @@ impl ManagedServer { mod tests { use super::*; use crate::protocol::PROFILE_SHM_FUTEX; - use std::sync::atomic::Ordering; use std::sync::Arc; + use std::sync::atomic::Ordering; #[test] fn client_config_maps_to_transport() { @@ -302,7 +307,7 @@ mod tests { assert!(client.refresh()); assert_eq!(client.status().state, ClientState::NotFound); - assert_eq!(client.call(&[123]).unwrap_err(), NipcError::BadLayout); + assert_eq!(client.call(&[123]).unwrap_err(), NipcError::NotReady); assert_eq!(client.status().error_count, 1); client.close(); diff --git a/src/crates/netipc/src/service/apps_lookup_unix_tests.rs b/src/crates/netipc/src/service/apps_lookup_unix_tests.rs index 9ed37012..3e584b4b 100644 --- a/src/crates/netipc/src/service/apps_lookup_unix_tests.rs +++ b/src/crates/netipc/src/service/apps_lookup_unix_tests.rs @@ -1,13 +1,12 @@ use super::*; use crate::protocol::{ - AppsLookupBuilder, AppsLookupRequestView, APPS_CGROUP_HOST_ROOT, APPS_CGROUP_KNOWN, + APPS_CGROUP_HOST_ROOT, APPS_CGROUP_KNOWN, AppsLookupBuilder, AppsLookupRequestView, NIPC_UID_UNSET, ORCHESTRATOR_DOCKER, PID_LOOKUP_KNOWN, PID_LOOKUP_UNKNOWN, PROFILE_BASELINE, }; use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use std::thread; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; const TEST_RUN_DIR: &str = "/tmp/nipc_apps_lookup_rust_test"; const AUTH_TOKEN: u64 = 0xDEADBEEFCAFEBABE; @@ -23,13 +22,15 @@ fn unique_service(prefix: &str) -> String { .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); - format!( + let service = format!( "{}_{}_{}_{}", prefix, std::process::id(), SERVICE_COUNTER.fetch_add(1, Ordering::Relaxed) + 1, stamp - ) + ); + crate::test_locks::track(TEST_RUN_DIR, &service); + service } fn socket_path(service: &str) -> PathBuf { @@ -40,17 +41,6 @@ fn cleanup(service: &str) { let _ = std::fs::remove_file(socket_path(service)); } -fn wait_for_listener_bind(service: &str) { - let sock = socket_path(service); - for _ in 0..2000 { - if sock.exists() { - return; - } - thread::sleep(Duration::from_micros(500)); - } - panic!("listener did not bind for service {service}"); -} - fn server_config() -> ServerConfig { ServerConfig { supported_profiles: PROFILE_BASELINE, @@ -137,47 +127,24 @@ fn handler() -> Handler { } struct TestServer { - stop_flag: Arc, - thread: Option>, + _inner: raw::RunningTestServer, } impl TestServer { fn start(service: &str) -> Self { ensure_run_dir(); cleanup(service); - - let mut server = ManagedServer::new(TEST_RUN_DIR, service, server_config(), handler()); - let stop_flag = server.running_flag(); - let thread = thread::spawn(move || { - let _ = server.run(); - }); - wait_for_listener_bind(service); + let server = ManagedServer::new(TEST_RUN_DIR, service, server_config(), handler()); Self { - stop_flag, - thread: Some(thread), - } - } -} - -impl Drop for TestServer { - fn drop(&mut self) { - self.stop_flag - .store(false, std::sync::atomic::Ordering::Release); - if let Some(thread) = self.thread.take() { - let _ = thread.join(); + _inner: server.inner.spawn_for_test(), } } } +/// The server listens before tests connect, so one refresh must succeed. fn connect_ready(client: &mut AppsLookupClient) { - for _ in 0..200 { - client.refresh(); - if client.ready() { - return; - } - thread::sleep(Duration::from_millis(10)); - } - panic!("client did not reach READY state"); + client.refresh(); + assert!(client.ready(), "client did not reach READY state"); } #[test] @@ -224,7 +191,7 @@ fn test_apps_lookup_facade_not_ready_and_abort_unix() { abort.abort(); assert!(matches!( client.call_with_timeout(&[1], 1), - Err(NipcError::BadLayout) + Err(NipcError::NotReady) )); client.clear_abort(); client.close(); diff --git a/src/crates/netipc/src/service/cgroups_lookup.rs b/src/crates/netipc/src/service/cgroups_lookup.rs index 676e63bc..a2b4a67d 100644 --- a/src/crates/netipc/src/service/cgroups_lookup.rs +++ b/src/crates/netipc/src/service/cgroups_lookup.rs @@ -2,7 +2,7 @@ use super::raw; use crate::protocol::{ - CgroupsLookupResponseView, NipcError, METHOD_CGROUPS_LOOKUP, PROFILE_BASELINE, + CgroupsLookupResponseView, METHOD_CGROUPS_LOOKUP, NipcError, PROFILE_BASELINE, }; #[cfg(unix)] @@ -15,8 +15,8 @@ use crate::transport::windows::{ ClientConfig as TransportClientConfig, ServerConfig as TransportServerConfig, }; -use std::sync::atomic::AtomicBool; use std::sync::Arc; +use std::sync::atomic::AtomicBool; pub use raw::{CgroupsLookupHandler, ClientAbortHandle, ClientState, ClientStatus}; @@ -211,6 +211,11 @@ impl ManagedServer { self.inner.stop(); } + /// Returns a signal that is set once `run()` accepts connections. + pub fn listening_signal(&self) -> raw::ListeningSignal { + self.inner.listening_signal() + } + pub fn running_flag(&self) -> Arc { self.inner.running_flag() } @@ -220,8 +225,8 @@ impl ManagedServer { mod tests { use super::*; use crate::protocol::PROFILE_SHM_FUTEX; - use std::sync::atomic::Ordering; use std::sync::Arc; + use std::sync::atomic::Ordering; #[test] fn client_config_maps_to_transport() { @@ -306,7 +311,7 @@ mod tests { assert_eq!(client.status().state, ClientState::NotFound); let paths: [&[u8]; 1] = [b"/x".as_slice()]; - assert_eq!(client.call(&paths).unwrap_err(), NipcError::BadLayout); + assert_eq!(client.call(&paths).unwrap_err(), NipcError::NotReady); assert_eq!(client.status().error_count, 1); client.close(); diff --git a/src/crates/netipc/src/service/cgroups_lookup_unix_tests.rs b/src/crates/netipc/src/service/cgroups_lookup_unix_tests.rs index 1bfc1a27..fd2e37ca 100644 --- a/src/crates/netipc/src/service/cgroups_lookup_unix_tests.rs +++ b/src/crates/netipc/src/service/cgroups_lookup_unix_tests.rs @@ -1,13 +1,12 @@ use super::*; use crate::protocol::{ - CgroupsLookupBuilder, CgroupsLookupRequestView, CGROUP_LOOKUP_KNOWN, - CGROUP_LOOKUP_UNKNOWN_RETRY_LATER, ORCHESTRATOR_K8S, PROFILE_BASELINE, + CGROUP_LOOKUP_KNOWN, CGROUP_LOOKUP_UNKNOWN_RETRY_LATER, CgroupsLookupBuilder, + CgroupsLookupRequestView, ORCHESTRATOR_K8S, PROFILE_BASELINE, }; use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use std::thread; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; const TEST_RUN_DIR: &str = "/tmp/nipc_cgroups_lookup_rust_test"; const AUTH_TOKEN: u64 = 0xDEADBEEFCAFEBABE; @@ -23,13 +22,15 @@ fn unique_service(prefix: &str) -> String { .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); - format!( + let service = format!( "{}_{}_{}_{}", prefix, std::process::id(), SERVICE_COUNTER.fetch_add(1, Ordering::Relaxed) + 1, stamp - ) + ); + crate::test_locks::track(TEST_RUN_DIR, &service); + service } fn socket_path(service: &str) -> PathBuf { @@ -40,17 +41,6 @@ fn cleanup(service: &str) { let _ = std::fs::remove_file(socket_path(service)); } -fn wait_for_listener_bind(service: &str) { - let sock = socket_path(service); - for _ in 0..2000 { - if sock.exists() { - return; - } - thread::sleep(Duration::from_micros(500)); - } - panic!("listener did not bind for service {service}"); -} - fn server_config() -> ServerConfig { ServerConfig { supported_profiles: PROFILE_BASELINE, @@ -113,47 +103,24 @@ fn handler() -> Handler { } struct TestServer { - stop_flag: Arc, - thread: Option>, + _inner: raw::RunningTestServer, } impl TestServer { fn start(service: &str) -> Self { ensure_run_dir(); cleanup(service); - - let mut server = ManagedServer::new(TEST_RUN_DIR, service, server_config(), handler()); - let stop_flag = server.running_flag(); - let thread = thread::spawn(move || { - let _ = server.run(); - }); - wait_for_listener_bind(service); + let server = ManagedServer::new(TEST_RUN_DIR, service, server_config(), handler()); Self { - stop_flag, - thread: Some(thread), - } - } -} - -impl Drop for TestServer { - fn drop(&mut self) { - self.stop_flag - .store(false, std::sync::atomic::Ordering::Release); - if let Some(thread) = self.thread.take() { - let _ = thread.join(); + _inner: server.inner.spawn_for_test(), } } } +/// The server listens before tests connect, so one refresh must succeed. fn connect_ready(client: &mut CgroupsLookupClient) { - for _ in 0..200 { - client.refresh(); - if client.ready() { - return; - } - thread::sleep(Duration::from_millis(10)); - } - panic!("client did not reach READY state"); + client.refresh(); + assert!(client.ready(), "client did not reach READY state"); } #[test] @@ -198,7 +165,7 @@ fn test_cgroups_lookup_facade_not_ready_and_abort_unix() { let paths: [&[u8]; 1] = [b"/x".as_slice()]; assert!(matches!( client.call_with_timeout(&paths, 1), - Err(NipcError::BadLayout) + Err(NipcError::NotReady) )); client.clear_abort(); client.close(); diff --git a/src/crates/netipc/src/service/cgroups_snapshot.rs b/src/crates/netipc/src/service/cgroups_snapshot.rs index 9c3cbefa..1cb492e3 100644 --- a/src/crates/netipc/src/service/cgroups_snapshot.rs +++ b/src/crates/netipc/src/service/cgroups_snapshot.rs @@ -5,7 +5,7 @@ //! validation, not for public multi-method dispatch. use super::raw; -use crate::protocol::{CgroupsResponseView, NipcError, METHOD_CGROUPS_SNAPSHOT, PROFILE_BASELINE}; +use crate::protocol::{CgroupsResponseView, METHOD_CGROUPS_SNAPSHOT, NipcError, PROFILE_BASELINE}; #[cfg(unix)] use crate::transport::posix::{ @@ -17,8 +17,8 @@ use crate::transport::windows::{ ClientConfig as TransportClientConfig, ServerConfig as TransportServerConfig, }; -use std::sync::atomic::AtomicBool; use std::sync::Arc; +use std::sync::atomic::AtomicBool; pub use raw::{ CgroupsCacheItem, CgroupsCacheItemView, CgroupsCacheReadGuard, CgroupsCacheStatus, @@ -242,6 +242,11 @@ impl ManagedServer { /// /// For reliable shutdown, call `stop()`. On Windows, changing this flag /// alone does not wake a blocking listener accept. + /// Returns a signal that is set once `run()` accepts connections. + pub fn listening_signal(&self) -> raw::ListeningSignal { + self.inner.listening_signal() + } + pub fn running_flag(&self) -> Arc { self.inner.running_flag() } diff --git a/src/crates/netipc/src/service/cgroups_unix_tests.rs b/src/crates/netipc/src/service/cgroups_unix_tests.rs index a3fc318f..e5cf4a90 100644 --- a/src/crates/netipc/src/service/cgroups_unix_tests.rs +++ b/src/crates/netipc/src/service/cgroups_unix_tests.rs @@ -1,12 +1,10 @@ use super::*; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] use crate::protocol::PROFILE_SHM_FUTEX; use crate::protocol::{CgroupsBuilder, NipcError, PROFILE_BASELINE}; -use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use std::thread; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; const TEST_RUN_DIR: &str = "/tmp/nipc_cgroups_rust_test"; const AUTH_TOKEN: u64 = 0xDEADBEEFCAFEBABE; @@ -22,35 +20,21 @@ fn unique_service(prefix: &str) -> String { .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); - format!( + let service = format!( "{}_{}_{}_{}", prefix, std::process::id(), SERVICE_COUNTER.fetch_add(1, Ordering::Relaxed) + 1, stamp - ) + ); + crate::test_locks::track(TEST_RUN_DIR, &service); + service } fn cleanup_all(service: &str) { let _ = std::fs::remove_file(format!("{TEST_RUN_DIR}/{service}.sock")); } -fn socket_path(service: &str) -> PathBuf { - PathBuf::from(format!("{TEST_RUN_DIR}/{service}.sock")) -} - -fn wait_for_listener_bind(service: &str) { - let sock = socket_path(service); - for _ in 0..2000 { - if sock.exists() { - return; - } - thread::sleep(Duration::from_micros(500)); - } - - panic!("listener did not bind for service {service}"); -} - fn server_config() -> ServerConfig { ServerConfig { supported_profiles: PROFILE_BASELINE, @@ -73,7 +57,7 @@ fn client_config() -> ClientConfig { } } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] fn shm_server_config() -> ServerConfig { ServerConfig { supported_profiles: PROFILE_BASELINE | PROFILE_SHM_FUTEX, @@ -85,7 +69,7 @@ fn shm_server_config() -> ServerConfig { } } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] fn shm_client_config() -> ClientConfig { ClientConfig { supported_profiles: PROFILE_BASELINE | PROFILE_SHM_FUTEX, @@ -138,58 +122,23 @@ fn snapshot_handler() -> Handler { } } +/// The server listens before tests connect, so one refresh must succeed. fn connect_ready(client: &mut CgroupsClient) { - for _ in 0..200 { - client.refresh(); - if client.ready() { - return; - } - thread::sleep(Duration::from_millis(10)); - } - - panic!("client did not reach READY state"); + client.refresh(); + assert!(client.ready(), "client did not reach READY state"); } struct TestServer { - stop_flag: Arc, - thread: Option>, + _inner: raw::RunningTestServer, } impl TestServer { fn start(service: &str, config: ServerConfig) -> Self { ensure_run_dir(); cleanup_all(service); - - let ready_flag = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let ready_clone = ready_flag.clone(); - let mut server = ManagedServer::new(TEST_RUN_DIR, service, config, snapshot_handler()); - let stop_flag = server.running_flag(); - let thread = thread::spawn(move || { - ready_clone.store(true, std::sync::atomic::Ordering::Release); - let _ = server.run(); - }); - - for _ in 0..2000 { - if ready_flag.load(std::sync::atomic::Ordering::Acquire) { - break; - } - thread::sleep(Duration::from_micros(500)); - } - wait_for_listener_bind(service); - + let server = ManagedServer::new(TEST_RUN_DIR, service, config, snapshot_handler()); Self { - stop_flag, - thread: Some(thread), - } - } -} - -impl Drop for TestServer { - fn drop(&mut self) { - self.stop_flag - .store(false, std::sync::atomic::Ordering::Release); - if let Some(thread) = self.thread.take() { - let _ = thread.join(); + _inner: server.inner.spawn_for_test(), } } } @@ -213,7 +162,7 @@ fn test_snapshot_round_trip_unix() { assert_eq!(item0.path.as_bytes(), b"/sys/fs/cgroup/docker/abc123"); } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[test] fn test_snapshot_round_trip_shm_unix() { let service = unique_service("snapshot_shm"); @@ -233,15 +182,7 @@ fn test_cache_round_trip_unix() { let _server = TestServer::start(&service, server_config()); let cache = CgroupsCache::new(TEST_RUN_DIR, &service, client_config()); - let mut updated = false; - for _ in 0..200 { - if cache.refresh() { - updated = true; - break; - } - thread::sleep(Duration::from_millis(10)); - } - assert!(updated); + assert!(cache.refresh()); assert!(cache.ready()); let item = { @@ -263,7 +204,7 @@ fn test_client_not_ready_returns_error_unix() { let mut client = CgroupsClient::new(TEST_RUN_DIR, &service, client_config()); match client.call_snapshot() { - Err(NipcError::BadLayout) => {} + Err(NipcError::NotReady) => {} other => panic!("unexpected result: {other:?}"), } } diff --git a/src/crates/netipc/src/service/cgroups_windows_tests.rs b/src/crates/netipc/src/service/cgroups_windows_tests.rs index 45f240bd..4624193a 100644 --- a/src/crates/netipc/src/service/cgroups_windows_tests.rs +++ b/src/crates/netipc/src/service/cgroups_windows_tests.rs @@ -1,9 +1,7 @@ use super::*; use crate::protocol::{CgroupsBuilder, NipcError, PROFILE_BASELINE, PROFILE_SHM_HYBRID}; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use std::thread; -use std::time::Duration; +use std::sync::atomic::{AtomicU64, Ordering}; const TEST_RUN_DIR: &str = r"C:\Temp\nipc_cgroups_rust_test"; const AUTH_TOKEN: u64 = 0xDEADBEEFCAFEBABE; @@ -104,63 +102,22 @@ fn snapshot_handler() -> Handler { } } +/// The server listens before tests connect, so one refresh must succeed. fn connect_ready(client: &mut CgroupsClient) { - for _ in 0..200 { - client.refresh(); - if client.ready() { - return; - } - thread::sleep(Duration::from_millis(10)); - } - - panic!("client did not reach READY state"); + client.refresh(); + assert!(client.ready(), "client did not reach READY state"); } struct TestServer { - service: String, - wake_config: ClientConfig, - stop_flag: Arc, - thread: Option>, + _inner: raw::RunningTestServer, } impl TestServer { fn start(service: &str, config: ServerConfig) -> Self { ensure_run_dir(); - - let svc = service.to_string(); - let wake_config = ClientConfig { - supported_profiles: config.supported_profiles, - preferred_profiles: config.preferred_profiles, - max_request_batch_items: config.max_request_batch_items, - max_response_payload_bytes: config.max_response_payload_bytes, - auth_token: config.auth_token, - ..ClientConfig::default() - }; - let mut server = ManagedServer::new(TEST_RUN_DIR, service, config, snapshot_handler()); - let stop_flag = server.running_flag(); - let thread = thread::spawn(move || { - let _ = server.run(); - }); - - thread::sleep(Duration::from_millis(200)); - + let server = ManagedServer::new(TEST_RUN_DIR, service, config, snapshot_handler()); Self { - service: svc, - wake_config, - stop_flag, - thread: Some(thread), - } - } -} - -impl Drop for TestServer { - fn drop(&mut self) { - self.stop_flag - .store(false, std::sync::atomic::Ordering::Release); - let mut wake = CgroupsClient::new(TEST_RUN_DIR, &self.service, self.wake_config.clone()); - let _ = wake.refresh(); - if let Some(thread) = self.thread.take() { - let _ = thread.join(); + _inner: server.inner.spawn_for_test(), } } } @@ -198,15 +155,7 @@ fn test_cache_round_trip_windows() { let _server = TestServer::start(&service, server_config()); let cache = CgroupsCache::new(TEST_RUN_DIR, &service, client_config()); - let mut updated = false; - for _ in 0..200 { - if cache.refresh() { - updated = true; - break; - } - thread::sleep(Duration::from_millis(10)); - } - assert!(updated); + assert!(cache.refresh()); assert!(cache.ready()); let item = { @@ -242,7 +191,7 @@ fn test_client_not_ready_returns_error_windows() { let service = unique_service("not_ready"); let mut client = CgroupsClient::new(TEST_RUN_DIR, &service, client_config()); match client.call_snapshot() { - Err(NipcError::BadLayout) => {} + Err(NipcError::NotReady) => {} other => panic!("unexpected result: {other:?}"), } } @@ -265,7 +214,7 @@ fn test_client_facade_controls_windows() { let abort = client.abort_handle(); abort.abort(); match client.call_snapshot_with_timeout(1) { - Err(NipcError::BadLayout) => {} + Err(NipcError::NotReady) => {} other => panic!("unexpected result: {other:?}"), } diff --git a/src/crates/netipc/src/service/mod.rs b/src/crates/netipc/src/service/mod.rs index 8976bfa4..b9d871ec 100644 --- a/src/crates/netipc/src/service/mod.rs +++ b/src/crates/netipc/src/service/mod.rs @@ -10,3 +10,5 @@ pub mod cgroups_lookup; pub mod cgroups_snapshot; #[doc(hidden)] pub mod raw; + +pub use raw::ListeningSignal; diff --git a/src/crates/netipc/src/service/raw.rs b/src/crates/netipc/src/service/raw.rs index a01038a4..477992b3 100644 --- a/src/crates/netipc/src/service/raw.rs +++ b/src/crates/netipc/src/service/raw.rs @@ -28,36 +28,40 @@ mod server_unix; mod server_windows; mod string_reverse; -pub use apps_lookup::{apps_lookup_dispatch, AppsLookupHandler}; +pub use apps_lookup::{AppsLookupHandler, apps_lookup_dispatch}; pub use cgroups_cache::{ CgroupsCache, CgroupsCacheItem, CgroupsCacheItemView, CgroupsCacheReadGuard, CgroupsCacheStatus, }; -pub use cgroups_lookup::{cgroups_lookup_dispatch, CgroupsLookupHandler}; -pub use cgroups_snapshot::{snapshot_dispatch, snapshot_max_items, SnapshotHandler}; +pub use cgroups_lookup::{CgroupsLookupHandler, cgroups_lookup_dispatch}; +pub use cgroups_snapshot::{SnapshotHandler, snapshot_dispatch, snapshot_max_items}; pub use client::{ClientAbortHandle, ClientState, ClientStatus, RawClient}; pub use common::LookupLogicalConfig; pub use dispatch::{DispatchError, DispatchHandler}; -pub use increment::{increment_dispatch, IncrementHandler}; -pub use server::ManagedServer; -pub use string_reverse::{string_reverse_dispatch, StringReverseHandler}; +pub use increment::{IncrementHandler, increment_dispatch}; +#[cfg(test)] +pub(crate) use server::RunningTestServer; +pub use server::{ListeningSignal, ManagedServer}; +pub use string_reverse::{StringReverseHandler, string_reverse_dispatch}; +#[cfg(all(test, any(target_os = "linux", target_os = "freebsd")))] +use crate::protocol::STATUS_BAD_ENVELOPE; #[cfg(all(test, unix))] use crate::protocol::{ - self, batch_item_get, increment_decode, string_reverse_decode, string_reverse_encode, - CgroupsRequest, Header, NipcError, FLAG_BATCH, HEADER_SIZE, INCREMENT_PAYLOAD_SIZE, - KIND_REQUEST, MAGIC_MSG, METHOD_CGROUPS_SNAPSHOT, METHOD_INCREMENT, METHOD_STRING_REVERSE, - STATUS_BAD_ENVELOPE, STATUS_INTERNAL_ERROR, VERSION, + self, CgroupsRequest, FLAG_BATCH, HEADER_SIZE, Header, INCREMENT_PAYLOAD_SIZE, KIND_REQUEST, + MAGIC_MSG, METHOD_CGROUPS_SNAPSHOT, METHOD_INCREMENT, METHOD_STRING_REVERSE, NipcError, + STATUS_INTERNAL_ERROR, VERSION, batch_item_get, increment_decode, string_reverse_decode, + string_reverse_encode, }; #[cfg(all(test, unix))] use crate::transport::posix::{ClientConfig, ServerConfig, UdsListener, UdsSession}; -#[cfg(all(test, target_os = "linux"))] +#[cfg(all(test, any(target_os = "linux", target_os = "freebsd")))] use crate::transport::shm::ShmContext; #[cfg(all(test, windows))] use crate::transport::windows::{ClientConfig, NpListener, NpSession, ServerConfig}; #[cfg(all(test, unix))] -use std::sync::atomic::{AtomicBool, Ordering}; -#[cfg(all(test, unix))] use std::sync::Arc; +#[cfg(all(test, unix))] +use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(all(test, unix))] use client::{ClientResponseRef, ClientResponseSource}; diff --git a/src/crates/netipc/src/service/raw/apps_lookup.rs b/src/crates/netipc/src/service/raw/apps_lookup.rs index e4560418..7f04ec68 100644 --- a/src/crates/netipc/src/service/raw/apps_lookup.rs +++ b/src/crates/netipc/src/service/raw/apps_lookup.rs @@ -1,10 +1,10 @@ use super::client::{ClientConfig, RawCallKind, RawClient}; use super::common::lookup_raw_response_size; -use super::dispatch::{dispatch_error_from_protocol, DispatchHandler}; +use super::dispatch::{DispatchHandler, dispatch_error_from_protocol}; use crate::protocol::{ - self, AppsLookupBuilder, AppsLookupRequestView, AppsLookupResponseView, NipcError, - APPS_LOOKUP_KEY_SIZE, APPS_LOOKUP_REQ_HDR_SIZE, APPS_LOOKUP_RESP_HDR_SIZE, - LOOKUP_DIR_ENTRY_SIZE, METHOD_APPS_LOOKUP, PID_LOOKUP_PAYLOAD_EXCEEDED, + self, APPS_LOOKUP_KEY_SIZE, APPS_LOOKUP_REQ_HDR_SIZE, APPS_LOOKUP_RESP_HDR_SIZE, + AppsLookupBuilder, AppsLookupRequestView, AppsLookupResponseView, LOOKUP_DIR_ENTRY_SIZE, + METHOD_APPS_LOOKUP, NipcError, PID_LOOKUP_PAYLOAD_EXCEEDED, }; use std::sync::Arc; @@ -95,7 +95,7 @@ impl RawClient { Ok(next) => next, Err(NipcError::Overflow) if start < pids.len() => { let one_item_size = apps_lookup_request_size(1)?; - if self.ensure_lookup_request_capacity(one_item_size)? { + if self.ensure_lookup_request_capacity(one_item_size, deadline)? { continue; } return Err(NipcError::Overflow); diff --git a/src/crates/netipc/src/service/raw/cgroups_lookup.rs b/src/crates/netipc/src/service/raw/cgroups_lookup.rs index 927813d0..e96638d1 100644 --- a/src/crates/netipc/src/service/raw/cgroups_lookup.rs +++ b/src/crates/netipc/src/service/raw/cgroups_lookup.rs @@ -1,11 +1,11 @@ use super::client::{ClientConfig, RawCallKind, RawClient}; use super::common::lookup_raw_response_size; -use super::dispatch::{dispatch_error_from_protocol, DispatchHandler}; +use super::dispatch::{DispatchHandler, dispatch_error_from_protocol}; use crate::protocol::{ - self, CgroupsLookupBuilder, CgroupsLookupRequestView, CgroupsLookupResponseView, NipcError, + self, CGROUP_LOOKUP_OVERSIZED_ITEM, CGROUP_LOOKUP_PAYLOAD_EXCEEDED, CGROUPS_LOOKUP_ITEM_HDR_SIZE, CGROUPS_LOOKUP_REQ_HDR_SIZE, CGROUPS_LOOKUP_RESP_HDR_SIZE, - CGROUP_LOOKUP_OVERSIZED_ITEM, CGROUP_LOOKUP_PAYLOAD_EXCEEDED, LOOKUP_DIR_ENTRY_SIZE, - METHOD_CGROUPS_LOOKUP, + CgroupsLookupBuilder, CgroupsLookupRequestView, CgroupsLookupResponseView, + LOOKUP_DIR_ENTRY_SIZE, METHOD_CGROUPS_LOOKUP, NipcError, }; use std::sync::Arc; @@ -133,7 +133,7 @@ impl RawClient { Ok(next) => next, Err(err) if err == NipcError::Overflow && start < paths.len() => { let one_item_size = cgroups_lookup_request_size(&paths[start..start + 1])?; - if self.ensure_lookup_request_capacity(one_item_size)? { + if self.ensure_lookup_request_capacity(one_item_size, deadline)? { continue; } raw_items.push(cgroups_lookup_oversized_request_item(paths[start])?); diff --git a/src/crates/netipc/src/service/raw/cgroups_snapshot.rs b/src/crates/netipc/src/service/raw/cgroups_snapshot.rs index 458cba35..e76039b3 100644 --- a/src/crates/netipc/src/service/raw/cgroups_snapshot.rs +++ b/src/crates/netipc/src/service/raw/cgroups_snapshot.rs @@ -1,7 +1,7 @@ use super::client::{ClientConfig, RawCallKind, RawClient}; use super::dispatch::{DispatchError, DispatchHandler}; use crate::protocol::{ - self, CgroupsRequest, CgroupsResponseView, NipcError, METHOD_CGROUPS_SNAPSHOT, + self, CgroupsRequest, CgroupsResponseView, METHOD_CGROUPS_SNAPSHOT, NipcError, }; use std::sync::Arc; diff --git a/src/crates/netipc/src/service/raw/client.rs b/src/crates/netipc/src/service/raw/client.rs index fc38c89f..945b09a4 100644 --- a/src/crates/netipc/src/service/raw/client.rs +++ b/src/crates/netipc/src/service/raw/client.rs @@ -1,7 +1,9 @@ -use super::common::{ensure_client_scratch, next_power_of_2_u32, LookupLogicalConfig}; -use crate::protocol::{self, NipcError, MAX_PAYLOAD_CAP}; -use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(any(target_os = "linux", target_os = "freebsd", windows))] +use super::common::CLIENT_SHM_ATTACH_RETRY_TIMEOUT_MS; +use super::common::{LookupLogicalConfig, ensure_client_scratch, next_power_of_2_u32}; +use crate::protocol::{self, MAX_PAYLOAD_CAP, NipcError}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; #[cfg(unix)] @@ -10,7 +12,7 @@ pub(super) use crate::transport::posix::ClientConfig; #[cfg(unix)] use crate::transport::posix::UdsSession; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] use crate::transport::shm::ShmContext; #[cfg(windows)] @@ -25,6 +27,7 @@ use crate::transport::win_shm::WinShmContext; /// Default timeout for synchronous client calls. pub const CLIENT_CALL_TIMEOUT_DEFAULT_MS: u32 = 30_000; +#[cfg(any(target_os = "linux", target_os = "freebsd", windows))] pub(super) const CLIENT_ABORT_POLL_MS: u32 = 100; /// Client connection state machine. @@ -63,7 +66,7 @@ pub struct RawClient { #[cfg(unix)] pub(super) session: Option, - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] pub(super) shm: Option, #[cfg(windows)] @@ -72,6 +75,7 @@ pub struct RawClient { pub(super) shm: Option, pub(super) request_buf: Vec, + #[cfg(any(target_os = "linux", target_os = "freebsd", windows))] pub(super) send_buf: Vec, pub(super) transport_buf: Vec, @@ -85,6 +89,11 @@ pub struct RawClient { pub(super) max_logical_lookup_subcalls: u32, pub(super) max_logical_lookup_response_bytes: u32, pub(super) abort_requested: Arc, + /// How long to retry attaching a negotiated SHM region; tests shorten it. + #[cfg(any(target_os = "linux", target_os = "freebsd", windows))] + pub(super) shm_attach_timeout: std::time::Duration, + /// Deadline of the call or refresh in progress. + pub(super) op_deadline: Instant, } /// Cloneable handle that can abort a blocking client call from another thread. @@ -133,6 +142,18 @@ impl RawCallKind { } } +#[cfg(test)] +impl RawClient { + /// Shorten the client's internal waits for tests. + pub(crate) fn use_test_timings(&mut self) -> &mut Self { + #[cfg(any(target_os = "linux", target_os = "freebsd", windows))] + { + self.shm_attach_timeout = std::time::Duration::from_millis(300); + } + self + } +} + impl RawClient { pub(super) fn new_bound( run_dir: &str, @@ -148,11 +169,12 @@ impl RawClient { expected_method_code, transport_config: config, session: None, - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] shm: None, #[cfg(windows)] shm: None, request_buf: Vec::new(), + #[cfg(any(target_os = "linux", target_os = "freebsd", windows))] send_buf: Vec::new(), transport_buf: Vec::new(), connect_count: 0, @@ -164,6 +186,44 @@ impl RawClient { max_logical_lookup_subcalls: lookup_config.max_subcalls, max_logical_lookup_response_bytes: lookup_config.max_response_bytes, abort_requested: Arc::new(AtomicBool::new(false)), + #[cfg(any(target_os = "linux", target_os = "freebsd", windows))] + shm_attach_timeout: std::time::Duration::from_millis( + CLIENT_SHM_ATTACH_RETRY_TIMEOUT_MS, + ), + op_deadline: Instant::now(), + } + } + + /// Start a call or refresh: one deadline, `timeout_ms` resolved as for + /// calls, bounds every connect, handshake, attach and retry inside it. + pub(super) fn begin_op(&mut self, timeout_ms: u32) { + self.op_deadline = + Instant::now() + Duration::from_millis(self.resolved_call_timeout(timeout_ms) as u64); + } + + /// Time left before the current deadline; 0 once it passed. Rounded up, + /// so a wait given this time never ends before the deadline itself. + pub(super) fn op_remaining_ms(&self) -> u32 { + let now = Instant::now(); + if now >= self.op_deadline { + return 0; + } + let left = self + .op_deadline + .duration_since(now) + .as_micros() + .div_ceil(1000); + left.min(u32::MAX as u128) as u32 + } + + /// A reconnect inside a call failed: report why the call ended. + pub(super) fn call_reconnect_failure(&self, err: NipcError) -> NipcError { + if self.abort_requested() { + NipcError::Aborted + } else if self.op_remaining_ms() == 0 { + NipcError::Timeout + } else { + err } } @@ -211,7 +271,7 @@ impl RawClient { pub(super) fn ensure_ready_for_logical_lookup(&mut self) -> Result<(), NipcError> { if self.state != ClientState::Ready { self.error_count += 1; - return Err(NipcError::BadLayout); + return Err(NipcError::NotReady); } if self.abort_requested() { self.disconnect(); @@ -225,6 +285,7 @@ impl RawClient { pub(super) fn ensure_lookup_request_capacity( &mut self, required: usize, + deadline: Instant, ) -> Result { if self.abort_requested() { self.disconnect(); @@ -246,12 +307,13 @@ impl RawClient { } let prev_req = self.session_max_request_payload_bytes(); + self.op_deadline = deadline; self.disconnect(); self.state = ClientState::Broken; self.state = self.try_connect(); if self.state != ClientState::Ready { self.error_count += 1; - return Err(NipcError::Overflow); + return Err(self.call_reconnect_failure(NipcError::Overflow)); } self.reconnect_count += 1; if self.session_max_request_payload_bytes() <= prev_req @@ -296,6 +358,12 @@ impl RawClient { pub fn refresh(&mut self) -> bool { let old_state = self.state; + // A pending abort keeps the client offline until it is cleared. + if self.abort_requested() { + return false; + } + self.begin_op(0); + match self.state { ClientState::Disconnected | ClientState::NotFound => { self.state = ClientState::Connecting; @@ -401,7 +469,7 @@ impl RawClient { /// Tear down the current connection. pub(super) fn disconnect(&mut self) { - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] { if let Some(mut shm) = self.shm.take() { shm.close(); diff --git a/src/crates/netipc/src/service/raw/client_call.rs b/src/crates/netipc/src/service/raw/client_call.rs index 8612d3b6..b68b86f8 100644 --- a/src/crates/netipc/src/service/raw/client_call.rs +++ b/src/crates/netipc/src/service/raw/client_call.rs @@ -1,13 +1,15 @@ -use super::client::{ - ClientResponseRef, ClientResponseSource, ClientState, RawCallKind, RawClient, - CLIENT_ABORT_POLL_MS, -}; -use super::common::{ensure_client_scratch, CACHE_RESPONSE_BUF_SIZE}; +#[cfg(any(target_os = "linux", target_os = "freebsd", windows))] +use super::client::CLIENT_ABORT_POLL_MS; +use super::client::{ClientResponseRef, ClientResponseSource, ClientState, RawCallKind, RawClient}; +use super::common::{CACHE_RESPONSE_BUF_SIZE, ensure_client_scratch}; +#[cfg(any(target_os = "linux", target_os = "freebsd", windows))] +use crate::protocol::{self, MAGIC_MSG, VERSION}; use crate::protocol::{ - self, Header, NipcError, HEADER_SIZE, KIND_REQUEST, KIND_RESPONSE, MAGIC_MSG, - STATUS_LIMIT_EXCEEDED, STATUS_OK, VERSION, + HEADER_SIZE, Header, KIND_REQUEST, KIND_RESPONSE, NipcError, STATUS_LIMIT_EXCEEDED, STATUS_OK, }; +#[cfg(any(target_os = "linux", target_os = "freebsd", windows))] use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(any(target_os = "linux", target_os = "freebsd", windows))] use std::time::{Duration, Instant}; impl RawClient { @@ -20,7 +22,7 @@ impl RawClient { ) -> Result { if self.state != ClientState::Ready { self.error_count += 1; - return Err(NipcError::BadLayout); + return Err(NipcError::NotReady); } if self.abort_requested() { self.disconnect(); @@ -29,7 +31,7 @@ impl RawClient { return Err(NipcError::Aborted); } - let timeout_ms = self.resolved_call_timeout(timeout_ms); + self.begin_op(timeout_ms); let mut overflow_retries = 0u32; loop { @@ -38,7 +40,7 @@ impl RawClient { let prev_cfg_req = self.transport_config.max_request_payload_bytes; let prev_cfg_resp = self.transport_config.max_response_payload_bytes; - match self.do_raw_call(method_code, req_len, call, timeout_ms) { + match self.do_raw_call(method_code, req_len, call) { Ok(payload) => { self.call_count += 1; return Ok(payload); @@ -57,11 +59,11 @@ impl RawClient { self.state = self.try_connect(); if self.state != ClientState::Ready { self.error_count += 1; - return Err(first_err); + return Err(self.call_reconnect_failure(first_err)); } self.reconnect_count += 1; - match self.do_raw_call(method_code, req_len, call, timeout_ms) { + match self.do_raw_call(method_code, req_len, call) { Ok(payload) => { self.call_count += 1; return Ok(payload); @@ -80,7 +82,7 @@ impl RawClient { self.state = self.try_connect(); if self.state != ClientState::Ready { self.error_count += 1; - return Err(first_err); + return Err(self.call_reconnect_failure(first_err)); } self.reconnect_count += 1; @@ -107,17 +109,20 @@ impl RawClient { } } - /// Single attempt at a raw call. + /// Single attempt at a raw call, bounded by the call deadline. fn do_raw_call( &mut self, method_code: u16, req_len: usize, call: RawCallKind, - timeout_ms: u32, ) -> Result { if self.abort_requested() { return Err(NipcError::Aborted); } + let timeout_ms = self.op_remaining_ms(); + if timeout_ms == 0 { + return Err(NipcError::Timeout); + } let mut hdr = Header { kind: KIND_REQUEST, @@ -129,7 +134,13 @@ impl RawClient { ..Header::default() }; - self.transport_send_request_buf(&mut hdr, req_len)?; + self.transport_send_request_buf(&mut hdr, req_len, timeout_ms)?; + // The send may have waited for buffer space: the receive gets what + // is left of the call deadline. + let timeout_ms = self.op_remaining_ms(); + if timeout_ms == 0 { + return Err(NipcError::Timeout); + } let (resp_hdr, response) = self.transport_receive(timeout_ms)?; if resp_hdr.kind != KIND_RESPONSE { @@ -172,18 +183,22 @@ impl RawClient { ) -> Result<(), NipcError> { let req = self.request_scratch(payload.len()); req.copy_from_slice(payload); - self.transport_send_request_buf(hdr, payload.len()) + self.transport_send_request_buf(hdr, payload.len(), 0) } /// Send via the active transport (SHM if available, baseline otherwise). + /// The baseline send ends at `timeout_ms` (0 = no deadline) or on + /// abort; the SHM send never waits for the peer. pub(super) fn transport_send_request_buf( &mut self, hdr: &mut Header, req_len: usize, + timeout_ms: u32, ) -> Result<(), NipcError> { + #[cfg(any(target_os = "linux", target_os = "freebsd", windows))] let max_request_payload_bytes = self.session_max_request_payload_bytes(); - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] { if self.shm.is_some() { if req_len > max_request_payload_bytes as usize { @@ -212,7 +227,7 @@ impl RawClient { self.client_note_request_capacity(req_len as u32); Err(NipcError::Overflow) } - Err(_) => Err(NipcError::Truncated), + Err(_) => Err(shm_send_failure(self.session.as_ref())), }; } } @@ -246,18 +261,31 @@ impl RawClient { self.client_note_request_capacity(req_len as u32); Err(NipcError::Overflow) } - Err(_) => Err(NipcError::Truncated), + Err(_) => Err(shm_send_failure(self.session.as_ref())), }; } } let send_result = { - let session = self.session.as_mut().ok_or(NipcError::Truncated)?; - session.send(hdr, &self.request_buf[..req_len]) + let session = self.session.as_mut().ok_or(NipcError::Transport)?; + session.send_with_control( + hdr, + &self.request_buf[..req_len], + timeout_ms, + Some(self.abort_requested.as_ref()), + ) }; match send_result { Ok(()) => Ok(()), #[cfg(unix)] + Err(crate::transport::posix::UdsError::Timeout) => Err(NipcError::Timeout), + #[cfg(unix)] + Err(crate::transport::posix::UdsError::Aborted) => Err(NipcError::Aborted), + #[cfg(windows)] + Err(crate::transport::windows::NpError::Timeout) => Err(NipcError::Timeout), + #[cfg(windows)] + Err(crate::transport::windows::NpError::Aborted) => Err(NipcError::Aborted), + #[cfg(unix)] Err(crate::transport::posix::UdsError::LimitExceeded) => { self.client_note_request_capacity(req_len as u32); Err(NipcError::Overflow) @@ -267,7 +295,7 @@ impl RawClient { self.client_note_request_capacity(req_len as u32); Err(NipcError::Overflow) } - Err(_) => Err(NipcError::Truncated), + Err(e) => Err(session_failure(e, self.session.as_ref())), } } @@ -280,10 +308,12 @@ impl RawClient { let abort = self.abort_requested.clone(); let scratch = ensure_client_scratch(&mut self.transport_buf, needed); - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] { if let Some(ref mut shm) = self.shm { - let mlen = receive_posix_shm_with_control(shm, scratch, timeout_ms, &abort)?; + let session_fd = self.session.as_ref().map_or(-1, |s| s.fd()); + let mlen = + receive_posix_shm_with_control(shm, scratch, timeout_ms, &abort, session_fd)?; if mlen < HEADER_SIZE { return Err(NipcError::Truncated); @@ -303,7 +333,13 @@ impl RawClient { #[cfg(windows)] { if let Some(ref mut shm) = self.shm { - let mlen = receive_win_shm_with_control(shm, scratch, timeout_ms, &abort)?; + let mlen = receive_win_shm_with_control( + shm, + scratch, + timeout_ms, + &abort, + self.session.as_ref(), + )?; if mlen < HEADER_SIZE { return Err(NipcError::Truncated); @@ -320,14 +356,15 @@ impl RawClient { } } - let session = self.session.as_mut().ok_or(NipcError::Truncated)?; + let session = self.session.as_mut().ok_or(NipcError::Transport)?; #[cfg(unix)] { let scratch_payload_ptr = unsafe { scratch.as_ptr().add(HEADER_SIZE) }; + let session_fd = session.fd(); let (hdr, payload) = session .receive_with_control(scratch, timeout_ms, Some(&abort)) - .map_err(map_uds_receive_error)?; + .map_err(|e| map_uds_receive_error(e, session_fd))?; let source = if payload.as_ptr() == scratch_payload_ptr { ClientResponseSource::TransportBuf } else { @@ -345,9 +382,11 @@ impl RawClient { #[cfg(windows)] { let scratch_payload_ptr = unsafe { scratch.as_ptr().add(HEADER_SIZE) }; - let (hdr, payload) = session - .receive_with_control(scratch, timeout_ms, Some(&abort)) - .map_err(map_np_receive_error)?; + let received = session.receive_with_control(scratch, timeout_ms, Some(&abort)); + let (hdr, payload) = match received { + Ok(v) => v, + Err(e) => return Err(map_np_receive_error(e, session)), + }; let source = if payload.as_ptr() == scratch_payload_ptr { ClientResponseSource::TransportBuf } else { @@ -411,10 +450,12 @@ impl RawClient { } } +#[cfg(any(target_os = "linux", target_os = "freebsd", windows))] fn receive_deadline(timeout_ms: u32) -> Option { (timeout_ms != 0).then(|| Instant::now() + Duration::from_millis(timeout_ms as u64)) } +#[cfg(any(target_os = "linux", target_os = "freebsd", windows))] fn receive_wait_slice_ms(deadline: Option, abort: &AtomicBool) -> Result { if abort.load(Ordering::Acquire) { return Err(NipcError::Aborted); @@ -437,13 +478,15 @@ fn receive_wait_slice_ms(deadline: Option, abort: &AtomicBool) -> Resul Ok(wait_ms.unwrap().min(u32::MAX as u128) as u32) } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] fn receive_posix_shm_with_control( shm: &mut crate::transport::shm::ShmContext, scratch: &mut [u8], timeout_ms: u32, abort: &AtomicBool, + session_fd: i32, ) -> Result { + use super::server_session_unix::peer_disconnected; use crate::transport::shm::ShmError; let deadline = receive_deadline(timeout_ms); @@ -451,9 +494,17 @@ fn receive_posix_shm_with_control( let wait_ms = receive_wait_slice_ms(deadline, abort)?; match shm.receive(scratch, wait_ms) { Ok(mlen) => return Ok(mlen), - Err(ShmError::Timeout) => continue, + Err(ShmError::Timeout) => { + // Only an idle slice pays for the probe. + if session_fd >= 0 && peer_disconnected(session_fd) { + return Err(NipcError::Disconnected); + } + continue; + } Err(ShmError::MsgTooLarge) => return Err(NipcError::Overflow), - Err(_) => return Err(NipcError::Truncated), + // A zero-length or corrupt slot is a truncated message. + Err(ShmError::BadHeader) => return Err(NipcError::Truncated), + Err(_) => return Err(NipcError::Transport), } } } @@ -464,9 +515,12 @@ fn receive_win_shm_with_control( scratch: &mut [u8], timeout_ms: u32, abort: &AtomicBool, + session: Option<&crate::transport::windows::NpSession>, ) -> Result { use crate::transport::win_shm::WinShmError; + let peer_gone = || session.is_some_and(super::server_session_windows::peer_disconnected); + if abort.load(Ordering::Acquire) { return Err(NipcError::Aborted); } @@ -474,7 +528,10 @@ fn receive_win_shm_with_control( Ok(Some(mlen)) => return Ok(mlen), Ok(None) => {} Err(WinShmError::MsgTooLarge) => return Err(NipcError::Overflow), - Err(_) => return Err(NipcError::Truncated), + Err(WinShmError::Disconnected) => return Err(NipcError::Disconnected), + // A zero-length or corrupt slot is a truncated message. + Err(WinShmError::BadHeader) => return Err(NipcError::Truncated), + Err(_) => return Err(NipcError::Transport), } let deadline = receive_deadline(timeout_ms); @@ -482,37 +539,130 @@ fn receive_win_shm_with_control( let wait_ms = receive_wait_slice_ms(deadline, abort)?; match shm.receive(scratch, wait_ms) { Ok(mlen) => return Ok(mlen), - Err(WinShmError::Timeout) => continue, + Err(WinShmError::Timeout) => { + // Only an idle slice pays for the probe. + if peer_gone() { + return Err(NipcError::Disconnected); + } + continue; + } Err(WinShmError::MsgTooLarge) => return Err(NipcError::Overflow), - Err(_) => return Err(NipcError::Truncated), + Err(WinShmError::Disconnected) => return Err(NipcError::Disconnected), + Err(WinShmError::BadHeader) => return Err(NipcError::Truncated), + Err(_) => return Err(NipcError::Transport), } } } #[cfg(unix)] -fn map_uds_receive_error(err: crate::transport::posix::UdsError) -> NipcError { +fn map_uds_receive_error(err: crate::transport::posix::UdsError, session_fd: i32) -> NipcError { match err { crate::transport::posix::UdsError::Timeout => NipcError::Timeout, crate::transport::posix::UdsError::Aborted => NipcError::Aborted, crate::transport::posix::UdsError::LimitExceeded => NipcError::Overflow, - _ => NipcError::Truncated, + other => socket_failure(other, session_fd, NipcError::Truncated), } } +/// The UDS transport reports EOF, EPIPE and ECONNRESET as plain send/recv +/// failures; the session probe tells a gone server apart from any other I/O +/// failure. Protocol errors keep their own meaning. +#[cfg(unix)] +fn socket_failure( + err: crate::transport::posix::UdsError, + session_fd: i32, + otherwise: NipcError, +) -> NipcError { + match err { + crate::transport::posix::UdsError::Recv(_) | crate::transport::posix::UdsError::Send(_) => { + if super::server_session_unix::peer_disconnected(session_fd) { + NipcError::Disconnected + } else { + NipcError::Transport + } + } + _ => otherwise, + } +} + +#[cfg(unix)] +fn session_failure( + err: crate::transport::posix::UdsError, + session: Option<&crate::transport::posix::UdsSession>, +) -> NipcError { + session.map_or(NipcError::Transport, |s| { + socket_failure(err, s.fd(), NipcError::Transport) + }) +} + +/// A failed SHM send is a disconnect when the session probe says the server +/// is gone, and a transport failure otherwise. +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn shm_send_failure(session: Option<&crate::transport::posix::UdsSession>) -> NipcError { + match session { + Some(s) if super::server_session_unix::peer_disconnected(s.fd()) => NipcError::Disconnected, + _ => NipcError::Transport, + } +} + +#[cfg(windows)] +fn shm_send_failure(session: Option<&crate::transport::windows::NpSession>) -> NipcError { + match session { + Some(s) if super::server_session_windows::peer_disconnected(s) => NipcError::Disconnected, + _ => NipcError::Transport, + } +} + +/// A broken pipe is a disconnect; a plain send/recv failure is one when the +/// session probe says the server is gone, and a transport failure otherwise. +/// Protocol errors keep their own meaning. +#[cfg(windows)] +fn pipe_failure( + err: crate::transport::windows::NpError, + session: &crate::transport::windows::NpSession, + otherwise: NipcError, +) -> NipcError { + match err { + crate::transport::windows::NpError::Disconnected => NipcError::Disconnected, + crate::transport::windows::NpError::Recv(_) + | crate::transport::windows::NpError::Send(_) => { + if super::server_session_windows::peer_disconnected(session) { + NipcError::Disconnected + } else { + NipcError::Transport + } + } + _ => otherwise, + } +} + +#[cfg(windows)] +fn session_failure( + err: crate::transport::windows::NpError, + session: Option<&crate::transport::windows::NpSession>, +) -> NipcError { + session.map_or(NipcError::Transport, |s| { + pipe_failure(err, s, NipcError::Transport) + }) +} + #[cfg(windows)] -fn map_np_receive_error(err: crate::transport::windows::NpError) -> NipcError { +fn map_np_receive_error( + err: crate::transport::windows::NpError, + session: &crate::transport::windows::NpSession, +) -> NipcError { match err { crate::transport::windows::NpError::Timeout => NipcError::Timeout, crate::transport::windows::NpError::Aborted => NipcError::Aborted, crate::transport::windows::NpError::LimitExceeded => NipcError::Overflow, - _ => NipcError::Truncated, + other => pipe_failure(other, session, NipcError::Truncated), } } #[cfg(all(test, windows))] mod tests { use super::*; - use crate::transport::win_shm::{WinShmContext, PROFILE_HYBRID}; + use crate::transport::win_shm::{PROFILE_HYBRID, WinShmContext}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; static RAW_WIN_SHM_TEST_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -554,7 +704,7 @@ mod tests { let mut scratch = [0u8; 128]; server.send(b"response").expect("server send"); - let len = receive_win_shm_with_control(&mut client, &mut scratch, 100, &abort) + let len = receive_win_shm_with_control(&mut client, &mut scratch, 100, &abort, None) .expect("receive ready response"); assert_eq!(len, 8); assert_eq!(&scratch[..len], b"response"); @@ -565,7 +715,7 @@ mod tests { let (mut server, mut client) = win_shm_pair("rs_raw_win_shm_abort"); let abort = AtomicBool::new(true); assert_eq!( - receive_win_shm_with_control(&mut client, &mut scratch, 100, &abort), + receive_win_shm_with_control(&mut client, &mut scratch, 100, &abort, None), Err(NipcError::Aborted) ); client.close(); @@ -574,7 +724,7 @@ mod tests { let (mut server, mut client) = win_shm_pair("rs_raw_win_shm_timeout"); let abort = AtomicBool::new(false); assert_eq!( - receive_win_shm_with_control(&mut client, &mut scratch, 1, &abort), + receive_win_shm_with_control(&mut client, &mut scratch, 1, &abort, None), Err(NipcError::Timeout) ); client.close(); @@ -584,8 +734,8 @@ mod tests { let abort = AtomicBool::new(false); server.destroy(); assert_eq!( - receive_win_shm_with_control(&mut client, &mut scratch, 100, &abort), - Err(NipcError::Truncated) + receive_win_shm_with_control(&mut client, &mut scratch, 100, &abort, None), + Err(NipcError::Disconnected) ); client.close(); } diff --git a/src/crates/netipc/src/service/raw/client_unix.rs b/src/crates/netipc/src/service/raw/client_unix.rs index b3b717ea..e86f7004 100644 --- a/src/crates/netipc/src/service/raw/client_unix.rs +++ b/src/crates/netipc/src/service/raw/client_unix.rs @@ -1,9 +1,10 @@ use super::client::{ClientState, RawClient}; -use super::common::{CLIENT_SHM_ATTACH_RETRY_INTERVAL_MS, CLIENT_SHM_ATTACH_RETRY_TIMEOUT_MS}; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +use super::common::CLIENT_SHM_ATTACH_RETRY_INTERVAL_MS; +#[cfg(any(target_os = "linux", target_os = "freebsd"))] use crate::protocol::{PROFILE_SHM_FUTEX, PROFILE_SHM_HYBRID}; use crate::transport::posix::UdsSession; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] use crate::transport::shm::ShmContext; impl RawClient { @@ -11,21 +12,39 @@ impl RawClient { /// upgrade if negotiated. #[cfg(unix)] pub(super) fn try_connect(&mut self) -> ClientState { - match UdsSession::connect(&self.run_dir, &self.service_name, &self.transport_config) { + let remaining = self.op_remaining_ms(); + if remaining == 0 || self.abort_requested() { + return ClientState::Disconnected; + } + let abort = self.abort_requested.clone(); + match UdsSession::connect_with_timeout( + &self.run_dir, + &self.service_name, + &self.transport_config, + remaining, + Some(&abort), + ) { Ok(session) => { - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] let selected_profile = session.selected_profile; - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] let session_id = session.session_id; - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] { if selected_profile == PROFILE_SHM_HYBRID || selected_profile == PROFILE_SHM_FUTEX { let mut shm_ok = false; - let deadline = std::time::Instant::now() - + std::time::Duration::from_millis(CLIENT_SHM_ATTACH_RETRY_TIMEOUT_MS); + // When the call deadline ends the wait first, SHM is + // not at fault and stays enabled for the next connect. + let call_left = + std::time::Duration::from_millis(self.op_remaining_ms() as u64); + // The remaining time is rounded up, so a tie means the + // call deadline comes first. + let call_bound = call_left <= self.shm_attach_timeout; + let deadline = + std::time::Instant::now() + call_left.min(self.shm_attach_timeout); loop { match ShmContext::client_attach( &self.run_dir, @@ -39,8 +58,20 @@ impl RawClient { } Err(_) => { if std::time::Instant::now() >= deadline { + if call_bound { + return ClientState::Disconnected; + } break; } + if self.abort_requested() { + return ClientState::Disconnected; + } + // A server that refused or died closes the + // session socket; its SHM region will never + // appear. Not an SHM problem: keep SHM enabled. + if super::server_session_unix::peer_disconnected(session.fd()) { + return ClientState::Disconnected; + } std::thread::sleep(std::time::Duration::from_millis( CLIENT_SHM_ATTACH_RETRY_INTERVAL_MS, )); diff --git a/src/crates/netipc/src/service/raw/client_windows.rs b/src/crates/netipc/src/service/raw/client_windows.rs index 7cbf6f8a..d0ae1eb7 100644 --- a/src/crates/netipc/src/service/raw/client_windows.rs +++ b/src/crates/netipc/src/service/raw/client_windows.rs @@ -1,15 +1,26 @@ use super::client::{ClientState, RawClient}; -use super::common::{CLIENT_SHM_ATTACH_RETRY_INTERVAL_MS, CLIENT_SHM_ATTACH_RETRY_TIMEOUT_MS}; +use super::common::CLIENT_SHM_ATTACH_RETRY_INTERVAL_MS; use crate::transport::win_shm::{ - WinShmContext, PROFILE_BUSYWAIT as WIN_SHM_PROFILE_BUSYWAIT, - PROFILE_HYBRID as WIN_SHM_PROFILE_HYBRID, + PROFILE_BUSYWAIT as WIN_SHM_PROFILE_BUSYWAIT, PROFILE_HYBRID as WIN_SHM_PROFILE_HYBRID, + WinShmContext, }; use crate::transport::windows::{NpError, NpSession}; impl RawClient { /// Windows: attempt a full Named Pipe connection + Win SHM upgrade. pub(super) fn try_connect(&mut self) -> ClientState { - match NpSession::connect(&self.run_dir, &self.service_name, &self.transport_config) { + let remaining = self.op_remaining_ms(); + if remaining == 0 || self.abort_requested() { + return ClientState::Disconnected; + } + let abort = self.abort_requested.clone(); + match NpSession::connect_with_timeout( + &self.run_dir, + &self.service_name, + &self.transport_config, + remaining, + Some(&abort), + ) { Ok(session) => { let selected_profile = session.selected_profile; @@ -17,8 +28,14 @@ impl RawClient { || selected_profile == WIN_SHM_PROFILE_BUSYWAIT { let mut shm_ok = false; - let deadline = std::time::Instant::now() - + std::time::Duration::from_millis(CLIENT_SHM_ATTACH_RETRY_TIMEOUT_MS); + // When the call deadline ends the wait first, WinSHM is + // not at fault and stays enabled for the next connect. + let call_left = std::time::Duration::from_millis(self.op_remaining_ms() as u64); + // The remaining time is rounded up, so a tie means the + // call deadline comes first. + let call_bound = call_left <= self.shm_attach_timeout; + let deadline = + std::time::Instant::now() + call_left.min(self.shm_attach_timeout); loop { match WinShmContext::client_attach( &self.run_dir, @@ -34,8 +51,20 @@ impl RawClient { } Err(_) => { if std::time::Instant::now() >= deadline { + if call_bound { + return ClientState::Disconnected; + } break; } + if self.abort_requested() { + return ClientState::Disconnected; + } + // A server that refused or died breaks the + // session pipe; its SHM objects will never + // appear. Not a WinSHM problem: keep it enabled. + if super::server_session_windows::peer_disconnected(&session) { + return ClientState::Disconnected; + } std::thread::sleep(std::time::Duration::from_millis( CLIENT_SHM_ATTACH_RETRY_INTERVAL_MS, )); @@ -60,7 +89,7 @@ impl RawClient { } Err(e) => match e { NpError::Connect(_) => ClientState::NotFound, - NpError::AuthFailed => ClientState::AuthFailed, + NpError::AuthFailed | NpError::UntrustedServer => ClientState::AuthFailed, NpError::NoProfile => ClientState::Incompatible, NpError::Incompatible(_) => ClientState::Incompatible, _ => ClientState::Disconnected, diff --git a/src/crates/netipc/src/service/raw/common.rs b/src/crates/netipc/src/service/raw/common.rs index 158f3360..0fcf3849 100644 --- a/src/crates/netipc/src/service/raw/common.rs +++ b/src/crates/netipc/src/service/raw/common.rs @@ -1,5 +1,37 @@ pub(super) const SERVER_POLL_TIMEOUT_MS: u32 = 100; +/// How long a stopping server waits for session workers to finish their +/// current message before forcing them out of blocking I/O. +pub(super) const SERVER_STOP_GRACE_MS: u64 = 1_000; + +/// Server waits. Production uses the defaults; tests shorten them per +/// instance so timeout paths run in milliseconds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ServerTimings { + pub(super) poll_ms: u32, + pub(super) stop_grace: std::time::Duration, + pub(super) handshake_ms: u32, +} + +impl Default for ServerTimings { + fn default() -> Self { + Self { + poll_ms: SERVER_POLL_TIMEOUT_MS, + stop_grace: std::time::Duration::from_millis(SERVER_STOP_GRACE_MS), + handshake_ms: crate::protocol::SERVER_HANDSHAKE_TIMEOUT_MS, + } + } +} + +impl ServerTimings { + /// How long a connection waits for a worker slot before it is refused. + /// Two poll slices: a worker whose client left exits within one. + pub(super) fn slot_wait(&self) -> std::time::Duration { + std::time::Duration::from_millis(2 * self.poll_ms as u64) + } +} +#[cfg(any(target_os = "linux", target_os = "freebsd", windows))] pub(super) const CLIENT_SHM_ATTACH_RETRY_INTERVAL_MS: u64 = 5; +#[cfg(any(target_os = "linux", target_os = "freebsd", windows))] pub(super) const CLIENT_SHM_ATTACH_RETRY_TIMEOUT_MS: u64 = 5_000; pub(super) const CACHE_RESPONSE_BUF_SIZE: usize = 65536; pub const LOOKUP_LOGICAL_ITEMS_DEFAULT: u32 = 65_536; @@ -46,6 +78,29 @@ impl LookupLogicalConfig { } } +/// When all workers are busy, wait up to `slot_wait` for one to +/// finish, so a client that reconnects right after closing is not refused +/// while its old worker exits. The accept loop has nothing else to serve +/// while full, so waiting costs nothing. Returns whether a slot is free. +pub(super) fn wait_for_worker_slot( + running: &std::sync::atomic::AtomicBool, + slot_wait: std::time::Duration, + mut has_slot: impl FnMut() -> bool, +) -> bool { + let deadline = std::time::Instant::now() + slot_wait; + loop { + if has_slot() { + return true; + } + if !running.load(std::sync::atomic::Ordering::Acquire) + || std::time::Instant::now() >= deadline + { + return false; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } +} + pub(super) fn next_power_of_2_u32(n: u32) -> u32 { if n < 16 { return 16; diff --git a/src/crates/netipc/src/service/raw/dispatch.rs b/src/crates/netipc/src/service/raw/dispatch.rs index 0a3d1a25..1704787d 100644 --- a/src/crates/netipc/src/service/raw/dispatch.rs +++ b/src/crates/netipc/src/service/raw/dispatch.rs @@ -1,7 +1,7 @@ use super::common::next_power_of_2_u32; use crate::protocol::NipcError; -use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DispatchError { diff --git a/src/crates/netipc/src/service/raw/increment.rs b/src/crates/netipc/src/service/raw/increment.rs index d377ab5d..e4346f68 100644 --- a/src/crates/netipc/src/service/raw/increment.rs +++ b/src/crates/netipc/src/service/raw/increment.rs @@ -1,8 +1,8 @@ use super::client::{ClientConfig, RawCallKind, RawClient}; use super::dispatch::{DispatchError, DispatchHandler}; use crate::protocol::{ - self, batch_item_get, increment_decode, increment_encode, BatchBuilder, NipcError, - INCREMENT_PAYLOAD_SIZE, METHOD_INCREMENT, + self, BatchBuilder, INCREMENT_PAYLOAD_SIZE, METHOD_INCREMENT, NipcError, batch_item_get, + increment_decode, increment_encode, }; use std::sync::Arc; diff --git a/src/crates/netipc/src/service/raw/server.rs b/src/crates/netipc/src/service/raw/server.rs index 005bda95..3130d791 100644 --- a/src/crates/netipc/src/service/raw/server.rs +++ b/src/crates/netipc/src/service/raw/server.rs @@ -1,3 +1,4 @@ +use super::common::ServerTimings; use super::dispatch::DispatchHandler; use crate::protocol::{MAX_PAYLOAD_CAP, MAX_PAYLOAD_DEFAULT}; @@ -8,7 +9,68 @@ pub(super) use crate::transport::posix::ServerConfig; pub(super) use crate::transport::windows::ServerConfig; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; + +/// Set once a managed server accepts connections. Clones share the state, so +/// a caller can keep one and wait while another thread runs the server. +#[derive(Clone, Default)] +pub struct ListeningSignal(Arc<(Mutex, Condvar)>); + +impl ListeningSignal { + /// Wait up to `timeout` for the server to listen. Returns whether it is + /// listening; a server whose `run()` failed to bind never is. + pub fn wait(&self, timeout: Duration) -> bool { + let (lock, cond) = &*self.0; + let guard = lock.lock().unwrap(); + let (guard, _) = cond + .wait_timeout_while(guard, timeout, |listening| !*listening) + .unwrap(); + *guard + } + + /// Whether the server has started listening. + pub fn is_set(&self) -> bool { + *self.0.0.lock().unwrap() + } + + pub(super) fn set(&self) { + let (lock, cond) = &*self.0; + *lock.lock().unwrap() = true; + cond.notify_all(); + } +} + +/// Test-only notifications from server internals, so tests wait on events +/// instead of sleeping. +#[cfg(test)] +#[derive(Clone, Default)] +pub(super) struct TestHooks { + pub(super) session_exited: Option>, + pub(super) handshake_started: Option>, + /// A session's SHM region was created. SHM exists only on Linux and + /// Windows. + #[cfg_attr( + not(any(target_os = "linux", target_os = "freebsd", windows)), + allow(dead_code) + )] + pub(super) shm_created: Option>, + /// Makes SHM region creation fail, to exercise the baseline fallback. + #[cfg_attr( + not(any(target_os = "linux", target_os = "freebsd", windows)), + allow(dead_code) + )] + pub(super) fail_shm_create: std::sync::Arc, +} + +#[cfg(test)] +impl TestHooks { + pub(super) fn notify(tx: &Option>) { + if let Some(tx) = tx { + let _ = tx.send(()); + } + } +} /// L2 managed server. Typed request/response dispatcher. /// @@ -21,14 +83,22 @@ pub struct ManagedServer { pub(super) expected_method_code: u16, pub(super) handler: Option, pub(super) running: Arc, + /// Set by `stop()` and when `run()` exits, never cleared: `run()` after a + /// stop returns at once, and pending handshakes abort on it. + pub(super) stop_requested: Arc, pub(super) learned_request_payload_bytes: Arc, pub(super) learned_response_payload_bytes: Arc, pub(super) request_payload_growth_ceiling: u32, pub(super) response_payload_growth_ceiling: u32, pub(super) next_session_id: u64, pub(super) worker_count: usize, + pub(super) timings: ServerTimings, + pub(super) listening: ListeningSignal, + #[cfg(test)] + pub(super) test_hooks: TestHooks, #[cfg(windows)] - pub(super) listener_handle: Arc>>, + pub(super) listener_waker: + Arc>>, } impl ManagedServer { @@ -87,36 +157,42 @@ impl ManagedServer { expected_method_code, handler, running: Arc::new(AtomicBool::new(false)), + stop_requested: Arc::new(AtomicBool::new(false)), learned_request_payload_bytes: Arc::new(AtomicU32::new(learned_request)), learned_response_payload_bytes: Arc::new(AtomicU32::new(learned_response)), request_payload_growth_ceiling, response_payload_growth_ceiling, next_session_id: 1, worker_count: if worker_count < 1 { 1 } else { worker_count }, + timings: ServerTimings::default(), + listening: ListeningSignal::default(), + #[cfg(test)] + test_hooks: TestHooks::default(), #[cfg(windows)] - listener_handle: Arc::new(std::sync::Mutex::new(None)), + listener_waker: Arc::new(std::sync::Mutex::new(None)), } } - /// Signal shutdown. On Windows, also closes the listener pipe to - /// unblock ConnectNamedPipe in the accept loop. + /// Signal shutdown. On Windows, also wakes the accept loop blocked in + /// ConnectNamedPipe; it never touches a handle the loop owns. pub fn stop(&self) { - self.running.store(false, Ordering::Release); + self.stop_handle().stop(); + } - #[cfg(windows)] - { - let mut guard = self.listener_handle.lock().unwrap(); - if let Some(h) = guard.take() { - unsafe extern "system" { - fn CloseHandle(h: isize) -> i32; - } - unsafe { - CloseHandle(h as isize); - } - } + fn stop_handle(&self) -> StopHandle { + StopHandle { + stop_requested: self.stop_requested.clone(), + running: self.running.clone(), + #[cfg(windows)] + listener_waker: self.listener_waker.clone(), } } + /// Returns a signal that is set once `run()` accepts connections. + pub fn listening_signal(&self) -> ListeningSignal { + self.listening.clone() + } + /// Returns the internal running flag for diagnostics and test helpers. /// /// For reliable shutdown, call `stop()`. On Windows, flipping this flag @@ -125,3 +201,155 @@ impl ManagedServer { self.running.clone() } } + +/// What `stop()` needs, detached from the server so another thread can stop +/// it while `run()` owns it. +struct StopHandle { + stop_requested: Arc, + running: Arc, + #[cfg(windows)] + listener_waker: Arc>>, +} + +impl StopHandle { + fn stop(&self) { + self.stop_requested.store(true, Ordering::Release); + self.running.store(false, Ordering::Release); + + #[cfg(windows)] + if let Some(waker) = self.listener_waker.lock().unwrap().as_ref() { + waker.wake(); + } + } +} + +/// Server waits for tests: timeout paths run in milliseconds while staying +/// well above scheduling noise. +#[cfg(test)] +pub(crate) const TEST_SERVER_TIMINGS: ServerTimings = ServerTimings { + poll_ms: 10, + stop_grace: Duration::from_millis(200), + handshake_ms: 500, +}; + +/// Bounds test waits that only expire when the code under test is broken; +/// reaching it fails the test. +#[cfg(test)] +pub(crate) const TEST_WAIT_LIMIT: Duration = Duration::from_secs(10); + +/// Receivers for a test server's session events. +#[cfg(test)] +pub(crate) struct TestEvents { + session_exited: std::sync::mpsc::Receiver<()>, + handshake_started: std::sync::mpsc::Receiver<()>, + shm_created: std::sync::mpsc::Receiver<()>, + fail_shm_create: std::sync::Arc, +} + +#[cfg(test)] +impl TestEvents { + /// Wait until `n` session workers have exited and freed their slots. + pub(crate) fn wait_session_exits(&self, n: usize) { + for _ in 0..n { + self.session_exited + .recv_timeout(TEST_WAIT_LIMIT) + .expect("session worker did not exit"); + } + } + + /// Number of SHM regions created since the last call. A region exists + /// before HELLO_ACK, so a READY client's region is already counted. + #[cfg_attr( + not(any(target_os = "linux", target_os = "freebsd", windows)), + allow(dead_code) + )] + pub(crate) fn take_shm_created(&self) -> usize { + self.shm_created.try_iter().count() + } + + /// Make the server's SHM region creation fail (or succeed again). + #[cfg_attr( + not(any(target_os = "linux", target_os = "freebsd", windows)), + allow(dead_code) + )] + pub(crate) fn set_fail_shm_create(&self, fail: bool) { + self.fail_shm_create.store(fail, Ordering::Release); + } + + /// Wait until `n` workers are waiting for HELLO. + pub(crate) fn wait_handshakes_started(&self, n: usize) { + for _ in 0..n { + self.handshake_started + .recv_timeout(TEST_WAIT_LIMIT) + .expect("no handshake started"); + } + } +} + +#[cfg(test)] +impl ManagedServer { + /// Switch to test timings and report session events. + pub(crate) fn use_test_timings(&mut self) -> TestEvents { + let (exit_tx, session_exited) = std::sync::mpsc::channel(); + let (hello_tx, handshake_started) = std::sync::mpsc::channel(); + let (shm_tx, shm_created) = std::sync::mpsc::channel(); + let fail_shm_create = std::sync::Arc::new(AtomicBool::new(false)); + self.timings = TEST_SERVER_TIMINGS; + self.test_hooks = TestHooks { + session_exited: Some(exit_tx), + handshake_started: Some(hello_tx), + shm_created: Some(shm_tx), + fail_shm_create: fail_shm_create.clone(), + }; + TestEvents { + session_exited, + handshake_started, + shm_created, + fail_shm_create, + } + } + + /// Run on a new thread with test timings; returns once the server + /// accepts connections. + pub(crate) fn spawn_for_test(mut self) -> RunningTestServer { + let events = self.use_test_timings(); + let listening = self.listening_signal(); + let stopper = self.stop_handle(); + let thread = std::thread::spawn(move || self.run()); + if !listening.wait(TEST_WAIT_LIMIT) { + panic!("test server is not listening"); + } + RunningTestServer { + stopper, + thread: Some(thread), + events, + } + } +} + +/// A managed server running on its own thread; dropping it stops and joins +/// the server. +#[cfg(test)] +pub(crate) struct RunningTestServer { + stopper: StopHandle, + thread: Option>>, + pub(crate) events: TestEvents, +} + +#[cfg(test)] +impl RunningTestServer { + /// Stop the server and wait for `run()` to return. + pub(crate) fn stop(&mut self) { + self.stopper.stop(); + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} + +#[cfg(test)] +impl Drop for RunningTestServer { + fn drop(&mut self) { + self.stop(); + } +} diff --git a/src/crates/netipc/src/service/raw/server_session_unix.rs b/src/crates/netipc/src/service/raw/server_session_unix.rs index 6753d48a..0e4b65a8 100644 --- a/src/crates/netipc/src/service/raw/server_session_unix.rs +++ b/src/crates/netipc/src/service/raw/server_session_unix.rs @@ -1,29 +1,32 @@ -use super::common::{ensure_client_scratch, SERVER_POLL_TIMEOUT_MS}; +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +use super::common::ensure_client_scratch; use super::dispatch::{ - dispatch_single_internal, method_supported_internal, server_note_payload_capacity, - DispatchError, DispatchHandler, + DispatchError, DispatchHandler, dispatch_single_internal, method_supported_internal, + server_note_payload_capacity, }; use crate::protocol::{ - self, batch_item_get, BatchBuilder, Header, FLAG_BATCH, HEADER_SIZE, KIND_REQUEST, - KIND_RESPONSE, MAGIC_MSG, STATUS_BAD_ENVELOPE, STATUS_INTERNAL_ERROR, STATUS_LIMIT_EXCEEDED, - STATUS_OK, VERSION, + self, BatchBuilder, FLAG_BATCH, HEADER_SIZE, Header, KIND_REQUEST, KIND_RESPONSE, + STATUS_BAD_ENVELOPE, STATUS_INTERNAL_ERROR, STATUS_LIMIT_EXCEEDED, STATUS_OK, batch_item_get, }; +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +use crate::protocol::{MAGIC_MSG, VERSION}; use crate::transport::posix::UdsSession; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] use crate::transport::shm::ShmContext; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; /// POSIX: Handle one client session in its own thread. pub(super) fn handle_session_threaded( - mut session: UdsSession, - #[cfg(target_os = "linux")] mut shm: Option, - #[cfg(not(target_os = "linux"))] _shm: Option<()>, + session: &mut UdsSession, + #[cfg(any(target_os = "linux", target_os = "freebsd"))] mut shm: Option, + #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] _shm: Option<()>, expected_method_code: u16, handler: Option, running: Arc, + poll_ms: u32, learned_request_payload_bytes: Arc, learned_response_payload_bytes: Arc, request_payload_growth_ceiling: u32, @@ -32,14 +35,15 @@ pub(super) fn handle_session_threaded( let mut recv_buf = vec![0u8; HEADER_SIZE + session.max_request_payload_bytes as usize]; let mut resp_buf = vec![0u8; session.max_response_payload_bytes as usize]; let mut item_resp_buf = vec![0u8; session.max_response_payload_bytes as usize]; + #[cfg(any(target_os = "linux", target_os = "freebsd"))] let mut msg_buf = vec![0u8; HEADER_SIZE + session.max_response_payload_bytes as usize]; while running.load(Ordering::Acquire) { let (hdr, payload) = { - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] { if let Some(ref mut shm_ctx) = shm { - match shm_ctx.receive(&mut recv_buf, SERVER_POLL_TIMEOUT_MS) { + match shm_ctx.receive(&mut recv_buf, poll_ms) { Ok(mlen) => { if mlen < HEADER_SIZE { break; @@ -51,11 +55,18 @@ pub(super) fn handle_session_threaded( let payload = &recv_buf[HEADER_SIZE..mlen]; (hdr, payload) } - Err(crate::transport::shm::ShmError::Timeout) => continue, + Err(crate::transport::shm::ShmError::Timeout) => { + // A client that left (cleanly or by crashing) + // never sends again; its socket is the only sign. + if peer_disconnected(session.fd()) { + break; + } + continue; + } Err(_) => break, } } else { - let ready = poll_fd(session.fd(), SERVER_POLL_TIMEOUT_MS as i32); + let ready = poll_fd(session.fd(), poll_ms as i32); if ready < 0 { break; } @@ -70,9 +81,9 @@ pub(super) fn handle_session_threaded( } } - #[cfg(not(target_os = "linux"))] + #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] { - let ready = poll_fd(session.fd(), SERVER_POLL_TIMEOUT_MS as i32); + let ready = poll_fd(session.fd(), poll_ms as i32); if ready < 0 { break; } @@ -109,7 +120,7 @@ pub(super) fn handle_session_threaded( ..Header::default() }; - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] { if let Some(ref mut shm_ctx) = shm { let msg = ensure_client_scratch(&mut msg_buf, HEADER_SIZE); @@ -238,7 +249,7 @@ pub(super) fn handle_session_threaded( } } - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] { if let Some(ref mut shm_ctx) = shm { let msg_len = HEADER_SIZE + response_len; @@ -275,13 +286,74 @@ pub(super) fn handle_session_threaded( } } - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] { if let Some(mut shm_ctx) = shm { shm_ctx.destroy(); } } - drop(session); +} + +/// Zero-wait probe of a session socket. SHM sessions keep the socket open for +/// their whole life and the kernel closes it when the peer exits, so +/// HUP/ERR/EOF here is the dead-peer signal for both directions. +pub(super) fn peer_disconnected(fd: i32) -> bool { + if fd < 0 { + return true; + } + + #[cfg(any(target_os = "linux", target_os = "android"))] + let rdhup = libc::POLLRDHUP; + #[cfg(not(any(target_os = "linux", target_os = "android")))] + let rdhup = 0; + + let mut pfd = libc::pollfd { + fd, + events: libc::POLLIN | libc::POLLHUP | libc::POLLERR | rdhup, + revents: 0, + }; + let ret = loop { + let ret = unsafe { libc::poll(&mut pfd, 1, 0) }; + if ret < 0 && std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) { + continue; + } + break ret; + }; + + if ret < 0 { + return true; + } + if ret == 0 { + return false; + } + if pfd.revents & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL | rdhup) != 0 { + return true; + } + if pfd.revents & libc::POLLIN != 0 { + let mut ch = 0u8; + let n = loop { + let n = unsafe { + libc::recv( + fd, + (&mut ch as *mut u8).cast(), + 1, + libc::MSG_PEEK | libc::MSG_DONTWAIT, + ) + }; + if n < 0 && std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) { + continue; + } + break n; + }; + if n == 0 { + return true; + } + if n < 0 { + let errno = std::io::Error::last_os_error().raw_os_error(); + return errno != Some(libc::EAGAIN) && errno != Some(libc::EWOULDBLOCK); + } + } + false } /// Poll a file descriptor for readability with a timeout in milliseconds. @@ -296,7 +368,7 @@ pub(super) fn poll_fd(fd: i32, timeout_ms: i32) -> i32 { let ret = unsafe { libc::poll(&mut pfd, 1, timeout_ms) }; if ret < 0 { - let errno = unsafe { *libc::__errno_location() }; + let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0); if errno == libc::EINTR { return 0; } diff --git a/src/crates/netipc/src/service/raw/server_session_windows.rs b/src/crates/netipc/src/service/raw/server_session_windows.rs index 65d1ec37..dd8bdbc5 100644 --- a/src/crates/netipc/src/service/raw/server_session_windows.rs +++ b/src/crates/netipc/src/service/raw/server_session_windows.rs @@ -1,17 +1,17 @@ -use super::common::{ensure_client_scratch, SERVER_POLL_TIMEOUT_MS}; +use super::common::ensure_client_scratch; use super::dispatch::{ - dispatch_single_internal, method_supported_internal, server_note_payload_capacity, - DispatchError, DispatchHandler, + DispatchError, DispatchHandler, dispatch_single_internal, method_supported_internal, + server_note_payload_capacity, }; use crate::protocol::{ - self, batch_item_get, BatchBuilder, Header, FLAG_BATCH, HEADER_SIZE, KIND_REQUEST, - KIND_RESPONSE, MAGIC_MSG, STATUS_BAD_ENVELOPE, STATUS_INTERNAL_ERROR, STATUS_LIMIT_EXCEEDED, - STATUS_OK, VERSION, + self, BatchBuilder, FLAG_BATCH, HEADER_SIZE, Header, KIND_REQUEST, KIND_RESPONSE, MAGIC_MSG, + STATUS_BAD_ENVELOPE, STATUS_INTERNAL_ERROR, STATUS_LIMIT_EXCEEDED, STATUS_OK, VERSION, + batch_item_get, }; use crate::transport::win_shm::WinShmContext; use crate::transport::windows::NpSession; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; /// Windows: handle one client session over Named Pipe + optional Win SHM. /// Standalone function for use in per-session threads. @@ -21,6 +21,7 @@ pub(super) fn handle_session_win_threaded( expected_method_code: u16, handler: Option, running: Arc, + poll_ms: u32, learned_request_payload_bytes: Arc, learned_response_payload_bytes: Arc, request_payload_growth_ceiling: u32, @@ -34,7 +35,7 @@ pub(super) fn handle_session_win_threaded( while running.load(Ordering::Acquire) { let (hdr, payload) = { if let Some(ref mut shm_ctx) = shm { - match shm_ctx.receive(&mut recv_buf, SERVER_POLL_TIMEOUT_MS) { + match shm_ctx.receive(&mut recv_buf, poll_ms) { Ok(mlen) => { if mlen < HEADER_SIZE { break; @@ -46,11 +47,18 @@ pub(super) fn handle_session_win_threaded( let payload = &recv_buf[HEADER_SIZE..mlen]; (hdr, payload) } - Err(crate::transport::win_shm::WinShmError::Timeout) => continue, + Err(crate::transport::win_shm::WinShmError::Timeout) => { + // A crashed client never sets the close flag; its + // broken pipe is the only sign it left. + if peer_disconnected(&session) { + break; + } + continue; + } Err(_) => break, } } else { - match session.wait_readable(SERVER_POLL_TIMEOUT_MS) { + match session.wait_readable(poll_ms) { Ok(true) => {} Ok(false) => continue, Err(_) => break, @@ -246,3 +254,13 @@ pub(super) fn handle_session_win_threaded( } session.close(); } + +/// Zero-wait probe of a session pipe. WinSHM sessions keep the pipe open for +/// their whole life and the kernel breaks it when the peer exits, so a broken +/// pipe is the dead-peer signal for both directions. +pub(super) fn peer_disconnected(session: &crate::transport::windows::NpSession) -> bool { + matches!( + session.wait_readable(0), + Err(crate::transport::windows::NpError::Disconnected) + ) +} diff --git a/src/crates/netipc/src/service/raw/server_unix.rs b/src/crates/netipc/src/service/raw/server_unix.rs index 40dc76e5..f2c72858 100644 --- a/src/crates/netipc/src/service/raw/server_unix.rs +++ b/src/crates/netipc/src/service/raw/server_unix.rs @@ -1,25 +1,49 @@ -use super::common::SERVER_POLL_TIMEOUT_MS; +use super::common::wait_for_worker_slot; +#[cfg(test)] +use super::server::TestHooks; use super::server::{ManagedServer, ServerConfig}; use super::server_session_unix::{handle_session_threaded, poll_fd}; use crate::protocol::NipcError; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] use crate::protocol::{HEADER_SIZE, PROFILE_SHM_FUTEX, PROFILE_SHM_HYBRID}; -use crate::transport::posix::UdsListener; -#[cfg(target_os = "linux")] +use crate::transport::posix::{PendingConnection, UdsListener, UdsSession}; +#[cfg(any(target_os = "linux", target_os = "freebsd"))] use crate::transport::shm::ShmContext; -use std::sync::atomic::Ordering; +use std::os::unix::io::RawFd; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// Session socket of a running worker, published so a stopping server can +/// shut it down. The worker clears it before closing the socket, so a +/// shutdown never reaches a reused descriptor. +#[derive(Default)] +struct WorkerSocket(Mutex>); + +struct Worker { + thread: std::thread::JoinHandle<()>, + socket: Arc, +} impl ManagedServer { - /// Run the acceptor loop. Blocking. Accepts clients, spawns a - /// thread per session (up to worker_count concurrent sessions). + /// Run the acceptor loop. Blocking. Accepts clients and runs each + /// connection, handshake included, in its own thread (up to + /// worker_count); a connection over the limit is closed before + /// HELLO_ACK. /// - /// Returns when `stop()` is called or on fatal error. + /// Returns when `stop()` is called or on fatal error. A stop before + /// run makes run return at once; the server is single-use. #[cfg(unix)] pub fn run(&mut self) -> Result<(), NipcError> { - #[cfg(target_os = "linux")] + // A stop before run must win; the server is single-use. + if self.stop_requested.load(Ordering::Acquire) { + return Ok(()); + } + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] crate::transport::shm::cleanup_stale(&self.run_dir, &self.service_name); - let listener = UdsListener::bind( + let mut listener = UdsListener::bind( &self.run_dir, &self.service_name, self.server_config.clone(), @@ -27,32 +51,28 @@ impl ManagedServer { .map_err(|_| NipcError::BadLayout)?; self.running.store(true, Ordering::Release); + if self.stop_requested.load(Ordering::Acquire) { + self.running.store(false, Ordering::Release); + } + self.listening.set(); - let mut session_threads: Vec> = Vec::new(); + let mut workers: Vec = Vec::new(); while self.running.load(Ordering::Acquire) { - let ready = poll_fd(listener.fd(), SERVER_POLL_TIMEOUT_MS as i32); + let ready = poll_fd(listener.fd(), self.timings.poll_ms as i32); if ready < 0 { break; } if ready == 0 { - session_threads.retain(|t| !t.is_finished()); - continue; - } - - let (session_id, accept_cfg, precreated_shm, ready) = self.prepare_unix_accept(); - if !ready { - std::thread::sleep(std::time::Duration::from_millis(10)); + workers.retain(|w| !w.thread.is_finished()); continue; } - let session = match listener.accept_with_config(session_id, accept_cfg) { - Ok(s) => s, + // The handshake runs on the worker, so a client that never sends + // HELLO cannot stall this loop. + let pending = match listener.accept_pending() { + Ok(p) => p, Err(_) => { - #[cfg(target_os = "linux")] - if let Some(mut shm) = precreated_shm { - shm.destroy(); - } if !self.running.load(Ordering::Acquire) { break; } @@ -61,108 +81,236 @@ impl ManagedServer { } }; - session_threads.retain(|t| !t.is_finished()); - if session_threads.len() >= self.worker_count { - #[cfg(target_os = "linux")] - if let Some(mut shm) = precreated_shm { - shm.destroy(); - } - drop(session); + // A connection holds its slot from accept, handshake included. + // Refuse before HELLO_ACK so the client's connect fails instead of + // a later call. + let worker_count = self.worker_count; + if !wait_for_worker_slot(&self.running, self.timings.slot_wait(), || { + workers.retain(|w| !w.thread.is_finished()); + workers.len() < worker_count + }) { + drop(pending); continue; } - #[cfg(target_os = "linux")] - let shm = match self.finalize_unix_shm(&session, precreated_shm) { - Some(shm) => Some(shm), - None if session.selected_profile == PROFILE_SHM_HYBRID - || session.selected_profile == PROFILE_SHM_FUTEX => - { - drop(session); - continue; - } - None => None, + let session_id = self.next_session_id; + self.next_session_id += 1; + let accept = AcceptContext { + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + run_dir: self.run_dir.clone(), + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + service_name: self.service_name.clone(), + base_config: self.server_config.clone(), + session_id, + handshake_ms: self.timings.handshake_ms, + #[cfg(all(test, any(target_os = "linux", target_os = "freebsd")))] + hooks: self.test_hooks.clone(), }; - #[cfg(not(target_os = "linux"))] - let shm: Option<()> = None; - + let socket = Arc::new(WorkerSocket::default()); + let worker_socket = socket.clone(); let expected_method_code = self.expected_method_code; let handler = self.handler.clone(); let running = self.running.clone(); + let poll_ms = self.timings.poll_ms; + #[cfg(test)] + let hooks = self.test_hooks.clone(); + let stop_requested = self.stop_requested.clone(); let learned_request_payload_bytes = self.learned_request_payload_bytes.clone(); let learned_response_payload_bytes = self.learned_response_payload_bytes.clone(); let request_payload_growth_ceiling = self.request_payload_growth_ceiling; let response_payload_growth_ceiling = self.response_payload_growth_ceiling; - let t = std::thread::spawn(move || { + let thread = std::thread::spawn(move || { + #[cfg(test)] + let _exit = SessionExitNotice(hooks.session_exited.clone()); + #[cfg(test)] + TestHooks::notify(&hooks.handshake_started); + let Some((mut session, shm)) = accept.handshake( + pending, + &stop_requested, + &learned_request_payload_bytes, + &learned_response_payload_bytes, + ) else { + return; + }; + *worker_socket.0.lock().unwrap() = Some(session.fd()); + handle_session_threaded( - session, - #[cfg(target_os = "linux")] - shm, - #[cfg(not(target_os = "linux"))] + &mut session, shm, expected_method_code, handler, running, + poll_ms, learned_request_payload_bytes, learned_response_payload_bytes, request_payload_growth_ceiling, response_payload_growth_ceiling, ); + + let mut published = worker_socket.0.lock().unwrap(); + *published = None; + drop(session); }); - session_threads.push(t); + workers.push(Worker { thread, socket }); } - for t in session_threads { - let _ = t.join(); - } + self.stop_requested.store(true, Ordering::Release); + self.running.store(false, Ordering::Release); + // Close the socket now; the endpoint lock drops with the listener, + // after the workers have exited. + listener.stop_listening(); + stop_workers(workers, self.timings.stop_grace); + drop(listener); Ok(()) } +} - #[cfg(target_os = "linux")] - fn prepare_unix_accept(&mut self) -> (u64, ServerConfig, Option, bool) { - let session_id = self.next_session_id; - self.next_session_id += 1; - - let mut cfg = self.server_config.clone(); - cfg.max_request_payload_bytes = self.learned_request_payload_bytes.load(Ordering::Acquire); - cfg.max_response_payload_bytes = - self.learned_response_payload_bytes.load(Ordering::Acquire); +/// Give workers a grace to finish their current message, then shut down the +/// sockets of the ones still blocked in send/recv, and join them all. +fn stop_workers(workers: Vec, grace: Duration) { + let deadline = Instant::now() + grace; + while Instant::now() < deadline && workers.iter().any(|w| !w.thread.is_finished()) { + std::thread::sleep(Duration::from_millis(5)); + } - let shm_profiles = cfg.supported_profiles & (PROFILE_SHM_HYBRID | PROFILE_SHM_FUTEX); - if shm_profiles == 0 { - return (session_id, cfg, None, true); + for w in &workers { + if w.thread.is_finished() { + continue; } - - match ShmContext::server_create( - &self.run_dir, - &self.service_name, - session_id, - cfg.max_request_payload_bytes + HEADER_SIZE as u32, - cfg.max_response_payload_bytes + HEADER_SIZE as u32, - ) { - Ok(ctx) => (session_id, cfg, Some(ctx), true), - Err(_) => { - cfg.supported_profiles &= !(PROFILE_SHM_HYBRID | PROFILE_SHM_FUTEX); - cfg.preferred_profiles &= !(PROFILE_SHM_HYBRID | PROFILE_SHM_FUTEX); - (session_id, cfg.clone(), None, cfg.supported_profiles != 0) + if let Some(fd) = *w.socket.0.lock().unwrap() { + unsafe { + libc::shutdown(fd, libc::SHUT_RDWR); } } } - #[cfg(target_os = "linux")] - fn finalize_unix_shm( - &self, - session: &crate::transport::posix::UdsSession, - mut shm: Option, - ) -> Option { - let profile = session.selected_profile; - if profile != PROFILE_SHM_HYBRID && profile != PROFILE_SHM_FUTEX { - if let Some(ref mut ctx) = shm { - ctx.destroy(); + for w in workers { + let _ = w.thread.join(); + } +} + +/// What a worker needs to run the server side of one handshake. +struct AcceptContext { + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + run_dir: String, + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + service_name: String, + base_config: ServerConfig, + session_id: u64, + handshake_ms: u32, + #[cfg(all(test, any(target_os = "linux", target_os = "freebsd")))] + hooks: TestHooks, +} + +/// Reports a session worker's exit, after its socket closed, however the +/// worker returns. +#[cfg(test)] +struct SessionExitNotice(Option>); + +#[cfg(test)] +impl Drop for SessionExitNotice { + fn drop(&mut self) { + TestHooks::notify(&self.0); + } +} + +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +type WorkerShm = Option; +#[cfg(not(any(target_os = "linux", target_os = "freebsd")))] +type WorkerShm = Option<()>; + +impl AcceptContext { + /// Run the handshake with the per-session config; the session's SHM + /// region is created inside it, only once SHM is selected. Returns + /// `None` if the session must not start. + fn handshake( + self, + pending: PendingConnection, + stop_requested: &AtomicBool, + learned_request_payload_bytes: &AtomicU32, + learned_response_payload_bytes: &AtomicU32, + ) -> Option<(UdsSession, WorkerShm)> { + let mut cfg = self.base_config.clone(); + cfg.max_request_payload_bytes = learned_request_payload_bytes.load(Ordering::Acquire); + cfg.max_response_payload_bytes = learned_response_payload_bytes.load(Ordering::Acquire); + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + { + let mut shm: WorkerShm = None; + let result = { + let mut prepare = |profile: u32, request: u32, response: u32| -> bool { + if profile != PROFILE_SHM_HYBRID && profile != PROFILE_SHM_FUTEX { + return true; + } + shm = self.create_shm(request, response); + shm.is_some() + }; + pending.handshake_prepared( + self.session_id, + &cfg, + self.handshake_ms, + Some(stop_requested), + &mut prepare, + ) + }; + let session = match result { + Ok(session) => session, + Err(_) => { + if let Some(mut shm) = shm { + shm.destroy(); + } + return None; + } + }; + + let profile = session.selected_profile; + let wants_shm = profile == PROFILE_SHM_HYBRID || profile == PROFILE_SHM_FUTEX; + match (wants_shm, shm) { + (true, Some(shm)) => Some((session, Some(shm))), + // SHM profile guarantee: the prepare step created the region + // before the ACK selected SHM. + (true, None) => None, + (false, shm) => { + if let Some(mut shm) = shm { + shm.destroy(); + } + Some((session, None)) + } } + } + #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] + { + let session = pending + .handshake( + self.session_id, + &cfg, + self.handshake_ms, + Some(stop_requested), + ) + .ok()?; + Some((session, None)) + } + } + + /// Create the session's SHM region for the agreed payload limits. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn create_shm(&self, request_payload: u32, response_payload: u32) -> Option { + #[cfg(test)] + if self.hooks.fail_shm_create.load(Ordering::Acquire) { return None; } - shm + let ctx = ShmContext::server_create( + &self.run_dir, + &self.service_name, + self.session_id, + request_payload.checked_add(HEADER_SIZE as u32)?, + response_payload.checked_add(HEADER_SIZE as u32)?, + ) + .ok()?; + #[cfg(test)] + TestHooks::notify(&self.hooks.shm_created); + Some(ctx) } } diff --git a/src/crates/netipc/src/service/raw/server_windows.rs b/src/crates/netipc/src/service/raw/server_windows.rs index 5be509ab..1bc329ed 100644 --- a/src/crates/netipc/src/service/raw/server_windows.rs +++ b/src/crates/netipc/src/service/raw/server_windows.rs @@ -1,16 +1,31 @@ +use super::common::wait_for_worker_slot; +#[cfg(test)] +use super::server::TestHooks; use super::server::{ManagedServer, ServerConfig}; use super::server_session_windows::handle_session_win_threaded; -use crate::protocol::{NipcError, HEADER_SIZE}; +use crate::protocol::{HEADER_SIZE, NipcError}; use crate::transport::win_shm::{ - WinShmContext, PROFILE_BUSYWAIT as WIN_SHM_PROFILE_BUSYWAIT, - PROFILE_HYBRID as WIN_SHM_PROFILE_HYBRID, + PROFILE_BUSYWAIT as WIN_SHM_PROFILE_BUSYWAIT, PROFILE_HYBRID as WIN_SHM_PROFILE_HYBRID, + WinShmContext, }; -use crate::transport::windows::{NpListener, NpSession}; -use std::sync::atomic::Ordering; +use crate::transport::windows::{NpListener, NpSession, PendingPipe}; +use std::os::windows::io::AsRawHandle; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +unsafe extern "system" { + fn CancelSynchronousIo(thread: isize) -> i32; +} impl ManagedServer { /// Windows: run the acceptor loop over Named Pipes. pub fn run(&mut self) -> Result<(), NipcError> { + // A stop before run must win; the server is single-use. + if self.stop_requested.load(Ordering::Acquire) { + return Ok(()); + } + let mut listener = NpListener::bind( &self.run_dir, &self.service_name, @@ -18,29 +33,22 @@ impl ManagedServer { ) .map_err(|_| NipcError::BadLayout)?; - self.remember_windows_listener_handle(&listener); + *self.listener_waker.lock().unwrap() = Some(listener.waker()); self.running.store(true, Ordering::Release); + if self.stop_requested.load(Ordering::Acquire) { + self.running.store(false, Ordering::Release); + } + self.listening.set(); - let mut session_threads: Vec> = Vec::new(); + let mut session_threads: Vec> = Vec::new(); while self.running.load(Ordering::Acquire) { - let (session_id, accept_cfg, prepared_shm, ready) = self.prepare_windows_accept(); - if !ready { - std::thread::sleep(std::time::Duration::from_millis(10)); - continue; - } - - self.remember_windows_listener_handle(&listener); - let accepted = listener.accept_with_config(session_id, accept_cfg); - self.remember_windows_listener_handle(&listener); - - let session = match accepted { - Ok(s) => s, + // The handshake runs on the worker, so a client that never sends + // HELLO cannot stall this loop. + let pending = match listener.accept_pending() { + Ok(p) => p, Err(_) => { - if let Some(mut prepared) = prepared_shm { - prepared.destroy_all(); - } if !self.running.load(Ordering::Acquire) { break; } @@ -49,38 +57,65 @@ impl ManagedServer { } }; - session_threads.retain(|t| !t.is_finished()); - if session_threads.len() >= self.worker_count { - if let Some(mut prepared) = prepared_shm { - prepared.destroy_all(); - } - drop(session); - continue; + // Stop wakes ConnectNamedPipe with a throwaway connection. + if !self.running.load(Ordering::Acquire) { + break; } - let shm = self.finalize_windows_shm(&session, prepared_shm); - if shm.is_none() - && (session.selected_profile == WIN_SHM_PROFILE_HYBRID - || session.selected_profile == WIN_SHM_PROFILE_BUSYWAIT) - { - drop(session); + // A connection holds its slot from accept, handshake included. + // Refuse before HELLO_ACK so the client's connect fails instead of + // a later call. + let worker_count = self.worker_count; + if !wait_for_worker_slot(&self.running, self.timings.slot_wait(), || { + session_threads.retain(|t| !t.is_finished()); + session_threads.len() < worker_count + }) { + drop(pending); continue; } + let session_id = self.next_session_id; + self.next_session_id += 1; + let accept = AcceptContext { + run_dir: self.run_dir.clone(), + service_name: self.service_name.clone(), + base_config: self.server_config.clone(), + session_id, + handshake_ms: self.timings.handshake_ms, + #[cfg(test)] + hooks: self.test_hooks.clone(), + }; let expected_method_code = self.expected_method_code; let handler = self.handler.clone(); let running = self.running.clone(); + let poll_ms = self.timings.poll_ms; + #[cfg(test)] + let hooks = self.test_hooks.clone(); + let stop_requested = self.stop_requested.clone(); let learned_request_payload_bytes = self.learned_request_payload_bytes.clone(); let learned_response_payload_bytes = self.learned_response_payload_bytes.clone(); let request_payload_growth_ceiling = self.request_payload_growth_ceiling; let response_payload_growth_ceiling = self.response_payload_growth_ceiling; let t = std::thread::spawn(move || { + #[cfg(test)] + let _exit = SessionExitNotice(hooks.session_exited.clone()); + #[cfg(test)] + TestHooks::notify(&hooks.handshake_started); + let Some((session, shm)) = accept.handshake( + pending, + &stop_requested, + &learned_request_payload_bytes, + &learned_response_payload_bytes, + ) else { + return; + }; handle_session_win_threaded( session, shm, expected_method_code, handler, running, + poll_ms, learned_request_payload_bytes, learned_response_payload_bytes, request_payload_growth_ceiling, @@ -90,130 +125,142 @@ impl ManagedServer { session_threads.push(t); } - *self.listener_handle.lock().unwrap() = None; - - for t in session_threads { - let _ = t.join(); - } + *self.listener_waker.lock().unwrap() = None; + self.stop_requested.store(true, Ordering::Release); + self.running.store(false, Ordering::Release); + drop(listener); + stop_workers(session_threads, self.timings.stop_grace); Ok(()) } +} - fn remember_windows_listener_handle(&self, listener: &NpListener) { - let handle = listener.handle(); - let stored = if handle == 0 || handle == -1 { - None - } else { - Some(handle as usize) - }; - *self.listener_handle.lock().unwrap() = stored; +/// Give workers a grace to finish their current message, then cancel the +/// pipe I/O of the ones still blocked until they exit, and join them all. A +/// single cancel can land before a thread enters its next blocking call, so +/// it repeats. +fn stop_workers(workers: Vec>, grace: Duration) { + let deadline = Instant::now() + grace; + while Instant::now() < deadline && workers.iter().any(|t| !t.is_finished()) { + std::thread::sleep(Duration::from_millis(5)); } - fn prepare_windows_accept(&mut self) -> (u64, ServerConfig, Option, bool) { - let session_id = self.next_session_id; - self.next_session_id += 1; - - let mut cfg = self.server_config.clone(); - cfg.max_request_payload_bytes = self.learned_request_payload_bytes.load(Ordering::Acquire); - cfg.max_response_payload_bytes = - self.learned_response_payload_bytes.load(Ordering::Acquire); - - let shm_profiles = - cfg.supported_profiles & (WIN_SHM_PROFILE_HYBRID | WIN_SHM_PROFILE_BUSYWAIT); - if shm_profiles == 0 { - return (session_id, cfg, None, true); - } - - let mut prepared = PreparedWinShm::default(); - for profile in [WIN_SHM_PROFILE_HYBRID, WIN_SHM_PROFILE_BUSYWAIT] { - if cfg.supported_profiles & profile == 0 { - continue; - } - - match WinShmContext::server_create( - &self.run_dir, - &self.service_name, - self.server_config.auth_token, - session_id, - profile, - cfg.max_request_payload_bytes + HEADER_SIZE as u32, - cfg.max_response_payload_bytes + HEADER_SIZE as u32, - ) { - Ok(ctx) => prepared.insert(profile, ctx), - Err(_) => { - cfg.supported_profiles &= !profile; - cfg.preferred_profiles &= !profile; - } + while workers.iter().any(|t| !t.is_finished()) { + for t in workers.iter().filter(|t| !t.is_finished()) { + // The JoinHandle keeps the thread handle valid until join. + unsafe { + CancelSynchronousIo(t.as_raw_handle() as isize); } } - - if cfg.supported_profiles == 0 { - prepared.destroy_all(); - return (session_id, cfg, None, false); - } - - if prepared.is_empty() { - return (session_id, cfg, None, true); - } - - (session_id, cfg, Some(prepared), true) + std::thread::sleep(Duration::from_millis(10)); } - fn finalize_windows_shm( - &self, - session: &NpSession, - mut prepared: Option, - ) -> Option { - let profile = session.selected_profile; - if profile != WIN_SHM_PROFILE_HYBRID && profile != WIN_SHM_PROFILE_BUSYWAIT { - if let Some(ref mut prepared) = prepared { - prepared.destroy_all(); - } - return None; - } - let mut prepared = prepared?; - // Keep the negotiated context and destroy every unused prepared context. - let selected = prepared.take(profile); - prepared.destroy_all(); - selected + for t in workers { + let _ = t.join(); } } -#[derive(Default)] -struct PreparedWinShm { - hybrid: Option, - busywait: Option, +/// What a worker needs to run the server side of one handshake. +struct AcceptContext { + run_dir: String, + service_name: String, + base_config: ServerConfig, + session_id: u64, + handshake_ms: u32, + #[cfg(test)] + hooks: TestHooks, } -impl PreparedWinShm { - fn insert(&mut self, profile: u32, ctx: WinShmContext) { - if profile == WIN_SHM_PROFILE_HYBRID { - self.hybrid = Some(ctx); - } else if profile == WIN_SHM_PROFILE_BUSYWAIT { - self.busywait = Some(ctx); - } - } +/// Reports a session worker's exit, after its pipe closed, however the +/// worker returns. +#[cfg(test)] +struct SessionExitNotice(Option>); - fn take(&mut self, profile: u32) -> Option { - if profile == WIN_SHM_PROFILE_HYBRID { - self.hybrid.take() - } else if profile == WIN_SHM_PROFILE_BUSYWAIT { - self.busywait.take() - } else { - None - } +#[cfg(test)] +impl Drop for SessionExitNotice { + fn drop(&mut self) { + TestHooks::notify(&self.0); } +} - fn destroy_all(&mut self) { - if let Some(mut ctx) = self.hybrid.take() { - ctx.destroy(); - } - if let Some(mut ctx) = self.busywait.take() { - ctx.destroy(); +impl AcceptContext { + /// Run the handshake with the per-session config; the session's SHM + /// objects are created inside it, only for the selected SHM profile. + /// Returns `None` if the session must not start. + fn handshake( + self, + pending: PendingPipe, + stop_requested: &AtomicBool, + learned_request_payload_bytes: &AtomicU32, + learned_response_payload_bytes: &AtomicU32, + ) -> Option<(NpSession, Option)> { + let mut cfg = self.base_config.clone(); + cfg.max_request_payload_bytes = learned_request_payload_bytes.load(Ordering::Acquire); + cfg.max_response_payload_bytes = learned_response_payload_bytes.load(Ordering::Acquire); + + let mut shm: Option = None; + let result = { + let mut prepare = |profile: u32, request: u32, response: u32| -> bool { + if profile != WIN_SHM_PROFILE_HYBRID && profile != WIN_SHM_PROFILE_BUSYWAIT { + return true; + } + shm = self.create_shm(profile, request, response); + shm.is_some() + }; + pending.handshake_prepared( + self.session_id, + &cfg, + self.handshake_ms, + Some(stop_requested), + &mut prepare, + ) + }; + let session = match result { + Ok(session) => session, + Err(_) => { + if let Some(mut shm) = shm { + shm.destroy(); + } + return None; + } + }; + + let profile = session.selected_profile; + if profile != WIN_SHM_PROFILE_HYBRID && profile != WIN_SHM_PROFILE_BUSYWAIT { + if let Some(mut shm) = shm { + shm.destroy(); + } + return Some((session, None)); } + // SHM profile guarantee: the prepare step created the selected + // profile's objects before the ACK selected it. + shm.map(|shm| (session, Some(shm))) } - fn is_empty(&self) -> bool { - self.hybrid.is_none() && self.busywait.is_none() + /// Create the session's SHM objects for one profile and the agreed + /// payload limits. + fn create_shm( + &self, + profile: u32, + request_payload: u32, + response_payload: u32, + ) -> Option { + #[cfg(test)] + if self.hooks.fail_shm_create.load(Ordering::Acquire) { + return None; + } + let ctx = WinShmContext::server_create( + &self.run_dir, + &self.service_name, + self.base_config.auth_token, + self.session_id, + profile, + request_payload.checked_add(HEADER_SIZE as u32)?, + response_payload.checked_add(HEADER_SIZE as u32)?, + ) + .ok()?; + #[cfg(test)] + TestHooks::notify(&self.hooks.shm_created); + Some(ctx) } } diff --git a/src/crates/netipc/src/service/raw/string_reverse.rs b/src/crates/netipc/src/service/raw/string_reverse.rs index 3c168357..3b3120d8 100644 --- a/src/crates/netipc/src/service/raw/string_reverse.rs +++ b/src/crates/netipc/src/service/raw/string_reverse.rs @@ -1,8 +1,8 @@ use super::client::{ClientConfig, RawCallKind, RawClient}; use super::dispatch::{DispatchError, DispatchHandler}; use crate::protocol::{ - self, string_reverse_decode, string_reverse_encode, NipcError, METHOD_STRING_REVERSE, - STRING_REVERSE_HDR_SIZE, + self, METHOD_STRING_REVERSE, NipcError, STRING_REVERSE_HDR_SIZE, string_reverse_decode, + string_reverse_encode, }; use std::sync::Arc; diff --git a/src/crates/netipc/src/service/raw_lookup_dispatch_tests.rs b/src/crates/netipc/src/service/raw_lookup_dispatch_tests.rs index 30a38c8a..524aa4d4 100644 --- a/src/crates/netipc/src/service/raw_lookup_dispatch_tests.rs +++ b/src/crates/netipc/src/service/raw_lookup_dispatch_tests.rs @@ -1,8 +1,8 @@ -use super::{apps_lookup_dispatch, cgroups_lookup_dispatch, ClientState, DispatchError, RawClient}; +use super::{ClientState, DispatchError, RawClient, apps_lookup_dispatch, cgroups_lookup_dispatch}; use crate::protocol::{ - self, AppsLookupBuilder, CgroupsLookupBuilder, APPS_CGROUP_KNOWN, APPS_LOOKUP_KEY_SIZE, - APPS_LOOKUP_REQ_HDR_SIZE, APPS_LOOKUP_RESP_HDR_SIZE, CGROUPS_LOOKUP_REQ_HDR_SIZE, - CGROUPS_LOOKUP_RESP_HDR_SIZE, CGROUP_LOOKUP_KNOWN, LOOKUP_DIR_ENTRY_SIZE, NIPC_UID_UNSET, + self, APPS_CGROUP_KNOWN, APPS_LOOKUP_KEY_SIZE, APPS_LOOKUP_REQ_HDR_SIZE, + APPS_LOOKUP_RESP_HDR_SIZE, AppsLookupBuilder, CGROUP_LOOKUP_KNOWN, CGROUPS_LOOKUP_REQ_HDR_SIZE, + CGROUPS_LOOKUP_RESP_HDR_SIZE, CgroupsLookupBuilder, LOOKUP_DIR_ENTRY_SIZE, NIPC_UID_UNSET, ORCHESTRATOR_DOCKER, ORCHESTRATOR_K8S, PID_LOOKUP_KNOWN, }; #[cfg(unix)] @@ -122,7 +122,10 @@ fn request_capacity_growth_honors_abort_before_reconnect() { client.abort(); assert_eq!( - client.ensure_lookup_request_capacity(protocol::MAX_PAYLOAD_DEFAULT as usize + 1), + client.ensure_lookup_request_capacity( + protocol::MAX_PAYLOAD_DEFAULT as usize + 1, + std::time::Instant::now() + std::time::Duration::from_secs(1), + ), Err(protocol::NipcError::Aborted) ); assert_eq!(client.state, ClientState::Broken); diff --git a/src/crates/netipc/src/service/raw_robustness_unix_tests.rs b/src/crates/netipc/src/service/raw_robustness_unix_tests.rs new file mode 100644 index 00000000..ba7bf45c --- /dev/null +++ b/src/crates/netipc/src/service/raw_robustness_unix_tests.rs @@ -0,0 +1,967 @@ +//! Robustness reproduction tests (SOW-0040). +//! +//! Each test asserts the correct behavior and runs every blocking step on a +//! helper thread under a watchdog, so a regression fails instead of hanging. +//! Bounds are deliberately lenient so they do not dictate a fix design. + +use super::*; +use crate::protocol::{METHOD_STRING_REVERSE, STRING_REVERSE_HDR_SIZE, string_reverse_encode}; +use std::io::{BufRead, BufReader}; +use std::process::{Child, Command, Stdio}; + +const HELPER_ENV: &str = "NETIPC_ROBUSTNESS_HELPER"; +const HELPER_TEST: &str = "service::raw::tests::robustness::robustness_helper_process"; + +/// Run `f` on a thread; return its result, or None if it did not finish in time. +/// On timeout the thread is left running; callers release whatever blocks it. +fn within( + limit: Duration, + f: impl FnOnce() -> T + Send + 'static, +) -> (Option, thread::JoinHandle<()>) { + let (tx, rx) = mpsc::channel(); + let handle = thread::spawn(move || { + let _ = tx.send(f()); + }); + (rx.recv_timeout(limit).ok(), handle) +} + +/// Connect and complete one increment call. The server listens before tests +/// connect and a slot is free by then, so one attempt must succeed. +fn served_once(service: &str, config: ClientConfig) -> Result<(), String> { + let mut client = increment_client(service, config); + client.refresh(); + let result = if client.ready() { + match client.call_increment_with_timeout(1, 1000) { + Ok(2) => Ok(()), + Ok(v) => Err(format!("unexpected value {v}")), + Err(e) => Err(format!("call error {e:?}")), + } + } else { + Err(format!("state {:?}", client.state)) + }; + client.close(); + result +} + +/// Increment handler that reports entry, then blocks until its process is +/// killed. +fn blocking_increment_dispatch(entered: impl Fn() + Send + Sync + 'static) -> DispatchHandler { + increment_dispatch(Arc::new(move |_v| { + entered(); + loop { + thread::park(); + } + })) +} + +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn shm_files(service: &str) -> Vec { + let prefix = format!("{service}-"); + std::fs::read_dir(TEST_RUN_DIR) + .map(|rd| { + rd.filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with(&prefix) && n.ends_with(".ipcshm")) + .collect() + }) + .unwrap_or_default() +} + +/// Connect a raw SOCK_SEQPACKET socket to the service without sending HELLO. +fn silent_connect(service: &str) -> RawFd { + let path = socket_path(service); + let bytes = path.as_os_str().as_bytes(); + let fd = unsafe { libc::socket(libc::AF_UNIX, crate::transport::posix::SOCK_TYPE, 0) }; + assert!(fd >= 0, "socket: {}", std::io::Error::last_os_error()); + let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() }; + addr.sun_family = libc::AF_UNIX as libc::sa_family_t; + for (i, b) in bytes.iter().enumerate() { + addr.sun_path[i] = *b as libc::c_char; + } + let rc = unsafe { + libc::connect( + fd, + &addr as *const libc::sockaddr_un as *const libc::sockaddr, + std::mem::size_of::() as libc::socklen_t, + ) + }; + assert_eq!(rc, 0, "connect: {}", std::io::Error::last_os_error()); + fd +} + +/// Start a managed server on its own thread with test timings. Dropping or +/// stopping the result stops the server. +fn start_server( + service: &str, + config: ServerConfig, + method: u16, + handler: DispatchHandler, + workers: usize, +) -> server::RunningTestServer { + ensure_run_dir(); + cleanup_all(service); + ManagedServer::with_workers( + TEST_RUN_DIR, + service, + config, + method, + Some(handler), + workers, + ) + .spawn_for_test() +} + +/// Stop `server`; returns whether `run()` returned within `limit`. +fn stop_within(mut server: server::RunningTestServer, limit: Duration) -> bool { + let (stopped, waiter) = within(limit, move || server.stop()); + let finished = stopped.is_some(); + if finished { + let _ = waiter.join(); + } + finished +} + +/// A helper process and the signal it sends when its server handler starts. +struct Helper { + child: Child, + handler_entered: mpsc::Receiver<()>, +} + +fn spawn_helper(mode: &str, service: &str) -> Helper { + let mut child = Command::new(std::env::current_exe().expect("current_exe")) + .args([HELPER_TEST, "--exact", "--nocapture", "--test-threads=1"]) + .env(HELPER_ENV, format!("{mode}:{service}")) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn helper"); + let stdout = child.stdout.take().expect("helper stdout"); + let (ready_tx, ready_rx) = mpsc::channel(); + let (entered_tx, handler_entered) = mpsc::channel(); + thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + if line.contains("HELPER_READY") { + let _ = ready_tx.send(()); + } + if line.contains("HELPER_HANDLER_ENTERED") { + let _ = entered_tx.send(()); + } + } + }); + if ready_rx.recv_timeout(server::TEST_WAIT_LIMIT).is_err() { + let _ = child.kill(); + let _ = child.wait(); + panic!("helper {mode} did not become ready"); + } + Helper { + child, + handler_entered, + } +} + +/// Child-process entry point. Inert unless spawned by `spawn_helper`. +#[test] +fn robustness_helper_process() { + let Ok(spec) = std::env::var(HELPER_ENV) else { + return; + }; + let (mode, service) = spec.split_once(':').expect("helper spec"); + match mode { + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + "shm_client" => { + let mut client = increment_client(service, shm_client_config()); + connect_ready(&mut client); + assert_eq!(client.call_increment_with_timeout(1, 2000), Ok(2)); + println!("HELPER_READY"); + // Until the test kills this process. + loop { + thread::park(); + } + } + "shm_server" | "uds_server" => { + let handler = blocking_increment_dispatch(|| println!("HELPER_HANDLER_ENTERED")); + let config = match mode { + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + "shm_server" => shm_server_config(), + _ => server_config(), + }; + let _server = start_server(service, config, METHOD_INCREMENT, handler, 2); + println!("HELPER_READY"); + loop { + thread::park(); + } + } + other => panic!("unknown helper mode {other}"), + } +} + +// --------------------------------------------------------------------------- +// K1: SHM server must release a session once its client leaves. +// --------------------------------------------------------------------------- + +#[test] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn test_robust_shm_sessions_released_after_clean_close() { + let svc = unique_service("rs_rob_shm_clean"); + let server = start_server( + &svc, + shm_server_config(), + METHOD_INCREMENT, + increment_dispatch_handler(), + 2, + ); + + // Each worker must exit and remove its region once its client closes; + // a leaked session fails wait_session_exits loudly. + let mut failures = Vec::new(); + for i in 0..5 { + if let Err(e) = served_once(&svc, shm_client_config()) { + failures.push(format!("client {i}: {e}")); + } + server.events.wait_session_exits(1); + } + let leftover = shm_files(&svc); + + stop_within(server, Duration::from_secs(5)); + cleanup_all(&svc); + assert!( + failures.is_empty(), + "with 2 workers, sequential SHM clients must all be served: {failures:?}" + ); + assert!(leftover.is_empty(), "SHM regions leaked: {leftover:?}"); +} + +#[test] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn test_robust_shm_sessions_released_after_client_crash() { + let svc = unique_service("rs_rob_shm_crash"); + let server = start_server( + &svc, + shm_server_config(), + METHOD_INCREMENT, + increment_dispatch_handler(), + 2, + ); + + for _ in 0..2 { + let mut helper = spawn_helper("shm_client", &svc); + let _ = helper.child.kill(); // SIGKILL + let _ = helper.child.wait(); + } + // Both workers must notice their crashed clients and free the slots. + server.events.wait_session_exits(2); + let served = served_once(&svc, shm_client_config()); + server.events.wait_session_exits(1); + let leftover = shm_files(&svc); + + stop_within(server, Duration::from_secs(5)); + cleanup_all(&svc); + assert!( + served.is_ok(), + "after 2 crashed SHM clients on 2 workers, a new client must be served: {served:?}" + ); + assert!(leftover.is_empty(), "SHM regions leaked: {leftover:?}"); +} + +// --------------------------------------------------------------------------- +// K3: a client that connects but never sends HELLO must not block others or +// shutdown. +// --------------------------------------------------------------------------- + +#[test] +fn test_robust_silent_client_does_not_block_other_clients() { + let svc = unique_service("rs_rob_silent"); + let server = start_server( + &svc, + server_config(), + METHOD_INCREMENT, + increment_dispatch_handler(), + 4, + ); + + let silent = silent_connect(&svc); + server.events.wait_handshakes_started(1); + let svc2 = svc.clone(); + let (served, worker) = within(Duration::from_secs(5), move || { + served_once(&svc2, client_config()) + }); + + unsafe { libc::close(silent) }; + let _ = worker.join(); + stop_within(server, Duration::from_secs(5)); + cleanup_all(&svc); + assert!( + matches!(served, Some(Ok(()))), + "a legitimate client must be served within 5s while a silent client is connected: {served:?}" + ); +} + +/// A silent client holds a worker only until the server handshake deadline. +#[test] +fn test_robust_silent_client_released_at_handshake_deadline() { + let svc = unique_service("rs_rob_silent_deadline"); + let server = start_server( + &svc, + server_config(), + METHOD_INCREMENT, + increment_dispatch_handler(), + 1, + ); + + let silent = silent_connect(&svc); + let start = Instant::now(); + server.events.wait_handshakes_started(1); + server.events.wait_session_exits(1); + let elapsed = start.elapsed(); + let served = served_once(&svc, client_config()); + + let mut ch = 0u8; + let n = unsafe { libc::recv(silent, (&mut ch as *mut u8).cast(), 1, libc::MSG_DONTWAIT) }; + unsafe { libc::close(silent) }; + stop_within(server, Duration::from_secs(5)); + cleanup_all(&svc); + let deadline = server::TEST_SERVER_TIMINGS.handshake_ms; + assert!( + elapsed >= Duration::from_millis(u64::from(deadline)), + "silent client released after {elapsed:?}, before the {deadline} ms handshake deadline" + ); + assert!( + served.is_ok(), + "the only worker must be freed once the silent client misses the handshake deadline: {served:?}" + ); + assert_eq!( + n, 0, + "server must close the silent connection at the deadline" + ); +} + +#[test] +fn test_robust_silent_client_does_not_block_shutdown() { + let svc = unique_service("rs_rob_silent_stop"); + let server = start_server( + &svc, + server_config(), + METHOD_INCREMENT, + increment_dispatch_handler(), + 4, + ); + + let silent = silent_connect(&svc); + server.events.wait_handshakes_started(1); + let joined = stop_within(server, Duration::from_secs(3)); + + unsafe { libc::close(silent) }; + cleanup_all(&svc); + assert!( + joined, + "server run() must return within 3s of stop() while a silent client is connected" + ); +} + +// --------------------------------------------------------------------------- +// K4: a client call must be bounded even when the server never answers HELLO. +// --------------------------------------------------------------------------- + +#[test] +fn test_robust_client_bounded_when_server_never_accepts() { + let svc = unique_service("rs_rob_noaccept"); + ensure_run_dir(); + cleanup_all(&svc); + let listener = raw_listener_fd_for_service(&svc); + + let svc2 = svc.clone(); + let start = Instant::now(); + let (result, worker) = within(Duration::from_secs(6), move || { + let mut client = increment_client(&svc2, client_config()); + client.set_call_timeout(1000); + client.refresh(); + let r = client.call_increment_with_timeout(1, 1000); + client.close(); + r + }); + let elapsed = start.elapsed(); + + unsafe { libc::close(listener) }; + let _ = worker.join(); + cleanup_all(&svc); + assert!( + result.is_some(), + "refresh + call with a 1s timeout must return within 6s against a server that never \ + accepts (still blocked after {elapsed:?})" + ); +} + +#[test] +fn test_robust_client_bounded_when_server_never_sends_hello_ack() { + let svc = unique_service("rs_rob_nohelloack"); + ensure_run_dir(); + cleanup_all(&svc); + let listener = raw_listener_fd_for_service(&svc); + let (release_tx, release_rx) = mpsc::channel::<()>(); + let acceptor = thread::spawn(move || { + let fd = unsafe { libc::accept(listener, std::ptr::null_mut(), std::ptr::null_mut()) }; + // Hold the session open without replying until the test releases it. + let _ = release_rx.recv(); + if fd >= 0 { + unsafe { libc::close(fd) }; + } + unsafe { libc::close(listener) }; + }); + + let svc2 = svc.clone(); + let (result, worker) = within(Duration::from_secs(6), move || { + let mut client = increment_client(&svc2, client_config()); + client.set_call_timeout(1000); + client.refresh(); + let r = client.call_increment_with_timeout(1, 1000); + client.close(); + r + }); + + drop(release_tx); + let _ = acceptor.join(); + let _ = worker.join(); + cleanup_all(&svc); + assert!( + result.is_some(), + "refresh + call with a 1s timeout must return within 6s when the server accepts but \ + never sends HELLO_ACK" + ); +} + +/// Accept one connection on `listener` and hold it without replying until +/// the returned sender is dropped. `accepted` fires once a client connected. +fn accept_and_hold( + listener: i32, +) -> (mpsc::Sender<()>, mpsc::Receiver<()>, thread::JoinHandle<()>) { + let (release_tx, release_rx) = mpsc::channel::<()>(); + let (accepted_tx, accepted_rx) = mpsc::channel::<()>(); + let holder = thread::spawn(move || { + let fd = unsafe { libc::accept(listener, std::ptr::null_mut(), std::ptr::null_mut()) }; + let _ = accepted_tx.send(()); + let _ = release_rx.recv(); + if fd >= 0 { + unsafe { libc::close(fd) }; + } + unsafe { libc::close(listener) }; + }); + (release_tx, accepted_rx, holder) +} + +#[test] +fn test_robust_call_reconnect_bounded_by_call_deadline() { + let svc = unique_service("rs_rob_reconnect_noack"); + let server = start_server( + &svc, + server_config(), + METHOD_INCREMENT, + increment_dispatch_handler(), + 1, + ); + let mut client = increment_client(&svc, client_config()); + connect_ready(&mut client); + assert!(stop_within(server, Duration::from_secs(5)), "server stops"); + + // The call fails on the dead session and reconnects to a server that + // accepts but never sends HELLO_ACK. + let (release_tx, _accepted, holder) = accept_and_hold(raw_listener_fd_for_service(&svc)); + let (result, worker) = within(Duration::from_secs(6), move || { + let start = Instant::now(); + let r = client.call_increment_with_timeout(1, 1000); + let elapsed = start.elapsed(); + client.close(); + (r, elapsed) + }); + + drop(release_tx); + let _ = holder.join(); + let _ = worker.join(); + cleanup_all(&svc); + let (r, elapsed) = result.expect("a call with a 1s timeout must return within 6s"); + assert_eq!( + r, + Err(NipcError::Timeout), + "call reports the timeout after {elapsed:?}" + ); +} + +#[test] +fn test_robust_abort_ends_refresh_waiting_for_hello_ack() { + let svc = unique_service("rs_rob_abort_refresh"); + ensure_run_dir(); + cleanup_all(&svc); + let (release_tx, accepted, holder) = accept_and_hold(raw_listener_fd_for_service(&svc)); + + let mut client = increment_client(&svc, client_config()); + // Only an abort can end the wait early. + client.set_call_timeout(10_000); + let abort = client.abort_handle(); + let (worker_tx, worker_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + client.refresh(); + let _ = worker_tx.send(client.ready()); + client.close(); + }); + accepted + .recv_timeout(server::TEST_WAIT_LIMIT) + .expect("server accepted the connection"); + abort.abort(); + let ready = worker_rx.recv_timeout(Duration::from_secs(2)); + + drop(release_tx); + let _ = holder.join(); + let _ = worker.join(); + cleanup_all(&svc); + assert_eq!( + ready, + Ok(false), + "refresh returns within 2s of the abort, not ready" + ); +} + +// --------------------------------------------------------------------------- +// N1: the configured call timeout must be honored on the UDS path. +// --------------------------------------------------------------------------- + +#[test] +fn test_robust_uds_call_honors_timeout_longer_than_100ms() { + let svc = unique_service("rs_rob_slow_handler"); + let handler = increment_dispatch(Arc::new(|v| { + thread::sleep(Duration::from_millis(300)); + Some(v + 1) + })); + let server = start_server(&svc, server_config(), METHOD_INCREMENT, handler, 2); + + let mut client = increment_client(&svc, client_config()); + connect_ready(&mut client); + let start = Instant::now(); + let result = client.call_increment_with_timeout(41, 2000); + let elapsed = start.elapsed(); + client.close(); + + stop_within(server, Duration::from_secs(5)); + cleanup_all(&svc); + assert_eq!( + result, + Ok(42), + "a 300ms handler must succeed with a 2000ms timeout (failed after {elapsed:?})" + ); +} + +// --------------------------------------------------------------------------- +// N2: shutdown must not wait forever on a worker blocked sending a response +// to a client that stopped reading. +// --------------------------------------------------------------------------- + +#[test] +fn test_robust_shutdown_not_blocked_by_non_reading_client() { + const BIG: u32 = 8 * 1024 * 1024; + let svc = unique_service("rs_rob_noread"); + let mut scfg = server_config(); + scfg.max_request_payload_bytes = BIG; + scfg.max_response_payload_bytes = BIG; + // Explicit packet size isolates this test from the default-size issue + // covered by test_robust_default_packet_size_carries_large_message. + scfg.packet_size = 65536; + // After the handler returns, the worker can only be sending the 4 MiB + // response, which blocks because the client never reads. + let (handled_tx, handled_rx) = mpsc::channel(); + let handled_tx = std::sync::Mutex::new(handled_tx); + let reverse = string_reverse_handler(); + let handler = string_reverse_dispatch(Arc::new(move |s: &str| { + let out = reverse(s); + let _ = handled_tx.lock().unwrap().send(()); + out + })); + let server = start_server(&svc, scfg, METHOD_STRING_REVERSE, handler, 2); + + let mut ccfg = client_config(); + ccfg.max_request_payload_bytes = BIG; + ccfg.max_response_payload_bytes = BIG; + ccfg.packet_size = 65536; + let mut session = UdsSession::connect(TEST_RUN_DIR, &svc, &ccfg).expect("connect"); + let text = "x".repeat(4 * 1024 * 1024); + let mut payload = vec![0u8; STRING_REVERSE_HDR_SIZE + text.len() + 1]; + let n = string_reverse_encode(text.as_bytes(), &mut payload); + assert!(n > 0, "encode request"); + let mut hdr = Header { + kind: KIND_REQUEST, + code: METHOD_STRING_REVERSE, + item_count: 1, + message_id: 1, + ..Header::default() + }; + session.send(&mut hdr, &payload[..n]).expect("send request"); + // Never read the response. + handled_rx + .recv_timeout(server::TEST_WAIT_LIMIT) + .expect("string reverse handler ran"); + + let joined = stop_within(server, Duration::from_secs(3)); + + drop(session); + cleanup_all(&svc); + assert!( + joined, + "server run() must return within 3s of stop() while a worker is blocked sending" + ); +} + +// --------------------------------------------------------------------------- +// N3: stop() issued before run() must not be lost. +// --------------------------------------------------------------------------- + +#[test] +fn test_robust_stop_before_run_is_honored() { + let svc = unique_service("rs_rob_stop_first"); + ensure_run_dir(); + cleanup_all(&svc); + let mut server = ManagedServer::with_workers( + TEST_RUN_DIR, + &svc, + server_config(), + METHOD_INCREMENT, + Some(increment_dispatch_handler()), + 2, + ); + server.stop(); + let flag = server.running_flag(); + let (returned, worker) = within(Duration::from_secs(2), move || { + let _ = server.run(); + }); + + if returned.is_none() { + flag.store(false, Ordering::Release); + } + let _ = worker.join(); + cleanup_all(&svc); + assert!( + returned.is_some(), + "run() after stop() must return promptly instead of serving forever" + ); +} + +// --------------------------------------------------------------------------- +// N11: with the auto-detected packet size, a message larger than SO_SNDBUF +// must still be delivered (chunked). +// --------------------------------------------------------------------------- + +#[test] +fn test_robust_default_packet_size_carries_large_message() { + let result = large_round_trip(0); + assert!( + result.is_ok(), + "a 512 KiB request must round-trip with default packet size: {result:?}" + ); +} + +/// Control: the same message with an explicit 64 KiB packet size passes, +/// isolating the failure above to the auto-detected packet size. +#[test] +fn test_robust_explicit_packet_size_carries_large_message() { + let result = large_round_trip(65536); + assert!( + result.is_ok(), + "a 512 KiB request must round-trip with a 64 KiB packet size: {result:?}" + ); +} + +fn large_round_trip(packet_size: u32) -> Result<(), NipcError> { + const BIG: u32 = 1024 * 1024; + let svc = unique_service("rs_rob_bigmsg"); + let mut scfg = server_config(); + scfg.max_request_payload_bytes = BIG; + scfg.max_response_payload_bytes = BIG; + scfg.packet_size = packet_size; + let server = start_server( + &svc, + scfg, + METHOD_STRING_REVERSE, + string_reverse_dispatch_handler(), + 2, + ); + + let mut ccfg = client_config(); + ccfg.max_request_payload_bytes = BIG; + ccfg.max_response_payload_bytes = BIG; + ccfg.packet_size = packet_size; + let mut client = string_reverse_client(&svc, ccfg); + connect_ready(&mut client); + let text = "y".repeat(512 * 1024); + let result = client + .call_string_reverse_with_timeout(&text, 5000) + .map(|v| v.as_str().len()); + client.close(); + + stop_within(server, Duration::from_secs(5)); + cleanup_all(&svc); + match result { + Ok(n) if n == text.len() => Ok(()), + Ok(_) => Err(NipcError::Truncated), + Err(e) => Err(e), + } +} + +// --------------------------------------------------------------------------- +// N5: an SHM client must notice a crashed server instead of waiting out its +// whole timeout. +// --------------------------------------------------------------------------- + +#[test] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn test_robust_shm_client_notices_crashed_server() { + client_notices_crashed_server(true); +} + +#[test] +fn test_robust_uds_client_reports_crashed_server_as_disconnected() { + client_notices_crashed_server(false); +} + +fn client_notices_crashed_server(shm: bool) { + let svc = unique_service(if shm { + "rs_rob_dead_server" + } else { + "rs_rob_dead_uds_server" + }); + ensure_run_dir(); + cleanup_all(&svc); + let mut helper = spawn_helper(if shm { "shm_server" } else { "uds_server" }, &svc); + + let svc2 = svc.clone(); + let (call_tx, call_rx) = mpsc::channel(); + let caller = thread::spawn(move || { + let config = match shm { + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + true => shm_client_config(), + _ => client_config(), + }; + let mut client = increment_client(&svc2, config); + connect_ready(&mut client); + let _ = call_tx.send(()); + let start = Instant::now(); + let r = client.call_increment_with_timeout(1, 10_000); + let elapsed = start.elapsed(); + client.close(); + (r, elapsed) + }); + call_rx + .recv_timeout(Duration::from_secs(10)) + .expect("client connected"); + // Request parked in the stalled handler. + helper + .handler_entered + .recv_timeout(server::TEST_WAIT_LIMIT) + .expect("helper server handler entered"); + let _ = helper.child.kill(); + let _ = helper.child.wait(); + + let (result, elapsed) = caller.join().expect("caller"); + cleanup_all(&svc); + assert!( + result.is_err() && elapsed < Duration::from_millis(3500), + "call must fail within 3s of the server crash (timeout is 10s): {result:?} after {elapsed:?}" + ); + assert_eq!( + result, + Err(NipcError::Disconnected), + "call must report the server as disconnected" + ); +} + +// A server at worker capacity completes the handshake, then closes the +// session and removes the prepared SHM region. The client must not spend its +// attach retry budget (5 s) on a region that will never appear, and must keep +// SHM enabled for its next connect. +#[test] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn test_robust_shm_attach_stops_when_server_closes_session() { + let svc = unique_service("rs_rob_attach_refused"); + let handler = increment_dispatch(Arc::new(|v| Some(v + 1))); + let server = start_server(&svc, shm_server_config(), METHOD_INCREMENT, handler, 1); + + let mut holder = increment_client(&svc, shm_client_config()); + connect_ready(&mut holder); + + // Production attach budget: a short test budget would hide the bug. + let mut refused = RawClient::new_increment(TEST_RUN_DIR, &svc, shm_client_config()); + let start = Instant::now(); + refused.refresh(); + let elapsed = start.elapsed(); + + holder.close(); + server.events.wait_session_exits(1); + refused.refresh(); + let ready = refused.ready(); + let uses_shm = refused.shm.is_some(); + refused.close(); + stop_within(server, Duration::from_secs(5)); + cleanup_all(&svc); + + assert!( + elapsed < Duration::from_secs(1), + "refused connect must return within 1s (attach retry budget is 5s): {elapsed:?}" + ); + assert!(ready, "refused client must connect once the worker is free"); + assert!(uses_shm, "refused client must still use SHM"); +} + +// --------------------------------------------------------------------------- +// Shutdown order: run closes the listening socket when its loop exits; the +// endpoint lock stays held until every worker has exited. +// --------------------------------------------------------------------------- + +#[test] +fn test_robust_shutdown_closes_socket_before_endpoint() { + let svc = unique_service("rs_rob_shut_order"); + let (entered_tx, entered_rx) = mpsc::channel(); + let entered_tx = std::sync::Mutex::new(entered_tx); + let (release_tx, release_rx) = mpsc::channel::<()>(); + let release_rx = std::sync::Mutex::new(release_rx); + let handler = increment_dispatch(Arc::new(move |v| { + let _ = entered_tx.lock().unwrap().send(()); + let _ = release_rx.lock().unwrap().recv(); + Some(v + 1) + })); + let mut server = start_server(&svc, server_config(), METHOD_INCREMENT, handler, 1); + + let call_svc = svc.clone(); + let call = thread::spawn(move || { + let mut client = increment_client(&call_svc, client_config()); + client.refresh(); + let result = client.call_increment_with_timeout(1, 10_000); + client.close(); + result + }); + entered_rx + .recv_timeout(server::TEST_WAIT_LIMIT) + .expect("handler entered"); + + // stop() returns only after the workers exit, so it runs aside. + let stopper = thread::spawn(move || server.stop()); + // After its grace the server shuts the blocked worker's socket, which + // fails the call; the worker itself stays in the handler. + assert!( + call.join().expect("call join").is_err(), + "blocked call fails" + ); + + let socket = std::path::Path::new(TEST_RUN_DIR).join(format!("{svc}.sock")); + let socket_gone = !socket.exists(); + let held = UdsListener::bind(TEST_RUN_DIR, &svc, server_config()).is_err(); + + release_tx.send(()).expect("release handler"); + stopper.join().expect("stopper join"); + let free = UdsListener::bind(TEST_RUN_DIR, &svc, server_config()).is_ok(); + cleanup_all(&svc); + + assert!( + socket_gone, + "socket file removed once the accept loop exits" + ); + assert!(held, "endpoint held while a worker runs"); + assert!(free, "endpoint free once the server stopped"); +} + +// --------------------------------------------------------------------------- +// Bounded send: a request the server never reads ends at the call deadline, +// or on abort, instead of blocking in the send. +// --------------------------------------------------------------------------- + +const STALLED_READER_REQUEST_BYTES: u32 = 8 * 1024 * 1024; +const STALLED_READER_PIDS: u32 = 1024 * 1024; + +/// Completes the handshake and never reads. Sends on `started` once the +/// request's first bytes arrived, then holds the session until `release`. +fn start_stalled_reader( + svc: &str, + started: mpsc::Sender<()>, + release: mpsc::Receiver<()>, +) -> thread::JoinHandle<()> { + ensure_run_dir(); + cleanup_all(svc); + let cfg = ServerConfig { + max_request_payload_bytes: STALLED_READER_REQUEST_BYTES, + ..server_config() + }; + let listener = UdsListener::bind(TEST_RUN_DIR, svc, cfg).expect("bind"); + thread::spawn(move || { + let Ok(session) = listener.accept() else { + let _ = started.send(()); + return; + }; + let mut pfd = libc::pollfd { + fd: session.fd(), + events: libc::POLLIN, + revents: 0, + }; + unsafe { libc::poll(&mut pfd, 1, 10_000) }; + let _ = started.send(()); + let _ = release.recv_timeout(server::TEST_WAIT_LIMIT); + drop(session); + drop(listener); + }) +} + +#[test] +fn test_robust_send_bounded_by_deadline_and_abort() { + let pids: Vec = (1..=STALLED_READER_PIDS).collect(); + for use_abort in [false, true] { + let svc = unique_service(if use_abort { + "rs_rob_send_abort" + } else { + "rs_rob_send_deadline" + }); + let (started_tx, started_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let server = start_stalled_reader(&svc, started_tx, release_rx); + + let mut client = apps_lookup_client( + &svc, + ClientConfig { + max_request_payload_bytes: STALLED_READER_REQUEST_BYTES, + ..client_config() + }, + ); + client.set_lookup_logical_config(LookupLogicalConfig { + max_items: STALLED_READER_PIDS, + ..LookupLogicalConfig::default() + }); + client.refresh(); + assert!(client.ready(), "client ready against the stalled reader"); + + let abort = client.abort_handle(); + let timeout_ms = if use_abort { 30_000 } else { 200 }; + let start = Instant::now(); + let call_pids = pids.clone(); + let call = thread::spawn(move || { + let result = client + .call_apps_lookup_with_timeout(&call_pids, timeout_ms) + .map(|_| ()); + client.close(); + result + }); + if use_abort { + started_rx + .recv_timeout(server::TEST_WAIT_LIMIT) + .expect("request started"); + abort.abort(); + } + let result = call.join().expect("call join"); + let elapsed = start.elapsed(); + let _ = release_tx.send(()); + server.join().expect("server join"); + cleanup_all(&svc); + + let expected = if use_abort { + NipcError::Aborted + } else { + NipcError::Timeout + }; + assert_eq!(result, Err(expected), "blocked send result"); + assert!( + elapsed < Duration::from_secs(5), + "blocked send ends promptly: {elapsed:?}" + ); + } +} diff --git a/src/crates/netipc/src/service/raw_robustness_windows_tests.rs b/src/crates/netipc/src/service/raw_robustness_windows_tests.rs new file mode 100644 index 00000000..9297d3b4 --- /dev/null +++ b/src/crates/netipc/src/service/raw_robustness_windows_tests.rs @@ -0,0 +1,722 @@ +//! Robustness reproduction tests (SOW-0040), Windows. +//! +//! Each test asserts the correct behavior and runs every blocking step on a +//! helper thread under a watchdog, so a regression fails instead of hanging. +//! Bounds are deliberately lenient so they do not dictate a fix design. + +use super::*; +use crate::protocol::{STRING_REVERSE_HDR_SIZE, string_reverse_encode}; +use std::io::{BufRead, BufReader}; +use std::process::{Child, Command, Stdio}; + +const HELPER_ENV: &str = "NETIPC_ROBUSTNESS_HELPER"; +const HELPER_TEST: &str = "service::raw::windows_tests::robustness::robustness_helper_process"; +const GENERIC_READ: Dword = 0x8000_0000; +const GENERIC_WRITE: Dword = 0x4000_0000; +const OPEN_EXISTING: Dword = 3; +const FILE_FLAG_FIRST_PIPE_INSTANCE: Dword = 0x0008_0000; + +unsafe extern "system" { + fn CreateFileW( + name: *const u16, + access: Dword, + share: Dword, + security: *const core::ffi::c_void, + disposition: Dword, + flags: Dword, + template: RawHandle, + ) -> RawHandle; +} + +/// Run `f` on a thread; return its result, or None if it did not finish in time. +fn within( + limit: Duration, + f: impl FnOnce() -> T + Send + 'static, +) -> (Option, thread::JoinHandle<()>) { + let (tx, rx) = mpsc::channel(); + let handle = thread::spawn(move || { + let _ = tx.send(f()); + }); + (rx.recv_timeout(limit).ok(), handle) +} + +/// Connect and complete one increment call. The server listens before tests +/// connect and a slot is free by then, so one attempt must succeed. +fn served_once(service: &str, config: ClientConfig) -> Result<(), String> { + let mut client = increment_client(service, config); + client.refresh(); + let result = if client.ready() { + match client.call_increment_with_timeout(1, 1000) { + Ok(2) => Ok(()), + Ok(v) => Err(format!("unexpected value {v}")), + Err(e) => Err(format!("call error {e:?}")), + } + } else { + Err(format!("state {:?}", client.state)) + }; + client.close(); + result +} + +/// Start a managed server on its own thread with test timings. Dropping or +/// stopping the result stops the server. +fn start_server( + service: &str, + config: ServerConfig, + method: u16, + handler: DispatchHandler, + workers: usize, +) -> server::RunningTestServer { + ensure_run_dir(); + ManagedServer::with_workers( + TEST_RUN_DIR, + service, + config, + method, + Some(handler), + workers, + ) + .spawn_for_test() +} + +/// Stop `server`; returns whether `run()` returned within `limit`. +fn stop_within(mut server: server::RunningTestServer, limit: Duration) -> bool { + let (stopped, waiter) = within(limit, move || server.stop()); + let finished = stopped.is_some(); + if finished { + let _ = waiter.join(); + } + finished +} + +fn open_pipe_silently(service: &str) -> RawHandle { + let name = build_pipe_name(TEST_RUN_DIR, service).expect("pipe name"); + let h = unsafe { + CreateFileW( + name.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + 0, + ptr::null(), + OPEN_EXISTING, + 0, + 0, + ) + }; + assert_ne!( + h, + INVALID_HANDLE_VALUE, + "could not open pipe for {service}: {}", + unsafe { GetLastError() } + ); + h +} + +/// A helper process and the signal it sends when its server handler starts. +struct Helper { + child: Child, + handler_entered: mpsc::Receiver<()>, +} + +fn spawn_helper(mode: &str, service: &str) -> Helper { + let mut child = Command::new(std::env::current_exe().expect("current_exe")) + .args([HELPER_TEST, "--exact", "--nocapture", "--test-threads=1"]) + .env(HELPER_ENV, format!("{mode}:{service}")) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn helper"); + let stdout = child.stdout.take().expect("helper stdout"); + let (ready_tx, ready_rx) = mpsc::channel(); + let (entered_tx, handler_entered) = mpsc::channel(); + thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + if line.contains("HELPER_READY") { + let _ = ready_tx.send(()); + } + if line.contains("HELPER_HANDLER_ENTERED") { + let _ = entered_tx.send(()); + } + } + }); + if ready_rx.recv_timeout(server::TEST_WAIT_LIMIT).is_err() { + let _ = child.kill(); + let _ = child.wait(); + panic!("helper {mode} did not become ready"); + } + Helper { + child, + handler_entered, + } +} + +fn slow_increment_dispatch(delay: Duration) -> DispatchHandler { + increment_dispatch(Arc::new(move |v| { + thread::sleep(delay); + Some(v + 1) + })) +} + +/// Increment handler that reports entry, then blocks until its process is +/// killed. +fn blocking_increment_dispatch(entered: impl Fn() + Send + Sync + 'static) -> DispatchHandler { + increment_dispatch(Arc::new(move |_v| { + entered(); + loop { + thread::park(); + } + })) +} + +/// Child-process entry point. Inert unless spawned by `spawn_helper`. +#[test] +fn robustness_helper_process() { + let Ok(spec) = std::env::var(HELPER_ENV) else { + return; + }; + let (mode, service) = spec.split_once(':').expect("helper spec"); + match mode { + "shm_client" => { + let mut client = increment_client(service, shm_client_config()); + connect_ready(&mut client); + assert_eq!(client.call_increment_with_timeout(1, 2000), Ok(2)); + println!("HELPER_READY"); + // Until the test kills this process. + loop { + thread::park(); + } + } + "shm_server" => { + let _server = start_server( + service, + shm_server_config(), + METHOD_INCREMENT, + blocking_increment_dispatch(|| println!("HELPER_HANDLER_ENTERED")), + 2, + ); + println!("HELPER_READY"); + loop { + thread::park(); + } + } + other => panic!("unknown helper mode {other}"), + } +} + +// K2: SHM server must release a session once its client crashes. +#[test] +fn test_robust_win_shm_session_released_after_client_crash() { + let svc = unique_service("rs_wrob_shm_crash"); + let server = start_server( + &svc, + shm_server_config(), + METHOD_INCREMENT, + increment_dispatch_handler(), + 2, + ); + + for _ in 0..2 { + let mut helper = spawn_helper("shm_client", &svc); + let _ = helper.child.kill(); // TerminateProcess + let _ = helper.child.wait(); + } + // Both workers must notice their crashed clients and free the slots. + server.events.wait_session_exits(2); + let served = served_once(&svc, shm_client_config()); + stop_within(server, Duration::from_secs(5)); + assert!( + served.is_ok(), + "after 2 crashed SHM clients on 2 workers, a new client must be served: {served:?}" + ); +} + +// K3: a client that opens the pipe but never sends HELLO. +#[test] +fn test_robust_win_silent_client_does_not_block_other_clients() { + let svc = unique_service("rs_wrob_silent"); + let server = start_server( + &svc, + server_config(), + METHOD_INCREMENT, + increment_dispatch_handler(), + 4, + ); + + let silent = open_pipe_silently(&svc); + server.events.wait_handshakes_started(1); + let svc2 = svc.clone(); + let (served, worker) = within(Duration::from_secs(5), move || { + served_once(&svc2, client_config()) + }); + + unsafe { CloseHandle(silent) }; + let _ = worker.join(); + stop_within(server, Duration::from_secs(5)); + assert!( + matches!(served, Some(Ok(()))), + "a legitimate client must be served within 5s while a silent client is connected: {served:?}" + ); +} + +/// A silent client holds a worker only until the server handshake deadline. +#[test] +fn test_robust_win_silent_client_released_at_handshake_deadline() { + let svc = unique_service("rs_wrob_silent_deadline"); + let server = start_server( + &svc, + server_config(), + METHOD_INCREMENT, + increment_dispatch_handler(), + 1, + ); + + let silent = open_pipe_silently(&svc); + let start = Instant::now(); + server.events.wait_handshakes_started(1); + server.events.wait_session_exits(1); + let elapsed = start.elapsed(); + let served = served_once(&svc, client_config()); + + unsafe { CloseHandle(silent) }; + stop_within(server, Duration::from_secs(5)); + let deadline = server::TEST_SERVER_TIMINGS.handshake_ms; + assert!( + elapsed >= Duration::from_millis(u64::from(deadline)), + "silent client released after {elapsed:?}, before the {deadline} ms handshake deadline" + ); + assert!( + served.is_ok(), + "the only worker must be freed once the silent client misses the handshake deadline: {served:?}" + ); +} + +#[test] +fn test_robust_win_silent_client_does_not_block_shutdown() { + let svc = unique_service("rs_wrob_silent_stop"); + let server = start_server( + &svc, + server_config(), + METHOD_INCREMENT, + increment_dispatch_handler(), + 4, + ); + + let silent = open_pipe_silently(&svc); + server.events.wait_handshakes_started(1); + let stopped = stop_within(server, Duration::from_secs(3)); + + unsafe { CloseHandle(silent) }; + assert!( + stopped, + "server run() must return within 3s of shutdown while a silent client is connected" + ); +} + +// K4: a client call must be bounded even when the server never answers HELLO. +#[test] +fn test_robust_win_client_bounded_when_server_never_sends_hello_ack() { + let svc = unique_service("rs_wrob_nohelloack"); + let name = build_pipe_name(TEST_RUN_DIR, &svc).expect("pipe name"); + let pipe = unsafe { + CreateNamedPipeW( + name.as_ptr(), + PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, + 4, + RAW_PIPE_PACKET_SIZE, + RAW_PIPE_PACKET_SIZE, + 0, + ptr::null(), + ) + }; + assert_ne!(pipe, INVALID_HANDLE_VALUE, "fake server pipe"); + + let svc2 = svc.clone(); + let (result, worker) = within(Duration::from_secs(6), move || { + let mut client = increment_client(&svc2, client_config()); + client.set_call_timeout(1000); + client.refresh(); + let r = client.call_increment_with_timeout(1, 1000); + client.close(); + r + }); + + unsafe { CloseHandle(pipe) }; + let _ = worker.join(); + assert!( + result.is_some(), + "refresh + call with a 1s timeout must return within 6s when the server never sends HELLO_ACK" + ); +} + +/// A pipe that accepts clients but never answers HELLO. +fn fake_server_pipe(service: &str) -> RawHandle { + let name = build_pipe_name(TEST_RUN_DIR, service).expect("pipe name"); + let pipe = unsafe { + CreateNamedPipeW( + name.as_ptr(), + PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, + 4, + RAW_PIPE_PACKET_SIZE, + RAW_PIPE_PACKET_SIZE, + 0, + ptr::null(), + ) + }; + assert_ne!(pipe, INVALID_HANDLE_VALUE, "fake server pipe"); + pipe +} + +#[test] +fn test_robust_win_call_reconnect_bounded_by_call_deadline() { + let svc = unique_service("rs_wrob_reconnect_noack"); + let server = start_server( + &svc, + server_config(), + METHOD_INCREMENT, + increment_dispatch_handler(), + 1, + ); + let mut client = increment_client(&svc, client_config()); + connect_ready(&mut client); + assert!(stop_within(server, Duration::from_secs(5)), "server stops"); + + // The call fails on the dead session and reconnects to a server that + // accepts but never sends HELLO_ACK. + let pipe = fake_server_pipe(&svc); + let (result, worker) = within(Duration::from_secs(6), move || { + let start = Instant::now(); + let r = client.call_increment_with_timeout(1, 1000); + let elapsed = start.elapsed(); + client.close(); + (r, elapsed) + }); + + unsafe { CloseHandle(pipe) }; + let _ = worker.join(); + let (r, elapsed) = result.expect("a call with a 1s timeout must return within 6s"); + assert_eq!( + r, + Err(NipcError::Timeout), + "call reports the timeout after {elapsed:?}" + ); +} + +#[test] +fn test_robust_win_abort_ends_refresh_waiting_for_hello_ack() { + let svc = unique_service("rs_wrob_abort_refresh"); + let pipe = fake_server_pipe(&svc); + + let mut client = increment_client(&svc, client_config()); + // Only an abort can end the wait early. + client.set_call_timeout(10_000); + let abort = client.abort_handle(); + let (worker_tx, worker_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + client.refresh(); + let _ = worker_tx.send(client.ready()); + client.close(); + }); + // A client that never connects must fail the test, not hang it. + let (connected, _connect) = within(Duration::from_secs(5), move || { + let ok = unsafe { ConnectNamedPipe(pipe, ptr::null_mut()) } != 0; + ok || unsafe { GetLastError() } == ERROR_PIPE_CONNECTED + }); + assert_eq!(connected, Some(true), "client connected to the fake server"); + abort.abort(); + let ready = worker_rx.recv_timeout(Duration::from_secs(2)); + + unsafe { CloseHandle(pipe) }; + let _ = worker.join(); + assert_eq!( + ready, + Ok(false), + "refresh returns within 2s of the abort, not ready" + ); +} + +// N1: the configured call timeout must be honored on the named-pipe path. +#[test] +fn test_robust_win_pipe_call_honors_timeout_longer_than_100ms() { + let svc = unique_service("rs_wrob_slow_handler"); + let server = start_server( + &svc, + server_config(), + METHOD_INCREMENT, + slow_increment_dispatch(Duration::from_millis(300)), + 2, + ); + + let mut client = increment_client(&svc, client_config()); + connect_ready(&mut client); + let start = Instant::now(); + let result = client.call_increment_with_timeout(41, 2000); + let elapsed = start.elapsed(); + client.close(); + + stop_within(server, Duration::from_secs(5)); + assert_eq!( + result, + Ok(42), + "a 300ms handler must succeed with a 2000ms timeout (failed after {elapsed:?})" + ); +} + +// N2: shutdown must not wait forever on a worker blocked writing a response +// to a client that stopped reading. +#[test] +fn test_robust_win_shutdown_not_blocked_by_non_reading_client() { + const BIG: u32 = 8 * 1024 * 1024; + let svc = unique_service("rs_wrob_noread"); + let mut scfg = server_config(); + scfg.max_request_payload_bytes = BIG; + scfg.max_response_payload_bytes = BIG; + let mut ccfg = client_config(); + ccfg.max_request_payload_bytes = BIG; + ccfg.max_response_payload_bytes = BIG; + // After the handler returns, the worker can only be writing the 4 MiB + // response, which blocks because the client never reads. + let (handled_tx, handled_rx) = mpsc::channel(); + let handled_tx = std::sync::Mutex::new(handled_tx); + let handler = string_reverse_dispatch(Arc::new(move |s: &str| { + let out = Some(s.chars().rev().collect()); + let _ = handled_tx.lock().unwrap().send(()); + out + })); + let server = start_server(&svc, scfg, METHOD_STRING_REVERSE, handler, 2); + + let mut session = NpSession::connect(TEST_RUN_DIR, &svc, &ccfg).expect("connect"); + let text = "x".repeat(4 * 1024 * 1024); + let mut payload = vec![0u8; STRING_REVERSE_HDR_SIZE + text.len() + 1]; + let n = string_reverse_encode(text.as_bytes(), &mut payload); + assert!(n > 0, "encode request"); + let mut hdr = Header { + kind: KIND_REQUEST, + code: METHOD_STRING_REVERSE, + item_count: 1, + message_id: 1, + ..Header::default() + }; + session.send(&mut hdr, &payload[..n]).expect("send request"); + // Never read the response. + handled_rx + .recv_timeout(server::TEST_WAIT_LIMIT) + .expect("string reverse handler ran"); + + let stopped = stop_within(server, Duration::from_secs(3)); + drop(session); + assert!( + stopped, + "server run() must return within 3s of shutdown while a worker is blocked writing" + ); +} + +// N3: stop() issued before run() must not be lost. +#[test] +fn test_robust_win_stop_before_run_is_honored() { + let svc = unique_service("rs_wrob_stop_first"); + ensure_run_dir(); + let mut server = ManagedServer::with_workers( + TEST_RUN_DIR, + &svc, + server_config(), + METHOD_INCREMENT, + Some(increment_dispatch_handler()), + 2, + ); + server.stop(); + let flag = server.running_flag(); + let (returned, worker) = within(Duration::from_secs(2), move || { + let _ = server.run(); + }); + + if returned.is_none() { + flag.store(false, Ordering::Release); + let _ = NpSession::connect(TEST_RUN_DIR, &svc, &client_config()); + } + let _ = worker.join(); + assert!( + returned.is_some(), + "run() after stop() must return promptly instead of serving forever" + ); +} + +// N5: an SHM client must notice a crashed server instead of waiting out its +// whole timeout. +#[test] +fn test_robust_win_shm_client_notices_crashed_server() { + let svc = unique_service("rs_wrob_dead_server"); + let mut helper = spawn_helper("shm_server", &svc); + + let svc2 = svc.clone(); + let (call_tx, call_rx) = mpsc::channel(); + let caller = thread::spawn(move || { + let mut client = increment_client(&svc2, shm_client_config()); + connect_ready(&mut client); + let _ = call_tx.send(()); + let start = Instant::now(); + let r = client.call_increment_with_timeout(1, 10_000); + let elapsed = start.elapsed(); + client.close(); + (r, elapsed) + }); + call_rx + .recv_timeout(Duration::from_secs(10)) + .expect("client connected"); + // Request parked in the stalled handler. + helper + .handler_entered + .recv_timeout(server::TEST_WAIT_LIMIT) + .expect("helper server handler entered"); + let _ = helper.child.kill(); + let _ = helper.child.wait(); + + let (result, elapsed) = caller.join().expect("caller"); + assert!( + result.is_err() && elapsed < Duration::from_millis(3500), + "call must fail within 3s of the server crash (timeout is 10s): {result:?} after {elapsed:?}" + ); + assert_eq!( + result, + Err(NipcError::Disconnected), + "call must report the server as disconnected" + ); +} + +// A server at worker capacity completes the handshake, then closes the +// session and destroys the prepared WinSHM objects. The client must not spend +// its attach retry budget (5 s) on objects that will never appear, and must +// keep WinSHM enabled for its next connect. +#[test] +fn test_robust_win_shm_attach_stops_when_server_closes_session() { + let svc = unique_service("rs_wrob_attach_refused"); + let server = start_server( + &svc, + shm_server_config(), + METHOD_INCREMENT, + increment_dispatch_handler(), + 1, + ); + + let mut holder = increment_client(&svc, shm_client_config()); + holder.refresh(); + let holder_ready = holder.ready(); + + // Production attach budget: a short test budget would hide the bug. + let mut refused = RawClient::new_increment(TEST_RUN_DIR, &svc, shm_client_config()); + let start = Instant::now(); + refused.refresh(); + let elapsed = start.elapsed(); + + holder.close(); + server.events.wait_session_exits(1); + refused.refresh(); + let ready = refused.ready(); + let uses_shm = refused.shm.is_some(); + refused.close(); + stop_within(server, Duration::from_secs(5)); + + assert!(holder_ready, "first client must hold the only worker"); + assert!( + elapsed < Duration::from_secs(1), + "refused connect must return within 1s (attach retry budget is 5s): {elapsed:?}" + ); + assert!(ready, "refused client must connect once the worker is free"); + assert!(uses_shm, "refused client must still use WinSHM"); +} + +// --------------------------------------------------------------------------- +// Bounded send: a request the server never reads ends at the call deadline, +// or on abort, instead of blocking in the write. +// --------------------------------------------------------------------------- + +const STALLED_READER_REQUEST_BYTES: u32 = 8 * 1024 * 1024; +const STALLED_READER_PIDS: u32 = 1024 * 1024; + +/// Completes the handshake and never reads. Sends on `started` once the +/// request's first bytes arrived, then holds the session until `release`. +fn start_stalled_reader( + svc: &str, + started: mpsc::Sender<()>, + release: mpsc::Receiver<()>, +) -> thread::JoinHandle<()> { + ensure_run_dir(); + let cfg = ServerConfig { + max_request_payload_bytes: STALLED_READER_REQUEST_BYTES, + ..server_config() + }; + let mut listener = NpListener::bind(TEST_RUN_DIR, svc, cfg).expect("bind"); + thread::spawn(move || { + let Ok(session) = listener.accept() else { + let _ = started.send(()); + return; + }; + let _ = session.wait_readable(10_000); + let _ = started.send(()); + let _ = release.recv_timeout(server::TEST_WAIT_LIMIT); + drop(session); + drop(listener); + }) +} + +#[test] +fn test_win_robust_send_bounded_by_deadline_and_abort() { + let pids: Vec = (1..=STALLED_READER_PIDS).collect(); + for use_abort in [false, true] { + let svc = unique_service(if use_abort { + "rs_winrob_send_abort" + } else { + "rs_winrob_send_deadline" + }); + let (started_tx, started_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let server = start_stalled_reader(&svc, started_tx, release_rx); + + let mut client = apps_lookup_client( + &svc, + ClientConfig { + max_request_payload_bytes: STALLED_READER_REQUEST_BYTES, + ..client_config() + }, + ); + client.set_lookup_logical_config(LookupLogicalConfig { + max_items: STALLED_READER_PIDS, + ..LookupLogicalConfig::default() + }); + client.refresh(); + assert!(client.ready(), "client ready against the stalled reader"); + + let abort = client.abort_handle(); + let timeout_ms = if use_abort { 30_000 } else { 200 }; + let start = Instant::now(); + let call_pids = pids.clone(); + let call = thread::spawn(move || { + let result = client + .call_apps_lookup_with_timeout(&call_pids, timeout_ms) + .map(|_| ()); + client.close(); + result + }); + if use_abort { + started_rx + .recv_timeout(server::TEST_WAIT_LIMIT) + .expect("request started"); + abort.abort(); + } + let result = call.join().expect("call join"); + let elapsed = start.elapsed(); + let _ = release_tx.send(()); + server.join().expect("server join"); + + let expected = if use_abort { + NipcError::Aborted + } else { + NipcError::Timeout + }; + assert_eq!(result, Err(expected), "blocked send result"); + assert!( + elapsed < Duration::from_secs(5), + "blocked send ends promptly: {elapsed:?}" + ); + } +} diff --git a/src/crates/netipc/src/service/raw_unix_tests.rs b/src/crates/netipc/src/service/raw_unix_tests.rs index 88545e5f..c8c8a021 100644 --- a/src/crates/netipc/src/service/raw_unix_tests.rs +++ b/src/crates/netipc/src/service/raw_unix_tests.rs @@ -1,16 +1,16 @@ use super::*; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] use crate::protocol::PROFILE_SHM_FUTEX; use crate::protocol::{ - increment_encode, AppsLookupBuilder, AppsLookupRequestView, AppsLookupResponseView, - BatchBuilder, CgroupsBuilder, CgroupsLookupBuilder, CgroupsLookupRequestView, - CgroupsLookupResponseView, APPS_CGROUP_HOST_ROOT, APPS_CGROUP_KNOWN, APPS_LOOKUP_RESP_HDR_SIZE, - CGROUPS_LOOKUP_RESP_HDR_SIZE, CGROUP_LOOKUP_KNOWN, CGROUP_LOOKUP_OVERSIZED_ITEM, - CGROUP_LOOKUP_PAYLOAD_EXCEEDED, CGROUP_LOOKUP_UNKNOWN_RETRY_LATER, KIND_RESPONSE, + APPS_CGROUP_HOST_ROOT, APPS_CGROUP_KNOWN, APPS_LOOKUP_RESP_HDR_SIZE, AppsLookupBuilder, + AppsLookupRequestView, AppsLookupResponseView, BatchBuilder, CGROUP_LOOKUP_KNOWN, + CGROUP_LOOKUP_OVERSIZED_ITEM, CGROUP_LOOKUP_PAYLOAD_EXCEEDED, + CGROUP_LOOKUP_UNKNOWN_RETRY_LATER, CGROUPS_LOOKUP_RESP_HDR_SIZE, CgroupsBuilder, + CgroupsLookupBuilder, CgroupsLookupRequestView, CgroupsLookupResponseView, KIND_RESPONSE, MAX_PAYLOAD_CAP, METHOD_APPS_LOOKUP, METHOD_CGROUPS_LOOKUP, NIPC_UID_UNSET, ORCHESTRATOR_DOCKER, ORCHESTRATOR_K8S, PID_LOOKUP_KNOWN, PID_LOOKUP_OVERSIZED_ITEM, PID_LOOKUP_PAYLOAD_EXCEEDED, PID_LOOKUP_UNKNOWN, PROFILE_BASELINE, STATUS_LIMIT_EXCEEDED, - STATUS_OK, + STATUS_OK, increment_encode, }; use std::os::fd::RawFd; use std::os::unix::ffi::OsStrExt; @@ -39,7 +39,7 @@ fn ensure_run_dir() { fn cleanup_all(service: &str) { let _ = std::fs::remove_file(format!("{TEST_RUN_DIR}/{service}.sock")); - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] crate::transport::shm::cleanup_stale(TEST_RUN_DIR, service); } @@ -48,24 +48,14 @@ fn socket_path(service: &str) -> PathBuf { } fn unique_service(prefix: &str) -> String { - format!( + let service = format!( "{}_{}_{}", prefix, std::process::id(), RAW_SERVICE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1 - ) -} - -fn wait_for_listener_bind(service: &str) { - let sock = socket_path(service); - for _ in 0..2000 { - if sock.exists() { - return; - } - thread::sleep(Duration::from_micros(500)); - } - - panic!("listener did not bind for service {service}"); + ); + crate::test_locks::track(TEST_RUN_DIR, &service); + service } fn server_config() -> ServerConfig { @@ -76,7 +66,9 @@ fn server_config() -> ServerConfig { max_response_payload_bytes: RESPONSE_BUF_SIZE as u32, max_response_batch_items: 1, auth_token: AUTH_TOKEN, - backlog: 4, + // Above every test's connect burst: macOS and FreeBSD refuse a + // connect to a full backlog at once, where Linux waits. + backlog: 64, ..ServerConfig::default() } } @@ -93,7 +85,7 @@ fn client_config() -> ClientConfig { } } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] fn shm_server_config() -> ServerConfig { ServerConfig { supported_profiles: PROFILE_BASELINE | PROFILE_SHM_FUTEX, @@ -108,7 +100,7 @@ fn shm_server_config() -> ServerConfig { } } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] fn shm_client_config() -> ClientConfig { ClientConfig { supported_profiles: PROFILE_BASELINE | PROFILE_SHM_FUTEX, @@ -148,43 +140,49 @@ fn batch_client_config() -> ClientConfig { } fn snapshot_client(service: &str, config: ClientConfig) -> RawClient { - RawClient::new_snapshot(TEST_RUN_DIR, service, config) + let mut client = RawClient::new_snapshot(TEST_RUN_DIR, service, config); + client.use_test_timings(); + client } fn cgroups_lookup_client(service: &str, config: ClientConfig) -> RawClient { - RawClient::new_cgroups_lookup(TEST_RUN_DIR, service, config) + let mut client = RawClient::new_cgroups_lookup(TEST_RUN_DIR, service, config); + client.use_test_timings(); + client } fn apps_lookup_client(service: &str, config: ClientConfig) -> RawClient { - RawClient::new_apps_lookup(TEST_RUN_DIR, service, config) + let mut client = RawClient::new_apps_lookup(TEST_RUN_DIR, service, config); + client.use_test_timings(); + client } fn increment_client(service: &str, config: ClientConfig) -> RawClient { - RawClient::new_increment(TEST_RUN_DIR, service, config) + let mut client = RawClient::new_increment(TEST_RUN_DIR, service, config); + client.use_test_timings(); + client } fn string_reverse_client(service: &str, config: ClientConfig) -> RawClient { - RawClient::new_string_reverse(TEST_RUN_DIR, service, config) + let mut client = RawClient::new_string_reverse(TEST_RUN_DIR, service, config); + client.use_test_timings(); + client } +/// The server listens before tests connect, so one refresh must succeed. fn connect_ready(client: &mut RawClient) { - for _ in 0..200 { - client.refresh(); - if client.ready() { - return; - } - thread::sleep(Duration::from_millis(10)); - } - - panic!("client did not reach READY state"); + client.refresh(); + assert!(client.ready(), "client did not reach READY state"); } #[test] fn test_client_call_timeout_on_wedged_peer() { let svc = unique_service("rs_call_timeout"); + let (release_tx, release_rx) = mpsc::channel::<()>(); let mut server = start_raw_session_server(&svc, server_config(), move |_session, _hdr, _payload| { - thread::sleep(Duration::from_millis(150)); + // Never answer; the test releases the peer once the call timed out. + let _ = release_rx.recv_timeout(server::TEST_WAIT_LIMIT); Ok(()) }); @@ -203,6 +201,7 @@ fn test_client_call_timeout_on_wedged_peer() { ); client.close(); + drop(release_tx); server.wait(); cleanup_all(&svc); } @@ -276,6 +275,7 @@ fn fill_test_cgroups_snapshot(builder: &mut CgroupsBuilder<'_>) -> bool { true } +#[cfg(any(target_os = "linux", target_os = "freebsd"))] fn panic_payload_to_string(payload: Box) -> String { match payload.downcast::() { Ok(msg) => *msg, @@ -470,8 +470,7 @@ fn string_reverse_dispatch_handler() -> DispatchHandler { } struct TestServer { - stop_flag: Arc, - thread: Option>, + inner: server::RunningTestServer, } impl TestServer { @@ -479,7 +478,7 @@ impl TestServer { Self::start_with(service, server_config(), expected_method_code, handler, 8) } - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] fn start_shm( service: &str, expected_method_code: u16, @@ -518,41 +517,16 @@ impl TestServer { ) -> Self { ensure_run_dir(); cleanup_all(service); - - let svc = service.to_string(); - let ready_flag = Arc::new(AtomicBool::new(false)); - let ready_clone = ready_flag.clone(); - - let mut server = ManagedServer::with_workers( + let server = ManagedServer::with_workers( TEST_RUN_DIR, - &svc, + service, config, expected_method_code, handler, worker_count, ); - let stop_flag = server.running_flag(); - - let thread = thread::spawn(move || { - // We need to signal readiness after bind but before accept loop. - // The run() method binds internally, so we signal immediately - // after it starts (it blocks on accept). - ready_clone.store(true, Ordering::Release); - let _ = server.run(); - }); - - // Wait for server to be ready - for _ in 0..2000 { - if ready_flag.load(Ordering::Acquire) { - break; - } - thread::sleep(Duration::from_micros(500)); - } - wait_for_listener_bind(service); - TestServer { - stop_flag, - thread: Some(thread), + inner: server.spawn_for_test(), } } @@ -562,50 +536,17 @@ impl TestServer { handler: Option, resp_buf_size: usize, ) -> Self { - ensure_run_dir(); - cleanup_all(service); - - let svc = service.to_string(); - let ready_flag = Arc::new(AtomicBool::new(false)); - let ready_clone = ready_flag.clone(); - let mut scfg = server_config(); scfg.max_response_payload_bytes = resp_buf_size as u32; - - let mut server = - ManagedServer::new(TEST_RUN_DIR, &svc, scfg, expected_method_code, handler); - let stop_flag = server.running_flag(); - - let thread = thread::spawn(move || { - ready_clone.store(true, Ordering::Release); - let _ = server.run(); - }); - - for _ in 0..2000 { - if ready_flag.load(Ordering::Acquire) { - break; - } - thread::sleep(Duration::from_micros(500)); - } - wait_for_listener_bind(service); - - TestServer { - stop_flag, - thread: Some(thread), - } + Self::start_with(service, scfg, expected_method_code, handler, 8) } - fn stop(&mut self) { - self.stop_flag.store(false, Ordering::Release); - if let Some(t) = self.thread.take() { - let _ = t.join(); - } + fn events(&self) -> &server::TestEvents { + &self.inner.events } -} -impl Drop for TestServer { - fn drop(&mut self) { - self.stop(); + fn stop(&mut self) { + self.inner.stop(); } } @@ -625,7 +566,7 @@ fn raw_listener_fd_for_service(service: &str) -> RawFd { "socket path too long" ); - let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_SEQPACKET, 0) }; + let fd = unsafe { libc::socket(libc::AF_UNIX, crate::transport::posix::SOCK_TYPE, 0) }; assert!( fd >= 0, "socket failed: {}", @@ -694,24 +635,35 @@ fn start_raw_hello_ack_server(service: &str, packet: Vec) -> RawHelloAckServ ensure_run_dir(); cleanup_all(service); - let svc = service.to_string(); + // Listen before returning so the client's first connect cannot race it. + let fd = raw_listener_fd_for_service(service); let thread = thread::spawn(move || { - let fd = raw_listener_fd_for_service(&svc); let client_fd = unsafe { libc::accept(fd, std::ptr::null_mut(), std::ptr::null_mut()) }; if client_fd < 0 { unsafe { libc::close(fd) }; return Err(format!("accept: {}", std::io::Error::last_os_error())); } + // A stream socket may return the HELLO in parts: read all of it. + let hello_len = + crate::protocol::HEADER_SIZE + crate::transport::framing::HELLO_PAYLOAD_SIZE; let mut hello_buf = [0u8; crate::protocol::HEADER_SIZE + 128]; - let n = unsafe { - libc::recv( - client_fd, - hello_buf.as_mut_ptr() as *mut libc::c_void, - hello_buf.len(), - 0, - ) - }; + let mut got = 0usize; + let mut n: isize = 0; + while got < hello_len { + n = unsafe { + libc::recv( + client_fd, + hello_buf[got..].as_mut_ptr() as *mut libc::c_void, + hello_len - got, + 0, + ) + }; + if n <= 0 { + break; + } + got += n as usize; + } if n < 0 { unsafe { libc::close(client_fd); @@ -719,6 +671,13 @@ fn start_raw_hello_ack_server(service: &str, packet: Vec) -> RawHelloAckServ } return Err(format!("recv: {}", std::io::Error::last_os_error())); } + if got < hello_len { + unsafe { + libc::close(client_fd); + libc::close(fd); + } + return Err(format!("truncated HELLO: {got} of {hello_len} bytes")); + } let wrote = unsafe { libc::send( @@ -742,7 +701,6 @@ fn start_raw_hello_ack_server(service: &str, packet: Vec) -> RawHelloAckServ Ok(()) }); - thread::sleep(Duration::from_millis(50)); RawHelloAckServer { thread: Some(thread), } @@ -768,13 +726,9 @@ where cleanup_all(service); let svc = service.to_string(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); + let listener = UdsListener::bind(TEST_RUN_DIR, &svc, cfg).expect("bind"); let thread = thread::spawn(move || { - let listener = - UdsListener::bind(TEST_RUN_DIR, &svc, cfg).map_err(|e| format!("bind: {e}"))?; - ready_clone.store(true, Ordering::Release); let mut session = listener.accept().map_err(|e| format!("accept: {e}"))?; let (hdr, payload) = { @@ -788,14 +742,6 @@ where handler(&mut session, hdr, &payload) }); - for _ in 0..2000 { - if ready.load(Ordering::Acquire) { - break; - } - thread::sleep(Duration::from_micros(500)); - } - thread::sleep(Duration::from_millis(50)); - RawSessionServer { thread: Some(thread), } @@ -818,14 +764,9 @@ fn start_raw_overflow_no_growth_server(service: &str, cfg: ServerConfig) -> RawS cleanup_all(service); let svc = service.to_string(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); + let listener = UdsListener::bind(TEST_RUN_DIR, &svc, cfg).expect("bind"); let thread = thread::spawn(move || { - let listener = - UdsListener::bind(TEST_RUN_DIR, &svc, cfg).map_err(|e| format!("bind: {e}"))?; - ready_clone.store(true, Ordering::Release); - let mut first = listener .accept() .map_err(|e| format!("first accept: {e}"))?; @@ -858,25 +799,17 @@ fn start_raw_overflow_no_growth_server(service: &str, cfg: ServerConfig) -> RawS Ok(()) }); - for _ in 0..2000 { - if ready.load(Ordering::Acquire) { - break; - } - thread::sleep(Duration::from_micros(500)); - } - thread::sleep(Duration::from_millis(50)); - RawSessionServer { thread: Some(thread), } } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] struct RawShmSessionServer { thread: Option>>, } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] fn start_raw_shm_session_server( service: &str, cfg: ServerConfig, @@ -889,13 +822,9 @@ where cleanup_all(service); let svc = service.to_string(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); + let listener = UdsListener::bind(TEST_RUN_DIR, &svc, cfg).expect("bind"); let thread = thread::spawn(move || { - let listener = - UdsListener::bind(TEST_RUN_DIR, &svc, cfg).map_err(|e| format!("bind: {e}"))?; - ready_clone.store(true, Ordering::Release); let session = listener.accept().map_err(|e| format!("accept: {e}"))?; if session.selected_profile != PROFILE_SHM_FUTEX { return Err(format!("unexpected profile {}", session.selected_profile)); @@ -923,20 +852,12 @@ where handler(&mut shm, hdr, &payload) }); - for _ in 0..2000 { - if ready.load(Ordering::Acquire) { - break; - } - thread::sleep(Duration::from_micros(500)); - } - thread::sleep(Duration::from_millis(50)); - RawShmSessionServer { thread: Some(thread), } } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] impl RawShmSessionServer { fn wait(&mut self) { if let Some(thread) = self.thread.take() { @@ -949,7 +870,7 @@ impl RawShmSessionServer { } } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] fn encode_raw_message(hdr: &Header, payload: &[u8]) -> Vec { let mut msg = vec![0u8; HEADER_SIZE + payload.len()]; hdr.encode(&mut msg[..HEADER_SIZE]); @@ -1360,9 +1281,14 @@ fn test_lookup_timeout_during_followup_subcall() { cfg.max_response_payload_bytes = 320; let calls = Arc::new(std::sync::atomic::AtomicU32::new(0)); let handler_calls = calls.clone(); + let (release_tx, release_rx) = mpsc::channel::<()>(); + let release_rx = std::sync::Mutex::new(release_rx); let handler = apps_lookup_dispatch(Arc::new(move |req, builder| { if handler_calls.fetch_add(1, Ordering::SeqCst) + 1 == 2 { - thread::sleep(Duration::from_millis(150)); + let _ = release_rx + .lock() + .unwrap() + .recv_timeout(server::TEST_WAIT_LIMIT); } builder.set_generation(9); for i in 0..req.item_count { @@ -1401,13 +1327,16 @@ fn test_lookup_timeout_during_followup_subcall() { let mut server = TestServer::start_with(&svc, cfg, METHOD_APPS_LOOKUP, Some(handler), 8); let mut client = apps_lookup_client(&svc, client_config()); connect_ready(&mut client); - let err = match client.call_apps_lookup_with_timeout(&[11, 22, 33], 30) { + // The budget leaves slow or emulated runners room for the first subcall; + // only the much slower second subcall can expire it. + let err = match client.call_apps_lookup_with_timeout(&[11, 22, 33], 500) { Ok(_) => panic!("apps follow-up timeout should fail"), Err(err) => err, }; assert_eq!(err, NipcError::Timeout); assert!(calls.load(Ordering::SeqCst) >= 2); client.close(); + drop(release_tx); server.stop(); cleanup_all(&svc); @@ -1416,9 +1345,14 @@ fn test_lookup_timeout_during_followup_subcall() { cfg.max_response_payload_bytes = 160; let calls = Arc::new(std::sync::atomic::AtomicU32::new(0)); let handler_calls = calls.clone(); + let (release_tx, release_rx) = mpsc::channel::<()>(); + let release_rx = std::sync::Mutex::new(release_rx); let handler = cgroups_lookup_dispatch(Arc::new(move |req, builder| { if handler_calls.fetch_add(1, Ordering::SeqCst) + 1 == 2 { - thread::sleep(Duration::from_millis(150)); + let _ = release_rx + .lock() + .unwrap() + .recv_timeout(server::TEST_WAIT_LIMIT); } builder.set_generation(7); for i in 0..req.item_count { @@ -1452,13 +1386,16 @@ fn test_lookup_timeout_during_followup_subcall() { let mut client = cgroups_lookup_client(&svc, client_config()); connect_ready(&mut client); let paths: [&[u8]; 3] = [b"/a".as_slice(), b"/huge".as_slice(), b"/b".as_slice()]; - let err = match client.call_cgroups_lookup_with_timeout(&paths, 30) { + // The budget leaves slow or emulated runners room for the first subcall; + // only the much slower second subcall can expire it. + let err = match client.call_cgroups_lookup_with_timeout(&paths, 500) { Ok(_) => panic!("cgroups follow-up timeout should fail"), Err(err) => err, }; assert_eq!(err, NipcError::Timeout); assert!(calls.load(Ordering::SeqCst) >= 2); client.close(); + drop(release_tx); server.stop(); cleanup_all(&svc); } @@ -1471,10 +1408,15 @@ fn test_lookup_abort_during_followup_subcall() { let calls = Arc::new(std::sync::atomic::AtomicU32::new(0)); let handler_calls = calls.clone(); let (second_tx, second_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel::<()>(); + let release_rx = std::sync::Mutex::new(release_rx); let handler = apps_lookup_dispatch(Arc::new(move |req, builder| { if handler_calls.fetch_add(1, Ordering::SeqCst) + 1 == 2 { let _ = second_tx.send(()); - thread::sleep(Duration::from_millis(250)); + let _ = release_rx + .lock() + .unwrap() + .recv_timeout(server::TEST_WAIT_LIMIT); } builder.set_generation(9); for i in 0..req.item_count { @@ -1530,6 +1472,7 @@ fn test_lookup_abort_during_followup_subcall() { .expect("apps abort result"), Err(NipcError::Aborted) ); + drop(release_tx); server.stop(); cleanup_all(&svc); @@ -1539,10 +1482,15 @@ fn test_lookup_abort_during_followup_subcall() { let calls = Arc::new(std::sync::atomic::AtomicU32::new(0)); let handler_calls = calls.clone(); let (second_tx, second_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel::<()>(); + let release_rx = std::sync::Mutex::new(release_rx); let handler = cgroups_lookup_dispatch(Arc::new(move |req, builder| { if handler_calls.fetch_add(1, Ordering::SeqCst) + 1 == 2 { let _ = second_tx.send(()); - thread::sleep(Duration::from_millis(250)); + let _ = release_rx + .lock() + .unwrap() + .recv_timeout(server::TEST_WAIT_LIMIT); } builder.set_generation(7); for i in 0..req.item_count { @@ -1593,6 +1541,7 @@ fn test_lookup_abort_during_followup_subcall() { .expect("cgroups abort result"), Err(NipcError::Aborted) ); + drop(release_tx); server.stop(); cleanup_all(&svc); } @@ -2492,9 +2441,11 @@ fn test_lookup_logical_limits() { max_subcalls: 0, max_response_bytes: 0, }); - assert!(client - .call_cgroups_lookup(&[b"/a".as_slice(), b"/b".as_slice()]) - .is_err()); + assert!( + client + .call_cgroups_lookup(&[b"/a".as_slice(), b"/b".as_slice()]) + .is_err() + ); let svc = unique_service("rs_svc_apps_lookup_response_limit"); let mut server = TestServer::start(&svc, METHOD_APPS_LOOKUP, Some(test_apps_lookup_dispatch())); @@ -2571,7 +2522,7 @@ fn test_lookup_logical_limits() { client .call_cgroups_lookup(&[b"/request-key-too-large-for-configured-cap".as_slice()]) .unwrap_err(), - crate::protocol::NipcError::BadLayout + crate::protocol::NipcError::NotReady ); } @@ -2617,9 +2568,11 @@ fn test_cgroups_lookup_rejects_mixed_generation_retry() { let mut client = cgroups_lookup_client(&svc, client_config()); connect_ready(&mut client); - assert!(client - .call_cgroups_lookup(&[b"/a".as_slice(), b"/huge".as_slice(), b"/b".as_slice()]) - .is_err()); + assert!( + client + .call_cgroups_lookup(&[b"/a".as_slice(), b"/huge".as_slice(), b"/b".as_slice()]) + .is_err() + ); assert!( calls.load(Ordering::SeqCst) >= 2, "handler should be called for at least two subrequests" @@ -2892,9 +2845,11 @@ fn test_lookup_endpoint_gone_after_partial_progress_unix() { }); let mut client = apps_lookup_client(&svc, client_config()); connect_ready(&mut client); - assert!(client - .call_apps_lookup_with_timeout(&[11, 22, 33], 1_000) - .is_err()); + assert!( + client + .call_apps_lookup_with_timeout(&[11, 22, 33], 1_000) + .is_err() + ); client.close(); server.wait(); cleanup_all(&svc); @@ -2920,12 +2875,14 @@ fn test_lookup_endpoint_gone_after_partial_progress_unix() { }); let mut client = cgroups_lookup_client(&svc, client_config()); connect_ready(&mut client); - assert!(client - .call_cgroups_lookup_with_timeout( - &[b"/a".as_slice(), b"/b".as_slice(), b"/c".as_slice()], - 1_000, - ) - .is_err()); + assert!( + client + .call_cgroups_lookup_with_timeout( + &[b"/a".as_slice(), b"/b".as_slice(), b"/c".as_slice()], + 1_000, + ) + .is_err() + ); client.close(); server.wait(); cleanup_all(&svc); @@ -2938,9 +2895,11 @@ fn test_lookup_endpoint_gone_before_first_subcall_unix() { let mut client = apps_lookup_client(&svc, client_config()); connect_ready(&mut client); server.stop(); - assert!(client - .call_apps_lookup_with_timeout(&[11, 22], 1_000) - .is_err()); + assert!( + client + .call_apps_lookup_with_timeout(&[11, 22], 1_000) + .is_err() + ); client.close(); cleanup_all(&svc); @@ -2953,9 +2912,11 @@ fn test_lookup_endpoint_gone_before_first_subcall_unix() { let mut client = cgroups_lookup_client(&svc, client_config()); connect_ready(&mut client); server.stop(); - assert!(client - .call_cgroups_lookup_with_timeout(&[b"/a".as_slice(), b"/b".as_slice()], 1_000) - .is_err()); + assert!( + client + .call_cgroups_lookup_with_timeout(&[b"/a".as_slice(), b"/b".as_slice()], 1_000) + .is_err() + ); client.close(); cleanup_all(&svc); } @@ -2970,7 +2931,7 @@ fn test_lookup_endpoint_absent_before_call_unix() { let err = client .call_apps_lookup_with_timeout(&[11, 22], 1_000) .expect_err("apps absent lookup should fail"); - assert_eq!(err, NipcError::BadLayout); + assert_eq!(err, NipcError::NotReady); client.close(); cleanup_all(&svc); @@ -2985,7 +2946,7 @@ fn test_lookup_endpoint_absent_before_call_unix() { let err = client .call_cgroups_lookup_with_timeout(&[b"/a".as_slice(), b"/b".as_slice()], 1_000) .expect_err("cgroups absent lookup should fail"); - assert_eq!(err, NipcError::BadLayout); + assert_eq!(err, NipcError::NotReady); client.close(); cleanup_all(&svc); } @@ -3129,7 +3090,7 @@ fn test_lookup_rejects_malformed_typed_responses_unix() { ); } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[test] fn test_cgroups_call_shm() { let svc = "rs_svc_cgroups_shm"; @@ -3157,7 +3118,7 @@ fn test_cgroups_call_shm() { cleanup_all(svc); } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[test] fn test_client_call_string_reverse_shm_success() { let svc = "rs_svc_strrev_shm"; @@ -3184,7 +3145,7 @@ fn test_client_call_string_reverse_shm_success() { cleanup_all(svc); } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[test] fn test_increment_batch_shm() { let svc = "rs_pp_batch_shm"; @@ -3209,21 +3170,18 @@ fn test_increment_batch_shm() { cleanup_all(svc); } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[test] fn test_refresh_shm_attach_failure_falls_back_to_baseline() { let svc = "rs_svc_shm_attach_fail"; ensure_run_dir(); cleanup_all(svc); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); let svc_clone = svc.to_string(); + let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, shm_server_config()) + .expect("bind shm listener"); let server_thread = thread::spawn(move || { - let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, shm_server_config()) - .expect("bind shm listener"); - ready_clone.store(true, Ordering::Release); let mut first = listener.accept().expect("accept negotiated shm session"); assert_eq!(first.selected_profile, PROFILE_SHM_FUTEX); @@ -3244,10 +3202,6 @@ fn test_refresh_shm_attach_failure_falls_back_to_baseline() { ); }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let mut client = snapshot_client(svc, shm_client_config()); assert!( client.refresh(), @@ -3275,7 +3229,7 @@ fn test_refresh_shm_attach_failure_falls_back_to_baseline() { cleanup_all(svc); } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[test] fn test_server_falls_back_to_baseline_when_linux_shm_prepare_fails() { let svc = "rs_svc_shm_upgrade_fail"; @@ -3313,21 +3267,17 @@ fn test_server_falls_back_to_baseline_when_linux_shm_prepare_fails() { cleanup_all(svc); } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[test] fn test_call_increment_shm_short_response_truncated() { let svc = "rs_svc_shm_short_resp"; ensure_run_dir(); cleanup_all(svc); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); let svc_clone = svc.to_string(); + let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, shm_server_config()).expect("bind"); let server_thread = thread::spawn(move || -> Result<(), String> { - let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, shm_server_config()) - .map_err(|e| format!("bind: {e}"))?; - ready_clone.store(true, Ordering::Release); let session = listener.accept().map_err(|e| format!("accept: {e}"))?; if session.selected_profile != PROFILE_SHM_FUTEX { return Err(format!("unexpected profile {}", session.selected_profile)); @@ -3355,10 +3305,6 @@ fn test_call_increment_shm_short_response_truncated() { Ok(()) }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let mut client = increment_client(svc, shm_client_config()); connect_ready(&mut client); assert!(client.shm.is_some(), "expected SHM to be negotiated"); @@ -3377,7 +3323,7 @@ fn test_call_increment_shm_short_response_truncated() { cleanup_all(svc); } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[test] fn test_raw_shm_session_server_wait_panics_on_baseline_profile() { let svc = "rs_svc_shm_helper_bad_profile"; @@ -3401,7 +3347,7 @@ fn test_raw_shm_session_server_wait_panics_on_baseline_profile() { cleanup_all(svc); } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[test] fn test_raw_shm_session_server_wait_panics_on_short_request() { let svc = "rs_svc_shm_helper_short_req"; @@ -3436,21 +3382,17 @@ fn test_raw_shm_session_server_wait_panics_on_short_request() { cleanup_all(svc); } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[test] fn test_call_increment_shm_server_thread_rejects_baseline_profile() { let svc = "rs_svc_shm_short_resp_bad_profile"; ensure_run_dir(); cleanup_all(svc); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); let svc_clone = svc.to_string(); + let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, shm_server_config()).expect("bind"); let server_thread = thread::spawn(move || -> Result<(), String> { - let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, shm_server_config()) - .map_err(|e| format!("bind: {e}"))?; - ready_clone.store(true, Ordering::Release); let session = listener.accept().map_err(|e| format!("accept: {e}"))?; if session.selected_profile != PROFILE_SHM_FUTEX { return Err(format!("unexpected profile {}", session.selected_profile)); @@ -3458,10 +3400,6 @@ fn test_call_increment_shm_server_thread_rejects_baseline_profile() { Ok(()) }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let mut client = increment_client(svc, client_config()); connect_ready(&mut client); client.close(); @@ -3478,21 +3416,17 @@ fn test_call_increment_shm_server_thread_rejects_baseline_profile() { cleanup_all(svc); } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[test] fn test_call_increment_shm_server_thread_rejects_short_request() { let svc = "rs_svc_shm_short_resp_short_req"; ensure_run_dir(); cleanup_all(svc); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); let svc_clone = svc.to_string(); + let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, shm_server_config()).expect("bind"); let server_thread = thread::spawn(move || -> Result<(), String> { - let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, shm_server_config()) - .map_err(|e| format!("bind: {e}"))?; - ready_clone.store(true, Ordering::Release); let session = listener.accept().map_err(|e| format!("accept: {e}"))?; if session.selected_profile != PROFILE_SHM_FUTEX { return Err(format!("unexpected profile {}", session.selected_profile)); @@ -3518,10 +3452,6 @@ fn test_call_increment_shm_server_thread_rejects_short_request() { Ok(()) }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let mut client = increment_client(svc, shm_client_config()); connect_ready(&mut client); assert!(client.shm.is_some(), "expected SHM to be negotiated"); @@ -3542,7 +3472,7 @@ fn test_call_increment_shm_server_thread_rejects_short_request() { cleanup_all(svc); } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[test] fn test_call_increment_shm_rejects_bad_message_id() { let svc = "rs_svc_shm_inc_bad_mid"; @@ -3587,7 +3517,7 @@ fn test_call_increment_shm_rejects_bad_message_id() { cleanup_all(svc); } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[test] fn test_call_string_reverse_shm_rejects_bad_message_id() { let svc = "rs_svc_shm_str_bad_mid"; @@ -3632,7 +3562,7 @@ fn test_call_string_reverse_shm_rejects_bad_message_id() { cleanup_all(svc); } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[test] fn test_call_increment_batch_shm_rejects_bad_message_id() { let svc = "rs_svc_shm_batch_bad_mid"; @@ -3710,7 +3640,6 @@ fn test_retry_on_failure() { // Kill server server1.stop(); cleanup_all(svc); - thread::sleep(Duration::from_millis(50)); // Restart server let mut server2 = @@ -3754,7 +3683,6 @@ fn test_string_reverse_retry_on_failure() { server1.stop(); cleanup_all(svc); - thread::sleep(Duration::from_millis(50)); let mut server2 = TestServer::start( svc, @@ -3797,7 +3725,6 @@ fn test_increment_batch_retry_on_failure() { server1.stop(); cleanup_all(svc); - thread::sleep(Duration::from_millis(50)); let mut server2 = TestServer::start_with( svc, @@ -3845,7 +3772,6 @@ fn test_string_reverse_retry_second_failure() { server1.stop(); cleanup_all(svc); - thread::sleep(Duration::from_millis(50)); let mut server2 = start_raw_session_server(svc, server_config(), move |session, req_hdr, payload| { @@ -3910,7 +3836,6 @@ fn test_increment_batch_retry_second_failure() { server1.stop(); cleanup_all(svc); - thread::sleep(Duration::from_millis(50)); let mut server2 = start_raw_session_server( svc, @@ -3993,21 +3918,28 @@ fn test_server_rejects_session_at_worker_capacity() { cleanup_all(svc); let handler_calls = Arc::new(std::sync::atomic::AtomicU32::new(0)); - let release = Arc::new(AtomicBool::new(false)); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel::<()>(); let handler = { let handler_calls = handler_calls.clone(); - let release = release.clone(); + let entered_tx = std::sync::Mutex::new(entered_tx); + let release_rx = std::sync::Mutex::new(release_rx); increment_dispatch(Arc::new(move |value| { handler_calls.fetch_add(1, Ordering::AcqRel); - while !release.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } + let _ = entered_tx.lock().unwrap().send(()); + let _ = release_rx + .lock() + .unwrap() + .recv_timeout(server::TEST_WAIT_LIMIT); Some(value + 1) })) }; let mut server = TestServer::start_with_workers(svc, METHOD_INCREMENT, Some(handler), 1); + // Rebound after `server` so it drops first: a failed assertion must + // release the blocked handler before dropping the server joins it. + let release_tx = release_tx; let (call_tx, call_rx) = std::sync::mpsc::channel(); let svc_name = svc.to_string(); @@ -4019,67 +3951,39 @@ fn test_server_rejects_session_at_worker_capacity() { call_tx.send(result).expect("send first call result"); }); - for _ in 0..500 { - if handler_calls.load(Ordering::Acquire) >= 1 { - break; - } - thread::sleep(Duration::from_millis(1)); - } - assert_eq!( - handler_calls.load(Ordering::Acquire), - 1, - "first client should occupy the only worker slot" - ); - - let mut session2 = - UdsSession::connect(TEST_RUN_DIR, svc, &client_config()).expect("second connect"); - let mut req_hdr = Header { - kind: KIND_REQUEST, - code: METHOD_INCREMENT, - item_count: 1, - message_id: 1, - ..Header::default() - }; - let mut req_payload = [0u8; INCREMENT_PAYLOAD_SIZE]; - assert_eq!( - increment_encode(9, &mut req_payload), - INCREMENT_PAYLOAD_SIZE, - "IncrementEncode should fill the request buffer" - ); + entered_rx + .recv_timeout(server::TEST_WAIT_LIMIT) + .expect("first client should occupy the only worker slot"); - let send_err = session2.send(&mut req_hdr, &req_payload).err(); - let recv_ok = if send_err.is_none() { - let mut recv_buf = [0u8; HEADER_SIZE + 64]; - session2.receive(&mut recv_buf).is_ok() - } else { - false - }; + // Over-capacity connections are refused before HELLO_ACK, so the + // connect itself fails instead of the first call. + let second = UdsSession::connect(TEST_RUN_DIR, svc, &client_config()); assert!( - !(send_err.is_none() && recv_ok), - "second session should be rejected while the server is at worker capacity" + second.is_err(), + "second connect should be refused while the server is at worker capacity" ); assert_eq!( handler_calls.load(Ordering::Acquire), 1, "second session should not enter the handler while the first session is active" ); - drop(session2); - release.store(true, Ordering::Release); + drop(release_tx); let first_result = call_rx.recv().expect("first call result"); assert_eq!(first_result.expect("first call should succeed"), 42); caller.join().expect("caller join"); - thread::sleep(Duration::from_millis(100)); - + // The worker slot frees once the first session thread exits. + server.events().wait_session_exits(1); let mut verify = increment_client(svc, client_config()); connect_ready(&mut verify); + let verified = verify.call_increment(1); + verify.close(); assert_eq!( - verify.call_increment(1).expect("verification call"), + verified.expect("verification call"), 2, "server should remain healthy after rejecting a session at capacity" ); - verify.close(); server.stop(); cleanup_all(svc); @@ -4087,21 +3991,9 @@ fn test_server_rejects_session_at_worker_capacity() { #[test] fn test_poll_fd_invalid_fd_returns_error() { - let mut fds = [0; 2]; - let rc = unsafe { libc::pipe(fds.as_mut_ptr()) }; - assert_eq!(rc, 0, "pipe failed"); - - let read_fd = fds[0]; - let write_fd = fds[1]; - unsafe { - libc::close(read_fd); - } - - let rc = poll_fd(read_fd, 0); - unsafe { - libc::close(write_fd); - } - + // A just-closed fd can be reused by a parallel test thread before the + // poll; a number far above any open fd always reports POLLNVAL. + let rc = poll_fd(1 << 30, 0); assert_eq!(rc, -1); } @@ -4147,10 +4039,14 @@ fn test_poll_fd_eintr_returns_timeout() { 0 ); - let tid = unsafe { libc::pthread_self() }; + // pthread_t is a pointer on musl and not Send; carry it as an integer. + let tid = unsafe { libc::pthread_self() } as usize; let signaler = thread::spawn(move || { thread::sleep(Duration::from_millis(50)); - assert_eq!(unsafe { libc::pthread_kill(tid, libc::SIGUSR1) }, 0); + assert_eq!( + unsafe { libc::pthread_kill(tid as libc::pthread_t, libc::SIGUSR1) }, + 0 + ); }); let rc = poll_fd(read_fd, 5000); @@ -4174,7 +4070,7 @@ fn test_managed_server_recovers_after_short_uds_request() { let session = UdsSession::connect(TEST_RUN_DIR, svc, &client_config()).expect("connect"); send_raw_packet(session.fd(), &[0xAB]); drop(session); - thread::sleep(Duration::from_millis(50)); + server.events().wait_session_exits(1); verify_increment_service_ok(svc, client_config()); @@ -4193,7 +4089,7 @@ fn test_managed_server_recovers_after_bad_uds_header() { let bad_header = [0u8; HEADER_SIZE]; send_raw_packet(session.fd(), &bad_header); drop(session); - thread::sleep(Duration::from_millis(50)); + server.events().wait_session_exits(1); verify_increment_service_ok(svc, client_config()); @@ -4207,17 +4103,32 @@ fn test_managed_server_recovers_after_uds_peer_closes_before_response() { ensure_run_dir(); cleanup_all(svc); - let handler = increment_dispatch(Arc::new(|value| { - thread::sleep(Duration::from_millis(50)); + // The handler holds the response until the peer is gone, so the worker + // sends into a closed socket. + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel::<()>(); + let entered_tx = std::sync::Mutex::new(entered_tx); + let release_rx = std::sync::Mutex::new(release_rx); + let handler = increment_dispatch(Arc::new(move |value| { + let _ = entered_tx.lock().unwrap().send(()); + let _ = release_rx + .lock() + .unwrap() + .recv_timeout(server::TEST_WAIT_LIMIT); Some(value + 1) })); let mut server = TestServer::start(svc, METHOD_INCREMENT, Some(handler)); + let release_tx = release_tx; let session = UdsSession::connect(TEST_RUN_DIR, svc, &client_config()).expect("connect"); let request = build_increment_request_message(77, 41); send_raw_packet(session.fd(), &request); + entered_rx + .recv_timeout(server::TEST_WAIT_LIMIT) + .expect("handler entered"); assert_eq!(unsafe { libc::shutdown(session.fd(), libc::SHUT_RDWR) }, 0); drop(session); - thread::sleep(Duration::from_millis(100)); + drop(release_tx); + server.events().wait_session_exits(1); verify_increment_service_ok(svc, client_config()); @@ -4225,7 +4136,7 @@ fn test_managed_server_recovers_after_uds_peer_closes_before_response() { cleanup_all(svc); } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[test] fn test_managed_server_recovers_after_short_shm_request() { let svc = "rs_svc_short_shm_req"; @@ -4248,7 +4159,7 @@ fn test_managed_server_recovers_after_short_shm_request() { .send(&[0xAB]) .expect("send short SHM request"); client.close(); - thread::sleep(Duration::from_millis(50)); + server.events().wait_session_exits(1); verify_increment_service_ok(svc, shm_client_config()); @@ -4256,7 +4167,7 @@ fn test_managed_server_recovers_after_short_shm_request() { cleanup_all(svc); } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[test] fn test_managed_server_recovers_after_bad_shm_header() { let svc = "rs_svc_bad_shm_hdr"; @@ -4280,7 +4191,7 @@ fn test_managed_server_recovers_after_bad_shm_header() { .send(&bad_header) .expect("send bad SHM header"); client.close(); - thread::sleep(Duration::from_millis(50)); + server.events().wait_session_exits(1); verify_increment_service_ok(svc, shm_client_config()); @@ -4306,16 +4217,7 @@ fn test_concurrent_clients() { let handle = thread::spawn(move || { let mut client = snapshot_client(&svc_name, client_config()); - // Connect with retry - for _ in 0..100 { - client.refresh(); - if client.ready() { - break; - } - thread::sleep(Duration::from_millis(10)); - } - - assert!(client.ready(), "client must be ready"); + connect_ready(&mut client); let mut successes = 0usize; for _ in 0..REQUESTS_PER { @@ -4445,7 +4347,7 @@ fn test_non_request_terminates_session() { if send_result.is_ok() { // But subsequent communication should fail because the // server terminated the session - thread::sleep(Duration::from_millis(100)); + server.events().wait_session_exits(1); let mut recv_buf = vec![0u8; 4096]; // Try to send a valid request and receive - should fail let mut hdr2 = Header { @@ -4565,7 +4467,6 @@ fn test_cache_refresh_failure_preserves() { // Kill server server.stop(); cleanup_all(svc); - thread::sleep(Duration::from_millis(50)); // Refresh fails, but old cache is preserved let updated = cache.refresh(); @@ -4657,7 +4558,6 @@ fn test_cache_reconnect_rebuilds() { // Kill and restart server server1.stop(); cleanup_all(svc); - thread::sleep(Duration::from_millis(50)); let mut server2 = TestServer::start(svc, METHOD_CGROUPS_SNAPSHOT, Some(test_cgroups_dispatch())); @@ -4933,8 +4833,7 @@ fn cache_dup(cache: &CgroupsCache, hash: u32, name: &str) -> Option, - thread: Option>, + inner: server::RunningTestServer, } impl StressTestServer { @@ -4943,8 +4842,6 @@ impl StressTestServer { cleanup_all(service); let svc = service.to_string(); - let ready_flag = Arc::new(AtomicBool::new(false)); - let ready_clone = ready_flag.clone(); let mut scfg = server_config(); scfg.max_response_payload_bytes = resp_buf_size as u32; @@ -4975,49 +4872,25 @@ impl StressTestServer { n, ); - let mut server = ManagedServer::new( + let server = ManagedServer::new( TEST_RUN_DIR, &svc, scfg, METHOD_CGROUPS_SNAPSHOT, Some(handler), ); - let stop_flag = server.running_flag(); - - let thread = thread::spawn(move || { - ready_clone.store(true, Ordering::Release); - let _ = server.run(); - }); - - for _ in 0..2000 { - if ready_flag.load(Ordering::Acquire) { - break; - } - thread::sleep(Duration::from_micros(500)); - } - thread::sleep(Duration::from_millis(50)); - StressTestServer { - stop_flag, - thread: Some(thread), + inner: server.spawn_for_test(), } } fn stop(&mut self) { - self.stop_flag.store(false, Ordering::Release); - if let Some(t) = self.thread.take() { - let _ = t.join(); - } - } -} - -impl Drop for StressTestServer { - fn drop(&mut self) { - self.stop(); + self.inner.stop(); } } #[test] +#[ignore = "stress: run by tests/run-extended-stress.sh"] fn test_stress_1000_items() { let svc = "rs_stress_1k"; @@ -5075,6 +4948,7 @@ fn test_stress_1000_items() { } #[test] +#[ignore = "stress: run by tests/run-extended-stress.sh"] fn test_stress_5000_items() { let svc = "rs_stress_5k"; @@ -5117,6 +4991,7 @@ fn test_stress_5000_items() { } #[test] +#[ignore = "stress: run by tests/run-extended-stress.sh"] fn test_stress_concurrent_clients() { let svc = "rs_stress_concurrent"; ensure_run_dir(); @@ -5194,6 +5069,7 @@ fn test_stress_concurrent_clients() { } #[test] +#[ignore = "stress: run by tests/run-extended-stress.sh"] fn test_stress_rapid_connect_disconnect() { let svc = "rs_stress_rapid"; ensure_run_dir(); @@ -5256,6 +5132,7 @@ fn test_stress_rapid_connect_disconnect() { } #[test] +#[ignore = "stress: run by tests/run-extended-stress.sh"] fn test_stress_cache_concurrent() { let svc = "rs_stress_cache"; ensure_run_dir(); @@ -5318,6 +5195,7 @@ fn test_stress_cache_concurrent() { } #[test] +#[ignore = "stress: run by tests/run-extended-stress.sh"] fn test_stress_long_running() { let svc = "rs_stress_long"; ensure_run_dir(); @@ -5508,31 +5386,15 @@ fn test_increment_batch() { // Start server with batch-capable config let svc_name = svc.to_string(); - let ready_flag = Arc::new(AtomicBool::new(false)); - let ready_clone = ready_flag.clone(); - - let mut server_obj = ManagedServer::with_workers( + let mut server = ManagedServer::with_workers( TEST_RUN_DIR, &svc_name, batch_server_config(), METHOD_INCREMENT, Some(increment_dispatch_handler()), 8, - ); - let stop_flag = server_obj.running_flag(); - - let thread_handle = thread::spawn(move || { - ready_clone.store(true, Ordering::Release); - let _ = server_obj.run(); - }); - - for _ in 0..2000 { - if ready_flag.load(Ordering::Acquire) { - break; - } - thread::sleep(Duration::from_micros(500)); - } - thread::sleep(Duration::from_millis(50)); + ) + .spawn_for_test(); let mut client = increment_client(svc, batch_client_config()); client.refresh(); @@ -5563,8 +5425,7 @@ fn test_increment_batch() { assert!(empty.is_empty()); client.close(); - stop_flag.store(false, Ordering::Release); - let _ = thread_handle.join(); + server.stop(); cleanup_all(svc); } @@ -5613,11 +5474,11 @@ fn test_client_incompatible() { // Client requires SHM_FUTEX only (no baseline) let mut bad_cfg = client_config(); - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] { bad_cfg.supported_profiles = crate::protocol::PROFILE_SHM_FUTEX; } - #[cfg(not(target_os = "linux"))] + #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] { // On non-Linux, use a profile bit that won't match bad_cfg.supported_profiles = 0x80000000; @@ -6408,31 +6269,15 @@ fn test_batch_dispatch_handler_failure() { } let svc_name = svc.to_string(); - let ready_flag = Arc::new(AtomicBool::new(false)); - let ready_clone = ready_flag.clone(); - - let mut server_obj = ManagedServer::with_workers( + let mut server = ManagedServer::with_workers( TEST_RUN_DIR, &svc_name, batch_server_config(), METHOD_INCREMENT, Some(increment_dispatch(fail_second_increment_handler())), 8, - ); - let stop_flag = server_obj.running_flag(); - - let thread_handle = thread::spawn(move || { - ready_clone.store(true, Ordering::Release); - let _ = server_obj.run(); - }); - - for _ in 0..2000 { - if ready_flag.load(Ordering::Acquire) { - break; - } - thread::sleep(Duration::from_micros(500)); - } - thread::sleep(Duration::from_millis(50)); + ) + .spawn_for_test(); let mut client = increment_client(svc, batch_client_config()); client.refresh(); @@ -6445,8 +6290,7 @@ fn test_batch_dispatch_handler_failure() { assert!(result.is_err()); client.close(); - stop_flag.store(false, Ordering::Release); - let _ = thread_handle.join(); + server.stop(); cleanup_all(svc); } @@ -6487,7 +6331,7 @@ fn test_batch_dispatch_builder_overflow_respects_configured_ceiling() { cleanup_all(svc); } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[test] fn test_shm_batch_request_item_decode_failure_returns_bad_envelope() { let svc = "rs_svc_shm_batch_bad_item"; @@ -6554,3 +6398,124 @@ fn test_shm_batch_request_item_decode_failure_returns_bad_envelope() { server.stop(); cleanup_all(svc); } + +#[path = "raw_robustness_unix_tests.rs"] +mod robustness; + +#[test] +fn test_send_failure_with_live_peer_is_transport_unix() { + ensure_run_dir(); + let svc = unique_service("rs_unix_send_live_peer"); + cleanup_all(&svc); + + // The server accepts one session, then removes the endpoint so the + // client cannot reconnect, and keeps the session open until released. + let listener = UdsListener::bind(TEST_RUN_DIR, &svc, server_config()).expect("bind"); + let (accepted_tx, accepted_rx) = mpsc::channel::<()>(); + let (release_tx, release_rx) = mpsc::channel::<()>(); + let server = thread::spawn(move || { + let session = listener.accept().expect("accept"); + drop(listener); + accepted_tx.send(()).expect("signal accepted"); + let _ = release_rx.recv(); + drop(session); + }); + + let mut client = increment_client(&svc, client_config()); + client.refresh(); + assert_eq!(client.state, ClientState::Ready); + accepted_rx.recv().expect("server accepted"); + + // Our own write shutdown makes send fail with EPIPE while the server + // still holds the session open. + let fd = client.session.as_ref().expect("session").fd(); + assert_eq!(unsafe { libc::shutdown(fd, libc::SHUT_WR) }, 0); + assert_eq!(client.call_increment(1), Err(NipcError::Transport)); + + release_tx.send(()).expect("release server"); + client.close(); + server.join().expect("server thread"); + cleanup_all(&svc); +} + +#[test] +fn test_transport_without_session_unix() { + let svc = unique_service("rs_unix_transport"); + let mut client = increment_client(&svc, client_config()); + + let mut hdr = Header { + kind: KIND_REQUEST, + code: METHOD_INCREMENT, + flags: 0, + item_count: 1, + message_id: 1, + transport_status: STATUS_OK, + ..Header::default() + }; + + assert_eq!( + client.transport_send(&mut hdr, &[]), + Err(NipcError::Transport) + ); + assert!(matches!( + client.transport_receive(0), + Err(NipcError::Transport) + )); + client.close(); +} + +/// The server creates a session's SHM region only when the handshake selects +/// SHM, and falls back to baseline in the same handshake when the region +/// cannot be created. +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +#[test] +fn test_shm_region_only_for_shm_sessions_unix() { + let svc = unique_service("rs_unix_shm_after_negotiation"); + let mut server = TestServer::start_with( + &svc, + shm_server_config(), + METHOD_INCREMENT, + Some(increment_dispatch_handler()), + 8, + ); + + let mut client = increment_client(&svc, client_config()); + client.refresh(); + assert_eq!(client.state, ClientState::Ready); + assert!(client.shm.is_none()); + assert_eq!(client.call_increment(1), Ok(2)); + assert_eq!(server.events().take_shm_created(), 0, "baseline session"); + client.close(); + + let mut bad_auth = shm_client_config(); + bad_auth.auth_token = AUTH_TOKEN + 1; + let mut client = increment_client(&svc, bad_auth); + client.refresh(); + assert_eq!(client.state, ClientState::AuthFailed); + assert_eq!(server.events().take_shm_created(), 0, "rejected client"); + client.close(); + + let mut client = increment_client(&svc, shm_client_config()); + client.refresh(); + assert_eq!(client.state, ClientState::Ready); + assert!(client.shm.is_some(), "expected SHM to be negotiated"); + assert_eq!(client.call_increment(1), Ok(2)); + assert_eq!(server.events().take_shm_created(), 1, "SHM session"); + client.close(); + + server.events().set_fail_shm_create(true); + let mut client = increment_client(&svc, shm_client_config()); + client.refresh(); + assert_eq!(client.state, ClientState::Ready); + assert!(client.shm.is_none(), "expected the baseline fallback"); + assert_eq!( + client.session.as_ref().map(|s| s.selected_profile), + Some(PROFILE_BASELINE) + ); + server.events().set_fail_shm_create(false); + assert_eq!(client.call_increment(1), Ok(2)); + assert_eq!(server.events().take_shm_created(), 0, "failed creation"); + client.close(); + + server.stop(); +} diff --git a/src/crates/netipc/src/service/raw_windows_tests.rs b/src/crates/netipc/src/service/raw_windows_tests.rs index ed7dd445..49129a7b 100644 --- a/src/crates/netipc/src/service/raw_windows_tests.rs +++ b/src/crates/netipc/src/service/raw_windows_tests.rs @@ -1,22 +1,23 @@ use super::*; use crate::protocol::{ - increment_encode, AppsLookupBuilder, AppsLookupRequestView, AppsLookupResponseView, - BatchBuilder, CgroupsBuilder, CgroupsLookupBuilder, CgroupsLookupRequestView, - CgroupsLookupResponseView, CgroupsRequest, Header, HelloAck, NipcError, APPS_CGROUP_HOST_ROOT, - APPS_CGROUP_KNOWN, APPS_LOOKUP_RESP_HDR_SIZE, CGROUPS_LOOKUP_RESP_HDR_SIZE, - CGROUP_LOOKUP_KNOWN, CGROUP_LOOKUP_OVERSIZED_ITEM, CGROUP_LOOKUP_PAYLOAD_EXCEEDED, - CGROUP_LOOKUP_UNKNOWN_RETRY_LATER, CODE_HELLO_ACK, FLAG_BATCH, HEADER_SIZE, - INCREMENT_PAYLOAD_SIZE, KIND_CONTROL, KIND_REQUEST, KIND_RESPONSE, MAX_PAYLOAD_CAP, - METHOD_APPS_LOOKUP, METHOD_CGROUPS_LOOKUP, METHOD_CGROUPS_SNAPSHOT, METHOD_INCREMENT, - METHOD_STRING_REVERSE, NIPC_UID_UNSET, ORCHESTRATOR_DOCKER, ORCHESTRATOR_K8S, PID_LOOKUP_KNOWN, + APPS_CGROUP_HOST_ROOT, APPS_CGROUP_KNOWN, APPS_LOOKUP_RESP_HDR_SIZE, AppsLookupBuilder, + AppsLookupRequestView, AppsLookupResponseView, BatchBuilder, CGROUP_LOOKUP_KNOWN, + CGROUP_LOOKUP_OVERSIZED_ITEM, CGROUP_LOOKUP_PAYLOAD_EXCEEDED, + CGROUP_LOOKUP_UNKNOWN_RETRY_LATER, CGROUPS_LOOKUP_RESP_HDR_SIZE, CODE_HELLO_ACK, + CgroupsBuilder, CgroupsLookupBuilder, CgroupsLookupRequestView, CgroupsLookupResponseView, + CgroupsRequest, FLAG_BATCH, HEADER_SIZE, Header, HelloAck, INCREMENT_PAYLOAD_SIZE, + KIND_CONTROL, KIND_REQUEST, KIND_RESPONSE, MAX_PAYLOAD_CAP, METHOD_APPS_LOOKUP, + METHOD_CGROUPS_LOOKUP, METHOD_CGROUPS_SNAPSHOT, METHOD_INCREMENT, METHOD_STRING_REVERSE, + NIPC_UID_UNSET, NipcError, ORCHESTRATOR_DOCKER, ORCHESTRATOR_K8S, PID_LOOKUP_KNOWN, PID_LOOKUP_OVERSIZED_ITEM, PID_LOOKUP_PAYLOAD_EXCEEDED, PID_LOOKUP_UNKNOWN, PROFILE_BASELINE, PROFILE_SHM_HYBRID, STATUS_INTERNAL_ERROR, STATUS_LIMIT_EXCEEDED, STATUS_OK, VERSION, + increment_encode, }; use crate::transport::windows::build_pipe_name; use std::ptr; +use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::mpsc; -use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; @@ -122,23 +123,33 @@ fn batch_client_config() -> ClientConfig { } fn snapshot_client(service: &str, config: ClientConfig) -> RawClient { - RawClient::new_snapshot(TEST_RUN_DIR, service, config) + let mut client = RawClient::new_snapshot(TEST_RUN_DIR, service, config); + client.use_test_timings(); + client } fn increment_client(service: &str, config: ClientConfig) -> RawClient { - RawClient::new_increment(TEST_RUN_DIR, service, config) + let mut client = RawClient::new_increment(TEST_RUN_DIR, service, config); + client.use_test_timings(); + client } fn string_reverse_client(service: &str, config: ClientConfig) -> RawClient { - RawClient::new_string_reverse(TEST_RUN_DIR, service, config) + let mut client = RawClient::new_string_reverse(TEST_RUN_DIR, service, config); + client.use_test_timings(); + client } fn cgroups_lookup_client(service: &str, config: ClientConfig) -> RawClient { - RawClient::new_cgroups_lookup(TEST_RUN_DIR, service, config) + let mut client = RawClient::new_cgroups_lookup(TEST_RUN_DIR, service, config); + client.use_test_timings(); + client } fn apps_lookup_client(service: &str, config: ClientConfig) -> RawClient { - RawClient::new_apps_lookup(TEST_RUN_DIR, service, config) + let mut client = RawClient::new_apps_lookup(TEST_RUN_DIR, service, config); + client.use_test_timings(); + client } fn fill_test_cgroups_snapshot(builder: &mut CgroupsBuilder<'_>) -> bool { @@ -276,36 +287,26 @@ fn apps_lookup_dispatch_handler() -> DispatchHandler { })) } +/// The server listens before tests connect, so one refresh must succeed. fn connect_ready(client: &mut RawClient) { - for _ in 0..200 { - client.refresh(); - if client.ready() { - return; - } - thread::sleep(Duration::from_millis(10)); - } - - panic!("client did not reach READY state"); + client.refresh(); + assert!(client.ready(), "client did not reach READY state"); } +/// The server listens before tests connect, so one refresh must reach `want`. fn wait_for_state(client: &mut RawClient, want: ClientState) { - for _ in 0..200 { - client.refresh(); - if client.state == want { - return; - } - thread::sleep(Duration::from_millis(10)); - } - - panic!("client did not reach state {:?}", want); + client.refresh(); + assert_eq!(client.state, want, "client did not reach state {want:?}"); } #[test] fn test_client_call_timeout_on_wedged_peer() { let svc = unique_service("rs_win_call_timeout"); + let (release_tx, release_rx) = mpsc::channel::<()>(); let mut server = start_raw_session_server(&svc, server_config(), move |_session, _hdr, _payload| { - thread::sleep(Duration::from_millis(150)); + // Never answer; the test releases the peer once the call timed out. + let _ = release_rx.recv_timeout(server::TEST_WAIT_LIMIT); Ok(()) }); @@ -324,6 +325,7 @@ fn test_client_call_timeout_on_wedged_peer() { ); client.close(); + drop(release_tx); server.wait(); cleanup_all(&svc); } @@ -370,10 +372,7 @@ fn test_client_abort_unblocks_call() { } struct TestServer { - service: String, - wake_config: ClientConfig, - stop_flag: Arc, - thread: Option>, + inner: server::RunningTestServer, } impl TestServer { @@ -413,57 +412,32 @@ impl TestServer { fn start_with( service: &str, config: ServerConfig, - wake_config: ClientConfig, + _client_config: ClientConfig, expected_method_code: u16, handler: DispatchHandler, worker_count: usize, ) -> Self { ensure_run_dir(); cleanup_all(service); - - let svc = service.to_string(); - let stop_cfg = wake_config.clone(); - let mut server = ManagedServer::with_workers( + let server = ManagedServer::with_workers( TEST_RUN_DIR, - &svc, + service, config, expected_method_code, Some(handler), worker_count, ); - let stop_flag = server.running_flag(); - - let thread = thread::spawn(move || { - let _ = server.run(); - }); - thread::sleep(Duration::from_millis(50)); - TestServer { - service: svc, - wake_config: stop_cfg, - stop_flag, - thread: Some(thread), + inner: server.spawn_for_test(), } } - fn stop(&mut self) { - self.stop_flag.store(false, Ordering::Release); - - // Wake a blocking ConnectNamedPipe() so the accept loop can observe - // the stop flag and exit. - let _ = NpSession::connect(TEST_RUN_DIR, &self.service, &self.wake_config); - - if let Some(handle) = self.thread.take() { - let _ = handle.join(); - } - - cleanup_all(&self.service); + fn events(&self) -> &server::TestEvents { + &self.inner.events } -} -impl Drop for TestServer { - fn drop(&mut self) { - self.stop(); + fn stop(&mut self) { + self.inner.stop(); } } @@ -565,27 +539,29 @@ fn start_raw_hello_ack_version_server(service: &str, version: u16) -> RawHelloAc let svc = service.to_string(); let packet = encode_hello_ack_packet_with_version(version, STATUS_OK, 1); - let thread = thread::spawn(move || { - let pipe_name = - build_pipe_name(TEST_RUN_DIR, &svc).map_err(|e| format!("pipe name: {e}"))?; - let pipe = unsafe { - CreateNamedPipeW( - pipe_name.as_ptr(), - PIPE_ACCESS_DUPLEX, - PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, - 1, - RAW_PIPE_PACKET_SIZE, - RAW_PIPE_PACKET_SIZE, - 0, - ptr::null(), - ) - }; - if pipe == INVALID_HANDLE_VALUE { - return Err(format!("CreateNamedPipeW failed: {}", unsafe { - GetLastError() - })); - } + // Create the pipe before returning so the client's first connect cannot + // race it. + let pipe_name = build_pipe_name(TEST_RUN_DIR, &svc).expect("pipe name"); + let pipe = unsafe { + CreateNamedPipeW( + pipe_name.as_ptr(), + PIPE_ACCESS_DUPLEX, + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, + 1, + RAW_PIPE_PACKET_SIZE, + RAW_PIPE_PACKET_SIZE, + 0, + ptr::null(), + ) + }; + assert_ne!( + pipe, + INVALID_HANDLE_VALUE, + "CreateNamedPipeW failed: {}", + unsafe { GetLastError() } + ); + let thread = thread::spawn(move || { let connect_ok = unsafe { ConnectNamedPipe(pipe, ptr::null_mut()) }; if connect_ok == 0 { let err = unsafe { GetLastError() }; @@ -638,7 +614,6 @@ fn start_raw_hello_ack_version_server(service: &str, version: u16) -> RawHelloAc Ok(()) }); - thread::sleep(Duration::from_millis(200)); RawHelloAckServer { accepted, thread: Some(thread), @@ -670,9 +645,8 @@ where cleanup_all(service); let svc = service.to_string(); + let mut listener = NpListener::bind(TEST_RUN_DIR, &svc, cfg).expect("bind"); let thread = thread::spawn(move || { - let mut listener = - NpListener::bind(TEST_RUN_DIR, &svc, cfg).map_err(|e| format!("bind: {e}"))?; let mut session = listener.accept().map_err(|e| format!("accept: {e}"))?; let (hdr, payload) = { @@ -686,7 +660,6 @@ where handler(&mut session, hdr, &payload) }); - thread::sleep(Duration::from_millis(200)); RawSessionServer { thread: Some(thread), } @@ -709,10 +682,8 @@ fn start_raw_overflow_no_growth_server(service: &str, cfg: ServerConfig) -> RawS cleanup_all(service); let svc = service.to_string(); + let mut listener = NpListener::bind(TEST_RUN_DIR, &svc, cfg).expect("bind"); let thread = thread::spawn(move || { - let mut listener = - NpListener::bind(TEST_RUN_DIR, &svc, cfg).map_err(|e| format!("bind: {e}"))?; - let mut first = listener .accept() .map_err(|e| format!("first accept: {e}"))?; @@ -745,7 +716,6 @@ fn start_raw_overflow_no_growth_server(service: &str, cfg: ServerConfig) -> RawS Ok(()) }); - thread::sleep(Duration::from_millis(200)); RawSessionServer { thread: Some(thread), } @@ -1434,9 +1404,14 @@ fn test_lookup_timeout_during_followup_subcall_windows() { cfg.max_response_payload_bytes = 320; let calls = Arc::new(AtomicU32::new(0)); let handler_calls = calls.clone(); + let (release_tx, release_rx) = mpsc::channel::<()>(); + let release_rx = std::sync::Mutex::new(release_rx); let handler = apps_lookup_dispatch(Arc::new(move |req, builder| { if handler_calls.fetch_add(1, Ordering::SeqCst) + 1 == 2 { - thread::sleep(Duration::from_millis(150)); + let _ = release_rx + .lock() + .unwrap() + .recv_timeout(server::TEST_WAIT_LIMIT); } builder.set_generation(9); for i in 0..req.item_count { @@ -1476,13 +1451,16 @@ fn test_lookup_timeout_during_followup_subcall_windows() { TestServer::start_with(&svc, cfg, client_config(), METHOD_APPS_LOOKUP, handler, 8); let mut client = apps_lookup_client(&svc, client_config()); connect_ready(&mut client); - let err = match client.call_apps_lookup_with_timeout(&[11, 22, 33], 30) { + // The budget leaves slow or emulated runners room for the first subcall; + // only the much slower second subcall can expire it. + let err = match client.call_apps_lookup_with_timeout(&[11, 22, 33], 500) { Ok(_) => panic!("apps follow-up timeout should fail"), Err(err) => err, }; assert_eq!(err, NipcError::Timeout); assert!(calls.load(Ordering::SeqCst) >= 2); client.close(); + drop(release_tx); server.stop(); let svc = unique_service("rs_win_cgroups_lookup_followup_timeout"); @@ -1490,9 +1468,14 @@ fn test_lookup_timeout_during_followup_subcall_windows() { cfg.max_response_payload_bytes = 160; let calls = Arc::new(AtomicU32::new(0)); let handler_calls = calls.clone(); + let (release_tx, release_rx) = mpsc::channel::<()>(); + let release_rx = std::sync::Mutex::new(release_rx); let handler = cgroups_lookup_dispatch(Arc::new(move |req, builder| { if handler_calls.fetch_add(1, Ordering::SeqCst) + 1 == 2 { - thread::sleep(Duration::from_millis(150)); + let _ = release_rx + .lock() + .unwrap() + .recv_timeout(server::TEST_WAIT_LIMIT); } builder.set_generation(7); for i in 0..req.item_count { @@ -1533,13 +1516,16 @@ fn test_lookup_timeout_during_followup_subcall_windows() { let mut client = cgroups_lookup_client(&svc, client_config()); connect_ready(&mut client); let paths: [&[u8]; 3] = [b"/a".as_slice(), b"/huge".as_slice(), b"/b".as_slice()]; - let err = match client.call_cgroups_lookup_with_timeout(&paths, 30) { + // The budget leaves slow or emulated runners room for the first subcall; + // only the much slower second subcall can expire it. + let err = match client.call_cgroups_lookup_with_timeout(&paths, 500) { Ok(_) => panic!("cgroups follow-up timeout should fail"), Err(err) => err, }; assert_eq!(err, NipcError::Timeout); assert!(calls.load(Ordering::SeqCst) >= 2); client.close(); + drop(release_tx); server.stop(); } @@ -1551,10 +1537,15 @@ fn test_lookup_abort_during_followup_subcall_windows() { let calls = Arc::new(AtomicU32::new(0)); let handler_calls = calls.clone(); let (second_tx, second_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel::<()>(); + let release_rx = std::sync::Mutex::new(release_rx); let handler = apps_lookup_dispatch(Arc::new(move |req, builder| { if handler_calls.fetch_add(1, Ordering::SeqCst) + 1 == 2 { let _ = second_tx.send(()); - thread::sleep(Duration::from_millis(250)); + let _ = release_rx + .lock() + .unwrap() + .recv_timeout(server::TEST_WAIT_LIMIT); } builder.set_generation(9); for i in 0..req.item_count { @@ -1611,6 +1602,7 @@ fn test_lookup_abort_during_followup_subcall_windows() { .expect("apps abort result"), Err(NipcError::Aborted) ); + drop(release_tx); server.stop(); let svc = unique_service("rs_win_cgroups_lookup_followup_abort"); @@ -1619,10 +1611,15 @@ fn test_lookup_abort_during_followup_subcall_windows() { let calls = Arc::new(AtomicU32::new(0)); let handler_calls = calls.clone(); let (second_tx, second_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel::<()>(); + let release_rx = std::sync::Mutex::new(release_rx); let handler = cgroups_lookup_dispatch(Arc::new(move |req, builder| { if handler_calls.fetch_add(1, Ordering::SeqCst) + 1 == 2 { let _ = second_tx.send(()); - thread::sleep(Duration::from_millis(250)); + let _ = release_rx + .lock() + .unwrap() + .recv_timeout(server::TEST_WAIT_LIMIT); } builder.set_generation(7); for i in 0..req.item_count { @@ -1680,6 +1677,7 @@ fn test_lookup_abort_during_followup_subcall_windows() { .expect("cgroups abort result"), Err(NipcError::Aborted) ); + drop(release_tx); server.stop(); } @@ -2273,9 +2271,11 @@ fn test_lookup_logical_limits_windows() { max_subcalls: 0, max_response_bytes: 0, }); - assert!(client - .call_cgroups_lookup(&[b"/a".as_slice(), b"/b".as_slice()]) - .is_err()); + assert!( + client + .call_cgroups_lookup(&[b"/a".as_slice(), b"/b".as_slice()]) + .is_err() + ); let svc = unique_service("rs_win_apps_lookup_response_limit"); let mut server = TestServer::start(&svc, METHOD_APPS_LOOKUP, apps_lookup_dispatch_handler()); @@ -2352,7 +2352,7 @@ fn test_lookup_logical_limits_windows() { client .call_cgroups_lookup(&[b"/request-key-too-large-for-configured-cap".as_slice()]) .unwrap_err(), - NipcError::BadLayout + NipcError::NotReady ); } @@ -2405,9 +2405,11 @@ fn test_cgroups_lookup_rejects_mixed_generation_retry_windows() { let mut client = cgroups_lookup_client(&svc, client_config()); connect_ready(&mut client); - assert!(client - .call_cgroups_lookup(&[b"/a".as_slice(), b"/huge".as_slice(), b"/b".as_slice()]) - .is_err()); + assert!( + client + .call_cgroups_lookup(&[b"/a".as_slice(), b"/huge".as_slice(), b"/b".as_slice()]) + .is_err() + ); assert!( calls.load(Ordering::SeqCst) >= 2, "handler should be called for at least two subrequests" @@ -2691,9 +2693,11 @@ fn test_lookup_endpoint_gone_after_partial_progress_windows() { }); let mut client = apps_lookup_client(&svc, client_config()); connect_ready(&mut client); - assert!(client - .call_apps_lookup_with_timeout(&[11, 22, 33], 1_000) - .is_err()); + assert!( + client + .call_apps_lookup_with_timeout(&[11, 22, 33], 1_000) + .is_err() + ); client.close(); server.wait(); @@ -2718,12 +2722,14 @@ fn test_lookup_endpoint_gone_after_partial_progress_windows() { }); let mut client = cgroups_lookup_client(&svc, client_config()); connect_ready(&mut client); - assert!(client - .call_cgroups_lookup_with_timeout( - &[b"/a".as_slice(), b"/b".as_slice(), b"/c".as_slice()], - 1_000, - ) - .is_err()); + assert!( + client + .call_cgroups_lookup_with_timeout( + &[b"/a".as_slice(), b"/b".as_slice(), b"/c".as_slice()], + 1_000, + ) + .is_err() + ); client.close(); server.wait(); } @@ -2735,9 +2741,11 @@ fn test_lookup_endpoint_gone_before_first_subcall_windows() { let mut client = apps_lookup_client(&svc, client_config()); connect_ready(&mut client); server.stop(); - assert!(client - .call_apps_lookup_with_timeout(&[11, 22], 1_000) - .is_err()); + assert!( + client + .call_apps_lookup_with_timeout(&[11, 22], 1_000) + .is_err() + ); client.close(); let svc = unique_service("rs_win_cgroups_lookup_gone_before_call"); @@ -2749,9 +2757,11 @@ fn test_lookup_endpoint_gone_before_first_subcall_windows() { let mut client = cgroups_lookup_client(&svc, client_config()); connect_ready(&mut client); server.stop(); - assert!(client - .call_cgroups_lookup_with_timeout(&[b"/a".as_slice(), b"/b".as_slice()], 1_000) - .is_err()); + assert!( + client + .call_cgroups_lookup_with_timeout(&[b"/a".as_slice(), b"/b".as_slice()], 1_000) + .is_err() + ); client.close(); } @@ -2764,7 +2774,7 @@ fn test_lookup_endpoint_absent_before_call_windows() { let err = client .call_apps_lookup_with_timeout(&[11, 22], 1_000) .expect_err("apps absent lookup should fail"); - assert_eq!(err, NipcError::BadLayout); + assert_eq!(err, NipcError::NotReady); client.close(); let svc = unique_service("rs_win_cgroups_lookup_absent"); @@ -2777,7 +2787,7 @@ fn test_lookup_endpoint_absent_before_call_windows() { let err = client .call_cgroups_lookup_with_timeout(&[b"/a".as_slice(), b"/b".as_slice()], 1_000) .expect_err("cgroups absent lookup should fail"); - assert_eq!(err, NipcError::BadLayout); + assert_eq!(err, NipcError::NotReady); client.close(); } @@ -2950,7 +2960,6 @@ fn test_retry_on_failure_windows() { stop_rx.recv_timeout(Duration::from_secs(2)).is_ok(), "server stop should not hang with an active client session" ); - thread::sleep(Duration::from_millis(100)); let mut server2 = TestServer::start(&svc, METHOD_CGROUPS_SNAPSHOT, test_cgroups_dispatch()); @@ -2980,7 +2989,7 @@ fn test_non_request_terminates_session_windows() { }; let send_result = session.send(&mut hdr, &[]); if send_result.is_ok() { - thread::sleep(Duration::from_millis(100)); + server.events().wait_session_exits(1); let mut recv_buf = vec![0u8; 4096]; let mut hdr2 = Header { kind: KIND_REQUEST, @@ -3112,14 +3121,8 @@ fn test_refresh_winshm_attach_failure_falls_back_to_baseline() { ensure_run_dir(); cleanup_all(&svc); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); - let server_svc = svc.clone(); + let mut listener = NpListener::bind(TEST_RUN_DIR, &svc, shm_server_config()).expect("bind"); let server_thread = thread::spawn(move || -> Result<(), String> { - let mut listener = NpListener::bind(TEST_RUN_DIR, &server_svc, shm_server_config()) - .map_err(|e| format!("bind: {e}"))?; - ready_clone.store(true, Ordering::Release); - let mut first = listener .accept() .map_err(|e| format!("accept first: {e}"))?; @@ -3152,10 +3155,6 @@ fn test_refresh_winshm_attach_failure_falls_back_to_baseline() { Ok(()) }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let mut client = increment_client(&svc, shm_client_config()); assert!( client.refresh(), @@ -3293,11 +3292,11 @@ fn test_transport_without_session_windows() { assert_eq!( client.transport_send(&mut hdr, &[]), - Err(NipcError::Truncated) + Err(NipcError::Transport) ); assert!(matches!( client.transport_receive(0), - Err(NipcError::Truncated) + Err(NipcError::Transport) )); client.close(); } @@ -3486,3 +3485,85 @@ fn test_call_increment_batch_rejects_wrong_item_count_windows() { client.close(); server.wait(); } + +#[path = "raw_robustness_windows_tests.rs"] +mod robustness; + +/// The server creates a session's SHM objects only when the handshake +/// selects SHM, and falls back to baseline in the same handshake when they +/// cannot be created. +#[test] +fn test_untrusted_server_is_auth_failed_windows() { + use crate::transport::win_security::TRUSTED_OWNER_FOR_TESTS; + let svc = unique_service("rs_win_untrusted"); + let mut server = TestServer::start_with( + &svc, + server_config(), + client_config(), + METHOD_INCREMENT, + increment_dispatch_handler(), + 8, + ); + + // BUILTIN\Users never owns the test pipe. + TRUSTED_OWNER_FOR_TESTS.with(|t| *t.borrow_mut() = Some("S-1-5-32-545".into())); + let mut client = increment_client(&svc, client_config()); + client.refresh(); + TRUSTED_OWNER_FOR_TESTS.with(|t| *t.borrow_mut() = None); + assert_eq!(client.state, ClientState::AuthFailed); + client.close(); + server.stop(); +} + +#[test] +fn test_shm_region_only_for_shm_sessions_windows() { + let svc = unique_service("rs_win_shm_after_negotiation"); + let mut server = TestServer::start_with( + &svc, + shm_server_config(), + shm_client_config(), + METHOD_INCREMENT, + increment_dispatch_handler(), + 8, + ); + + let mut client = increment_client(&svc, client_config()); + client.refresh(); + assert_eq!(client.state, ClientState::Ready); + assert!(client.shm.is_none()); + assert_eq!(client.call_increment(1), Ok(2)); + assert_eq!(server.events().take_shm_created(), 0, "baseline session"); + client.close(); + + let mut bad_auth = shm_client_config(); + bad_auth.auth_token = AUTH_TOKEN + 1; + let mut client = increment_client(&svc, bad_auth); + client.refresh(); + assert_eq!(client.state, ClientState::AuthFailed); + assert_eq!(server.events().take_shm_created(), 0, "rejected client"); + client.close(); + + let mut client = increment_client(&svc, shm_client_config()); + client.refresh(); + assert_eq!(client.state, ClientState::Ready); + assert!(client.shm.is_some(), "expected SHM to be negotiated"); + assert_eq!(client.call_increment(1), Ok(2)); + assert_eq!(server.events().take_shm_created(), 1, "SHM session"); + client.close(); + + server.events().set_fail_shm_create(true); + let mut client = increment_client(&svc, shm_client_config()); + client.refresh(); + assert_eq!(client.state, ClientState::Ready); + assert!(client.shm.is_none(), "expected the baseline fallback"); + assert_eq!( + client.session.as_ref().map(|s| s.selected_profile), + Some(PROFILE_BASELINE) + ); + server.events().set_fail_shm_create(false); + assert_eq!(client.call_increment(1), Ok(2)); + assert_eq!(server.events().take_shm_created(), 0, "failed creation"); + client.close(); + + server.stop(); +} diff --git a/src/crates/netipc/src/test_locks.rs b/src/crates/netipc/src/test_locks.rs new file mode 100644 index 00000000..f75d9599 --- /dev/null +++ b/src/crates/netipc/src/test_locks.rs @@ -0,0 +1,41 @@ +//! Test-only cleanup of endpoint lock files. +//! +//! Lock files stay after a server closes, by design +//! (`docs/level1-posix-uds.md`, "Endpoint lock"). Test service names are +//! unique per run, so without this every run would leave new locks behind: +//! the test process removes the locks it named when it exits. + +use std::sync::Mutex; +use std::sync::Once; +use std::sync::atomic::{AtomicU32, Ordering}; + +static PATHS: Mutex> = Mutex::new(Vec::new()); +static REGISTER: Once = Once::new(); +static OWNER_PID: AtomicU32 = AtomicU32::new(0); + +extern "C" fn remove_tracked_locks() { + // A forked child inherits the handler; only the process that named the + // services removes their locks. + if std::process::id() != OWNER_PID.load(Ordering::Relaxed) { + return; + } + let paths = PATHS.lock().unwrap_or_else(|e| e.into_inner()); + for path in paths.iter() { + let _ = std::fs::remove_file(path); + } +} + +/// Records `{dir}/{service}.lock` for removal at process exit. +pub(crate) fn track(dir: &str, service: &str) { + REGISTER.call_once(|| { + OWNER_PID.store(std::process::id(), Ordering::Relaxed); + // SAFETY: registers a plain extern "C" function with no captured state. + unsafe { + libc::atexit(remove_tracked_locks); + } + }); + PATHS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(format!("{dir}/{service}.lock")); +} diff --git a/src/crates/netipc/src/transport/framing.rs b/src/crates/netipc/src/transport/framing.rs new file mode 100644 index 00000000..5ffb8c86 --- /dev/null +++ b/src/crates/netipc/src/transport/framing.rs @@ -0,0 +1,814 @@ +//! Framing shared by the UDS and Named Pipe transports. +//! +//! Message send with transparent chunking, receive with reassembly and +//! validation, and the HELLO / HELLO_ACK negotiation. Each transport supplies +//! its packet I/O and its error type; everything on the wire is decided here. + +use crate::protocol::{ + self, ChunkHeader, FLAG_BATCH, HEADER_SIZE, Header, Hello, HelloAck, KIND_REQUEST, + KIND_RESPONSE, MAGIC_CHUNK, MAGIC_MSG, NipcError, VERSION, align8, +}; +use std::collections::HashSet; + +pub(crate) const HELLO_PAYLOAD_SIZE: usize = 44; +pub(crate) const HELLO_ACK_PAYLOAD_SIZE: usize = 48; + +/// The transport errors the shared framing reports. +pub(crate) trait FramingError: Sized { + fn protocol(msg: String) -> Self; + fn chunk(msg: String) -> Self; + fn incompatible(msg: String) -> Self; + fn handshake(msg: String) -> Self; + fn bad_param(msg: String) -> Self; + fn limit_exceeded() -> Self; + fn auth_failed() -> Self; + fn no_profile() -> Self; + fn duplicate_msg_id(id: u64) -> Self; + fn unknown_msg_id(id: u64) -> Self; + /// True when a failed send leaves the session unusable, so every + /// in-flight request fails with it. + fn fails_session(&self) -> bool; + /// True when a failed receive means the peer is gone, so every in-flight + /// request fails with it. A timeout or abort leaves them pending. + fn is_recv_failure(&self) -> bool; +} + +/// One direction of an established session: the limits for the messages +/// this side sends (or receives) and the agreed packet size. +#[derive(Clone, Copy)] +pub(crate) struct Framing { + pub client: bool, + pub max_payload: u32, + pub max_items: u32, + pub packet_size: u32, +} + +pub(crate) fn header_version_incompatible(buf: &[u8], expected_code: u16) -> bool { + if buf.len() < HEADER_SIZE { + return false; + } + + let magic = u32::from_ne_bytes(buf[0..4].try_into().unwrap()); + let version = u16::from_ne_bytes(buf[4..6].try_into().unwrap()); + let header_len = u16::from_ne_bytes(buf[6..8].try_into().unwrap()); + let kind = u16::from_ne_bytes(buf[8..10].try_into().unwrap()); + let code = u16::from_ne_bytes(buf[12..14].try_into().unwrap()); + + magic == MAGIC_MSG + && version != VERSION + && header_len == protocol::HEADER_LEN + && kind == protocol::KIND_CONTROL + && code == expected_code +} + +pub(crate) fn hello_layout_incompatible(buf: &[u8]) -> bool { + buf.len() >= 2 && u16::from_ne_bytes(buf[0..2].try_into().unwrap()) != 1 +} + +pub(crate) fn hello_ack_layout_incompatible(buf: &[u8]) -> bool { + buf.len() >= 2 && u16::from_ne_bytes(buf[0..2].try_into().unwrap()) != 1 +} + +/// Highest set bit in a bitmask (0 if empty). +pub(crate) fn highest_bit(mask: u32) -> u32 { + if mask == 0 { + return 0; + } + 1u32 << (31 - mask.leading_zeros()) +} + +pub(crate) fn apply_default(val: u32, def: u32) -> u32 { + if val == 0 { def } else { val } +} + +// --------------------------------------------------------------------------- +// Send / receive +// --------------------------------------------------------------------------- + +/// Send one logical message. `hdr` is the caller's outer header (kind, code, +/// flags, item_count, message_id); this sets magic/version/header_len/ +/// payload_len. A message larger than the packet size goes out in chunks. +/// `write` sends one packet made of a 32-byte header and a payload slice. +pub(crate) fn send_message( + f: Framing, + inflight: &mut HashSet, + hdr: &mut Header, + payload: &[u8], + mut write: impl FnMut(&[u8], &[u8]) -> Result<(), E>, +) -> Result<(), E> { + // Validate payload against negotiated directional limits before transmitting. + // The u32 cast below requires payload.len() <= u32::MAX. + if payload.len() > f.max_payload as usize + || payload.len() > (u32::MAX as usize).saturating_sub(HEADER_SIZE) + { + return Err(E::limit_exceeded()); + } + if hdr.item_count > f.max_items { + return Err(E::limit_exceeded()); + } + + // Client-side: track in-flight message_ids for requests + let tracked = f.client && hdr.kind == KIND_REQUEST; + if tracked && !inflight.insert(hdr.message_id) { + return Err(E::duplicate_msg_id(hdr.message_id)); + } + + // Fill envelope fields + hdr.magic = MAGIC_MSG; + hdr.version = VERSION; + hdr.header_len = protocol::HEADER_LEN; + hdr.payload_len = payload.len() as u32; + + let result = send_packets(f.packet_size, hdr, payload, &mut write); + + if let Err(err) = &result + && tracked + { + if err.fails_session() { + inflight.clear(); + } else { + inflight.remove(&hdr.message_id); + } + } + + result +} + +fn send_packets( + packet_size: u32, + hdr: &Header, + payload: &[u8], + write: &mut impl FnMut(&[u8], &[u8]) -> Result<(), E>, +) -> Result<(), E> { + let total_msg = HEADER_SIZE + payload.len(); + // total_message_len is a u32 wire field; larger totals are + // unrepresentable (parity with the C nipc_uds_header_payload_len). + if total_msg > u32::MAX as usize { + return Err(E::bad_param( + "total message length exceeds protocol limit".into(), + )); + } + + // Single packet? + if total_msg <= packet_size as usize { + let mut hdr_buf = [0u8; HEADER_SIZE]; + hdr.encode(&mut hdr_buf); + return write(&hdr_buf, payload); + } + + // Chunked send + let chunk_payload_budget = packet_size as usize - HEADER_SIZE; + if chunk_payload_budget == 0 { + return Err(E::bad_param("packet_size too small".into())); + } + + let first_chunk_payload = payload.len().min(chunk_payload_budget); + let remaining_after_first = payload.len() - first_chunk_payload; + + let continuation_chunks = if remaining_after_first > 0 { + 1 + ((remaining_after_first - 1) / chunk_payload_budget) + } else { + 0 + }; + let chunk_count = 1 + continuation_chunks as u32; + + // First chunk: outer header + first part of payload + let mut hdr_buf = [0u8; HEADER_SIZE]; + hdr.encode(&mut hdr_buf); + write(&hdr_buf, &payload[..first_chunk_payload])?; + + // Continuation chunks + let mut offset = first_chunk_payload; + for ci in 1..chunk_count { + let remaining = payload.len() - offset; + let this_chunk = remaining.min(chunk_payload_budget); + + let chk = ChunkHeader { + magic: MAGIC_CHUNK, + version: VERSION, + flags: 0, + message_id: hdr.message_id, + total_message_len: total_msg as u32, + chunk_index: ci, + chunk_count, + chunk_payload_len: this_chunk as u32, + }; + + let mut chk_buf = [0u8; HEADER_SIZE]; + chk.encode(&mut chk_buf); + write(&chk_buf, &payload[offset..offset + this_chunk])?; + + offset += this_chunk; + } + + Ok(()) +} + +/// Receive one logical message. `read` reads one packet into the buffer it +/// is given and returns its length. The first packet lands in `first`; a +/// chunked message is reassembled in `recv_buf`, its continuations read +/// through `pkt_buf`. The returned payload borrows one of the two. +pub(crate) fn receive_message<'a, E: FramingError>( + f: Framing, + inflight: &mut HashSet, + first: &'a mut [u8], + recv_buf: &'a mut Vec, + pkt_buf: &mut Vec, + mut read: impl FnMut(&mut [u8]) -> Result, +) -> Result<(Header, &'a [u8]), E> { + // Read first packet. A lost peer ends the session's pending requests. + let n = read(first).inspect_err(|e| { + if f.client && e.is_recv_failure() { + inflight.clear(); + } + })?; + + if n < HEADER_SIZE { + return Err(E::protocol("packet too short for header".into())); + } + + let hdr = + Header::decode(&first[..n]).map_err(|e| E::protocol(format!("header decode: {e}")))?; + + // Validate payload_len and item_count against the negotiated limits of + // this direction: a server receives requests, a client responses. + if hdr.payload_len > f.max_payload { + return Err(E::limit_exceeded()); + } + if hdr.item_count > f.max_items { + return Err(E::limit_exceeded()); + } + + // Client-side: validate response message_id is in-flight + if f.client && hdr.kind == KIND_RESPONSE && !inflight.remove(&hdr.message_id) { + return Err(E::unknown_msg_id(hdr.message_id)); + } + + // total_message_len is a u32 wire field; reject unrepresentable totals + // so the chunk-header comparison below cannot truncate-match (parity + // with the C nipc_uds_header_payload_len). The sum is checked because + // it overflows a 32-bit usize. + let Some(total_msg) = HEADER_SIZE + .checked_add(hdr.payload_len as usize) + .filter(|&total| total <= u32::MAX as usize) + else { + return Err(E::protocol( + "total message length exceeds protocol limit".into(), + )); + }; + + if n > total_msg { + return Err(E::protocol("packet exceeds declared payload_len".into())); + } + + // Non-chunked: entire message in one packet + if n == total_msg { + let payload = &first[HEADER_SIZE..total_msg]; + validate_batch_dir(&hdr, payload)?; + return Ok((hdr, payload)); + } + + // Chunked: first packet has partial payload + let first_payload_bytes = n - HEADER_SIZE; + + // Grow recv_buf to hold full payload + let needed = hdr.payload_len as usize; + if recv_buf.len() < needed { + recv_buf.resize(needed, 0); + } + + // Copy first chunk's payload + recv_buf[..first_payload_bytes].copy_from_slice(&first[HEADER_SIZE..n]); + + let mut assembled = first_payload_bytes; + let chunk_payload_budget = f.packet_size as usize - HEADER_SIZE; + + // Expected chunk count + let remaining_after_first = needed - first_payload_bytes; + let expected_continuations = if remaining_after_first > 0 && chunk_payload_budget > 0 { + 1 + ((remaining_after_first - 1) / chunk_payload_budget) + } else { + 0 + }; + let expected_chunk_count = 1 + expected_continuations as u32; + + if pkt_buf.len() < f.packet_size as usize { + pkt_buf.resize(f.packet_size as usize, 0); + } + + let mut ci = 1u32; + while assembled < needed { + let cn = read(pkt_buf).inspect_err(|e| { + if f.client && e.is_recv_failure() { + inflight.clear(); + } + })?; + + if cn < HEADER_SIZE { + return Err(E::chunk("continuation too short".into())); + } + + let chk = ChunkHeader::decode(&pkt_buf[..cn]) + .map_err(|e| E::chunk(format!("chunk header: {e}")))?; + + // Validate chunk header + if chk.message_id != hdr.message_id { + return Err(E::chunk("message_id mismatch".into())); + } + if chk.chunk_index != ci { + return Err(E::chunk(format!( + "chunk_index mismatch: expected {ci}, got {}", + chk.chunk_index + ))); + } + if chk.chunk_count != expected_chunk_count { + return Err(E::chunk("chunk_count mismatch".into())); + } + if chk.total_message_len != total_msg as u32 { + return Err(E::chunk("total_message_len mismatch".into())); + } + + let chunk_data = cn - HEADER_SIZE; + if chunk_data != chk.chunk_payload_len as usize { + return Err(E::chunk("chunk_payload_len mismatch".into())); + } + if chunk_data > needed - assembled { + return Err(E::chunk("chunk exceeds payload_len".into())); + } + + recv_buf[assembled..assembled + chunk_data].copy_from_slice(&pkt_buf[HEADER_SIZE..cn]); + assembled += chunk_data; + ci += 1; + } + + let payload = &recv_buf[..needed]; + validate_batch_dir(&hdr, payload)?; + Ok((hdr, payload)) +} + +fn validate_batch_dir(hdr: &Header, payload: &[u8]) -> Result<(), E> { + if hdr.flags & FLAG_BATCH == 0 || hdr.item_count <= 1 { + return Ok(()); + } + let dir_bytes = hdr.item_count as usize * 8; + let dir_aligned = align8(dir_bytes); + if payload.len() < dir_aligned { + return Err(E::protocol("batch directory exceeds payload".into())); + } + let packed_area_len = (payload.len() - dir_aligned) as u32; + protocol::batch_dir_validate(&payload[..dir_bytes], hdr.item_count, packed_area_len) + .map_err(|e| E::protocol(format!("batch directory: {e:?}"))) +} + +// --------------------------------------------------------------------------- +// Handshake +// --------------------------------------------------------------------------- + +fn control_header(code: u16, status: u16, payload_len: usize) -> [u8; HEADER_SIZE] { + let hdr = Header { + magic: MAGIC_MSG, + version: VERSION, + header_len: protocol::HEADER_LEN, + kind: protocol::KIND_CONTROL, + flags: 0, + code, + transport_status: status, + payload_len: payload_len as u32, + item_count: 1, + message_id: 0, + }; + let mut buf = [0u8; HEADER_SIZE]; + hdr.encode(&mut buf); + buf +} + +/// The HELLO packet a client sends. +pub(crate) fn hello_packet(hello: &Hello) -> [u8; HEADER_SIZE + HELLO_PAYLOAD_SIZE] { + let mut pkt = [0u8; HEADER_SIZE + HELLO_PAYLOAD_SIZE]; + pkt[..HEADER_SIZE].copy_from_slice(&control_header( + protocol::CODE_HELLO, + protocol::STATUS_OK, + HELLO_PAYLOAD_SIZE, + )); + hello.encode(&mut pkt[HEADER_SIZE..]); + pkt +} + +fn hello_ack_packet(ack: &HelloAck, status: u16) -> [u8; HEADER_SIZE + HELLO_ACK_PAYLOAD_SIZE] { + let mut pkt = [0u8; HEADER_SIZE + HELLO_ACK_PAYLOAD_SIZE]; + pkt[..HEADER_SIZE].copy_from_slice(&control_header( + protocol::CODE_HELLO_ACK, + status, + HELLO_ACK_PAYLOAD_SIZE, + )); + ack.encode(&mut pkt[HEADER_SIZE..]); + pkt +} + +/// Decode and check the HELLO_ACK packet `buf` answering a HELLO that +/// offered the `offered` profiles. The agreed packet size is left to the +/// transport, whose limits differ. +pub(crate) fn parse_hello_ack(buf: &[u8], offered: u32) -> Result { + let ack_hdr = match Header::decode(buf) { + Ok(hdr) => hdr, + Err(NipcError::BadVersion) => { + return Err(E::incompatible("ack header version mismatch".into())); + } + Err(e) => return Err(E::protocol(format!("ack header: {e}"))), + }; + + if ack_hdr.kind != protocol::KIND_CONTROL || ack_hdr.code != protocol::CODE_HELLO_ACK { + return Err(E::protocol("expected HELLO_ACK".into())); + } + + // Check transport_status for rejection + match ack_hdr.transport_status { + protocol::STATUS_OK => {} + protocol::STATUS_AUTH_FAILED => return Err(E::auth_failed()), + protocol::STATUS_UNSUPPORTED => return Err(E::no_profile()), + protocol::STATUS_INCOMPATIBLE => { + return Err(E::incompatible("ack transport_status incompatible".into())); + } + protocol::STATUS_LIMIT_EXCEEDED => return Err(E::limit_exceeded()), + status => return Err(E::handshake(format!("transport_status={status}"))), + } + + // Decode hello-ack payload + if buf.len() < HEADER_SIZE + HELLO_ACK_PAYLOAD_SIZE { + return Err(E::protocol("ack payload truncated".into())); + } + let payload = &buf[HEADER_SIZE..]; + let ack = match HelloAck::decode(payload) { + Ok(ack) => ack, + Err(NipcError::BadLayout) if hello_ack_layout_incompatible(payload) => { + return Err(E::incompatible( + "ack payload layout version mismatch".into(), + )); + } + Err(e) => return Err(E::protocol(format!("ack payload: {e}"))), + }; + // HELLO_ACK is exactly one 48-byte payload: extra bytes in the packet, or + // a declared length a stream peer may still be sending, would otherwise + // be read as the session's first message. + if ack_hdr.payload_len as usize != HELLO_ACK_PAYLOAD_SIZE + || buf.len() != HEADER_SIZE + HELLO_ACK_PAYLOAD_SIZE + { + return Err(E::protocol("ack payload length mismatch".into())); + } + + if !super::selected_profile_offered(ack.selected_profile, offered) { + return Err(E::protocol("selected profile was not offered".into())); + } + + Ok(ack) +} + +/// What a server offers in the handshake, with defaults applied and the +/// profiles already limited to what the platform provides. +pub(crate) struct ServerOffer { + pub packet_size: u32, + pub supported_profiles: u32, + pub preferred_profiles: u32, + pub max_request_payload_bytes: u32, + pub max_response_payload_bytes: u32, + pub auth_token: u64, +} + +/// Answer the HELLO packet `buf` with HELLO_ACK. On success returns the ACK +/// sent, which holds the session's agreed values. Rejections are sent on a +/// best-effort basis; only a failed success ACK is reported as a send error. +/// +/// Once a profile is selected and the payload limits are agreed, and before +/// HELLO_ACK is sent, `prepare(profile, max_request_payload_bytes, +/// max_response_payload_bytes)` sets up that profile's resources. Returning +/// false drops the profile and selection runs again. +pub(crate) fn server_negotiate( + buf: &[u8], + offer: &ServerOffer, + session_id: u64, + mut prepare: Option<&mut dyn FnMut(u32, u32, u32) -> bool>, + mut send: impl FnMut(&[u8]) -> Result<(), E>, +) -> Result { + let mut send_rejection = |status: u16| { + let ack = HelloAck { + layout_version: 1, + ..HelloAck::default() + }; + let _ = send(&hello_ack_packet(&ack, status)); + }; + + let hdr = match Header::decode(buf) { + Ok(hdr) => hdr, + Err(NipcError::BadVersion) if header_version_incompatible(buf, protocol::CODE_HELLO) => { + send_rejection(protocol::STATUS_INCOMPATIBLE); + return Err(E::incompatible("hello header version mismatch".into())); + } + Err(e) => return Err(E::protocol(format!("hello header: {e}"))), + }; + + if hdr.kind != protocol::KIND_CONTROL || hdr.code != protocol::CODE_HELLO { + return Err(E::protocol("expected HELLO".into())); + } + + let payload = &buf[HEADER_SIZE..]; + let hello = match Hello::decode(payload) { + Ok(hello) => hello, + Err(NipcError::BadLayout) if hello_layout_incompatible(payload) => { + send_rejection(protocol::STATUS_INCOMPATIBLE); + return Err(E::incompatible( + "hello payload layout version mismatch".into(), + )); + } + Err(e) => return Err(E::protocol(format!("hello payload: {e}"))), + }; + // HELLO is exactly one 44-byte payload, as HELLO_ACK is 48. + if hdr.payload_len as usize != HELLO_PAYLOAD_SIZE + || buf.len() != HEADER_SIZE + HELLO_PAYLOAD_SIZE + { + return Err(E::protocol("hello payload length mismatch".into())); + } + + let mut s_profiles = offer.supported_profiles; + let mut intersection = hello.supported_profiles & s_profiles; + if intersection == 0 { + send_rejection(protocol::STATUS_UNSUPPORTED); + return Err(E::no_profile()); + } + + if hello.auth_token != offer.auth_token { + send_rejection(protocol::STATUS_AUTH_FAILED); + return Err(E::auth_failed()); + } + + // Select profile + let select = |intersection: u32| { + let preferred = intersection & hello.preferred_profiles & offer.preferred_profiles; + highest_bit(if preferred != 0 { + preferred + } else { + intersection + }) + }; + let mut selected = select(intersection); + + if hello.max_request_payload_bytes > offer.max_request_payload_bytes { + send_rejection(protocol::STATUS_LIMIT_EXCEEDED); + return Err(E::limit_exceeded()); + } + + // Negotiate limits: + // - request payload and batch size are client-proposed and echoed + // - response payload is server-authoritative + // - response batch size is symmetric with request batch size + let agreed_req_pay = hello.max_request_payload_bytes; + let agreed_req_bat = hello.max_request_batch_items; + let agreed_resp_pay = offer.max_response_payload_bytes; + let agreed_resp_bat = agreed_req_bat; + let agreed_pkt = hello.packet_size.min(offer.packet_size); + + // packet_size must be large enough for a usable message packet + if agreed_pkt <= HEADER_SIZE as u32 { + send_rejection(protocol::STATUS_INCOMPATIBLE); + return Err(E::incompatible( + "packet size too small for negotiated session".into(), + )); + } + + // Set up the selected profile before the ACK promises it. A profile the + // caller cannot prepare is dropped, as if the server never offered it. + if let Some(prepare) = prepare.as_mut() { + while !prepare(selected, agreed_req_pay, agreed_resp_pay) { + s_profiles &= !selected; + intersection &= !selected; + if intersection == 0 { + send_rejection(protocol::STATUS_UNSUPPORTED); + return Err(E::no_profile()); + } + selected = select(intersection); + } + } + + let ack = HelloAck { + layout_version: 1, + flags: 0, + server_supported_profiles: s_profiles, + intersection_profiles: intersection, + selected_profile: selected, + agreed_max_request_payload_bytes: agreed_req_pay, + agreed_max_request_batch_items: agreed_req_bat, + agreed_max_response_payload_bytes: agreed_resp_pay, + agreed_max_response_batch_items: agreed_resp_bat, + agreed_packet_size: agreed_pkt, + session_id, + }; + send(&hello_ack_packet(&ack, protocol::STATUS_OK))?; + Ok(ack) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_incompatible_classifiers() { + let hdr = Header { + magic: MAGIC_MSG, + version: VERSION + 1, + header_len: protocol::HEADER_LEN, + kind: protocol::KIND_CONTROL, + flags: 0, + code: protocol::CODE_HELLO, + transport_status: protocol::STATUS_OK, + payload_len: HELLO_PAYLOAD_SIZE as u32, + item_count: 1, + message_id: 0, + }; + let mut hdr_buf = [0u8; HEADER_SIZE]; + hdr.encode(&mut hdr_buf); + assert!(header_version_incompatible(&hdr_buf, protocol::CODE_HELLO)); + assert!(!header_version_incompatible( + &hdr_buf, + protocol::CODE_HELLO_ACK + )); + + let hello = Hello { + layout_version: 2, + ..Hello::default() + }; + let mut hello_buf = [0u8; HELLO_PAYLOAD_SIZE]; + hello.encode(&mut hello_buf); + assert!(hello_layout_incompatible(&hello_buf)); + + let ack = HelloAck { + layout_version: 2, + ..HelloAck::default() + }; + let mut ack_buf = [0u8; HELLO_ACK_PAYLOAD_SIZE]; + ack.encode(&mut ack_buf); + assert!(hello_ack_layout_incompatible(&ack_buf)); + } + + /// `pkt` with 16 more bytes, the header declaring them too when `declare`. + fn padded(pkt: &[u8], declare: bool) -> Vec { + let mut out = pkt.to_vec(); + out.extend_from_slice(&[0u8; 16]); + if declare { + let len = (pkt.len() - HEADER_SIZE + 16) as u32; + out[16..20].copy_from_slice(&len.to_ne_bytes()); + } + out + } + + /// HELLO and HELLO_ACK are exactly 44 and 48 payload bytes: a packet with + /// more, declared or not, is a protocol error. + #[test] + fn test_handshake_payload_length_exact() { + let ack = HelloAck { + layout_version: 1, + selected_profile: protocol::PROFILE_BASELINE, + agreed_packet_size: 4096, + ..HelloAck::default() + }; + let pkt = hello_ack_packet(&ack, protocol::STATUS_OK); + assert!(parse_hello_ack::(&pkt, protocol::PROFILE_BASELINE).is_ok()); + for declare in [false, true] { + assert!(matches!( + parse_hello_ack::(&padded(&pkt, declare), protocol::PROFILE_BASELINE), + Err(TestError::Protocol(_)) + )); + } + + let offer = ServerOffer { + packet_size: 4096, + supported_profiles: protocol::PROFILE_BASELINE, + preferred_profiles: 0, + max_request_payload_bytes: 1024, + max_response_payload_bytes: 1024, + auth_token: 0, + }; + let hello = Hello { + layout_version: 1, + supported_profiles: protocol::PROFILE_BASELINE, + max_request_payload_bytes: 1024, + max_request_batch_items: 1, + max_response_payload_bytes: 1024, + max_response_batch_items: 1, + packet_size: 4096, + ..Hello::default() + }; + let pkt = hello_packet(&hello); + assert!(server_negotiate::(&pkt, &offer, 1, None, |_| Ok(())).is_ok()); + for declare in [false, true] { + assert!(matches!( + server_negotiate::(&padded(&pkt, declare), &offer, 1, None, |_| Ok(())), + Err(TestError::Protocol(_)) + )); + } + } + + #[test] + fn test_highest_bit() { + assert_eq!(highest_bit(0), 0); + assert_eq!(highest_bit(1), 1); + assert_eq!(highest_bit(0b0101), 4); + assert_eq!(highest_bit(0b1000), 8); + assert_eq!(highest_bit(0xFF), 128); + assert_eq!(highest_bit(u32::MAX), 1 << 31); + } + + #[derive(Debug, PartialEq)] + enum TestError { + Protocol(String), + Other, + } + + impl FramingError for TestError { + fn protocol(msg: String) -> Self { + TestError::Protocol(msg) + } + fn chunk(_: String) -> Self { + TestError::Other + } + fn incompatible(_: String) -> Self { + TestError::Other + } + fn handshake(_: String) -> Self { + TestError::Other + } + fn bad_param(_: String) -> Self { + TestError::Other + } + fn limit_exceeded() -> Self { + TestError::Other + } + fn auth_failed() -> Self { + TestError::Other + } + fn no_profile() -> Self { + TestError::Other + } + fn duplicate_msg_id(_: u64) -> Self { + TestError::Other + } + fn unknown_msg_id(_: u64) -> Self { + TestError::Other + } + fn fails_session(&self) -> bool { + false + } + fn is_recv_failure(&self) -> bool { + false + } + } + + #[test] + fn test_receive_rejects_unrepresentable_total_length() { + // With a u32::MAX payload limit, a payload_len near u32::MAX passes + // the limit check, but 32 + payload_len does not fit the u32 + // total_message_len of chunk headers. + let hdr = Header { + magic: MAGIC_MSG, + version: VERSION, + header_len: protocol::HEADER_LEN, + kind: protocol::KIND_REQUEST, + payload_len: u32::MAX - 8, + item_count: 1, + ..Header::default() + }; + let mut packet = [0u8; HEADER_SIZE]; + hdr.encode(&mut packet); + + let f = Framing { + client: false, + max_payload: u32::MAX, + max_items: 1, + packet_size: 65536, + }; + let mut first = [0u8; 64]; + let mut recv_buf = Vec::new(); + let mut pkt_buf = Vec::new(); + let result = receive_message( + f, + &mut HashSet::new(), + &mut first, + &mut recv_buf, + &mut pkt_buf, + |buf: &mut [u8]| -> Result { + buf[..HEADER_SIZE].copy_from_slice(&packet); + Ok(HEADER_SIZE) + }, + ); + assert_eq!( + result.unwrap_err(), + TestError::Protocol("total message length exceeds protocol limit".into()) + ); + // Rejected before reassembly reserved the declared payload. + assert!(recv_buf.is_empty()); + } + + #[test] + fn test_apply_default() { + assert_eq!(apply_default(0, 42), 42); + assert_eq!(apply_default(10, 42), 10); + } +} diff --git a/src/crates/netipc/src/transport/mod.rs b/src/crates/netipc/src/transport/mod.rs index 536fb656..8e557f91 100644 --- a/src/crates/netipc/src/transport/mod.rs +++ b/src/crates/netipc/src/transport/mod.rs @@ -3,10 +3,13 @@ //! Each transport provides connection lifecycle, handshake, and //! send/receive with transparent chunking. +#[cfg(any(unix, windows))] +pub(crate) mod framing; + #[cfg(unix)] pub mod posix; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] pub mod shm; #[cfg(windows)] @@ -14,3 +17,11 @@ pub mod windows; #[cfg(windows)] pub mod win_shm; + +#[cfg(windows)] +pub(crate) mod win_security; + +/// True when a HELLO_ACK selected exactly one of the profiles a client offered. +pub(crate) fn selected_profile_offered(selected: u32, offered: u32) -> bool { + selected.is_power_of_two() && selected & offered != 0 +} diff --git a/src/crates/netipc/src/transport/posix.rs b/src/crates/netipc/src/transport/posix.rs index b5d9dbf6..3f89157c 100644 --- a/src/crates/netipc/src/transport/posix.rs +++ b/src/crates/netipc/src/transport/posix.rs @@ -1,12 +1,14 @@ -//! L1 POSIX UDS SEQPACKET transport. +//! L1 POSIX UDS transport. //! //! Connection lifecycle, handshake with profile/limit negotiation, -//! and send/receive with transparent chunking over AF_UNIX SEQPACKET sockets. -//! Wire-compatible with the C implementation in netipc_uds.c. +//! and send/receive with transparent chunking over AF_UNIX SEQPACKET sockets +//! (a stream socket carrying the same packets on macOS and FreeBSD, see +//! `posix_stream.rs`). Wire-compatible with the C implementation in +//! netipc_uds.c. +use super::framing::{self, Framing, FramingError, ServerOffer, apply_default}; use crate::protocol::{ - self, align8, ChunkHeader, Header, Hello, HelloAck, FLAG_BATCH, HEADER_SIZE, KIND_REQUEST, - KIND_RESPONSE, MAGIC_CHUNK, MAGIC_MSG, MAX_PAYLOAD_DEFAULT, PROFILE_BASELINE, VERSION, + self, HEADER_SIZE, Header, Hello, HelloAck, MAX_PAYLOAD_DEFAULT, PROFILE_BASELINE, }; use std::collections::HashSet; use std::ffi::CString; @@ -22,9 +24,38 @@ use std::time::{Duration, Instant}; const DEFAULT_BACKLOG: i32 = 16; const DEFAULT_BATCH_ITEMS: u32 = 1; +#[cfg_attr(any(target_os = "macos", target_os = "freebsd"), allow(dead_code))] const DEFAULT_PACKET_SIZE_FALLBACK: u32 = 65536; -const HELLO_PAYLOAD_SIZE: usize = 44; -const HELLO_ACK_PAYLOAD_SIZE: usize = 48; +const PACKET_SIZE_MAX: u32 = 262_144; +/// Bytes Linux keeps from SO_SNDBUF for a SEQPACKET datagram: it accepts +/// SO_SNDBUF - 32. +#[cfg(not(any(target_os = "macos", target_os = "freebsd")))] +const SNDBUF_OVERHEAD: u32 = 32; + +/// macOS has no AF_UNIX SOCK_SEQPACKET, and FreeBSD 15 SEQPACKET keeps record +/// boundaries only where the sender sets MSG_EOR and can write part of a +/// record. Both use a stream socket carrying the same packets +/// (docs/level1-posix-uds.md, "Stream baseline"). +#[cfg(any(target_os = "macos", target_os = "freebsd"))] +pub(crate) const SOCK_TYPE: libc::c_int = libc::SOCK_STREAM; +#[cfg(not(any(target_os = "macos", target_os = "freebsd")))] +pub(crate) const SOCK_TYPE: libc::c_int = libc::SOCK_SEQPACKET; + +const SEND_FLAGS: libc::c_int = libc::MSG_NOSIGNAL; + +#[path = "posix_stream.rs"] +#[cfg_attr(not(any(target_os = "macos", target_os = "freebsd")), allow(dead_code))] +mod stream; + +/// Profiles this platform can provide; unassigned bits are never offered or +/// accepted. POSIX SHM needs a futex-style wait (Linux futex, FreeBSD +/// `_umtx_op`), so other POSIX systems offer and accept the baseline +/// profile only. +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +const PLATFORM_PROFILES: u32 = + PROFILE_BASELINE | protocol::PROFILE_SHM_HYBRID | protocol::PROFILE_SHM_FUTEX; +#[cfg(not(any(target_os = "linux", target_os = "freebsd")))] +const PLATFORM_PROFILES: u32 = PROFILE_BASELINE; // --------------------------------------------------------------------------- // Errors @@ -102,30 +133,43 @@ impl std::fmt::Display for UdsError { } } -fn header_version_incompatible(buf: &[u8], expected_code: u16) -> bool { - if buf.len() < HEADER_SIZE { - return false; +impl FramingError for UdsError { + fn protocol(msg: String) -> Self { + UdsError::Protocol(msg) + } + fn chunk(msg: String) -> Self { + UdsError::Chunk(msg) + } + fn incompatible(msg: String) -> Self { + UdsError::Incompatible(msg) + } + fn handshake(msg: String) -> Self { + UdsError::Handshake(msg) + } + fn bad_param(msg: String) -> Self { + UdsError::BadParam(msg) + } + fn limit_exceeded() -> Self { + UdsError::LimitExceeded + } + fn auth_failed() -> Self { + UdsError::AuthFailed + } + fn no_profile() -> Self { + UdsError::NoProfile + } + fn duplicate_msg_id(id: u64) -> Self { + UdsError::DuplicateMsgId(id) + } + fn unknown_msg_id(id: u64) -> Self { + UdsError::UnknownMsgId(id) + } + fn fails_session(&self) -> bool { + matches!(self, UdsError::Send(_)) + } + fn is_recv_failure(&self) -> bool { + matches!(self, UdsError::Recv(_)) } - - let magic = u32::from_ne_bytes(buf[0..4].try_into().unwrap()); - let version = u16::from_ne_bytes(buf[4..6].try_into().unwrap()); - let header_len = u16::from_ne_bytes(buf[6..8].try_into().unwrap()); - let kind = u16::from_ne_bytes(buf[8..10].try_into().unwrap()); - let code = u16::from_ne_bytes(buf[12..14].try_into().unwrap()); - - magic == MAGIC_MSG - && version != VERSION - && header_len == protocol::HEADER_LEN - && kind == protocol::KIND_CONTROL - && code == expected_code -} - -fn hello_layout_incompatible(buf: &[u8]) -> bool { - buf.len() >= 2 && u16::from_ne_bytes(buf[0..2].try_into().unwrap()) != 1 -} - -fn hello_ack_layout_incompatible(buf: &[u8]) -> bool { - buf.len() >= 2 && u16::from_ne_bytes(buf[0..2].try_into().unwrap()) != 1 } impl std::error::Error for UdsError {} @@ -154,7 +198,8 @@ pub struct ClientConfig { pub max_response_payload_bytes: u32, pub max_response_batch_items: u32, pub auth_token: u64, - /// 0 = auto-detect from SO_SNDBUF. + /// 0 = transport limit (SEQPACKET: SO_SNDBUF - 32; stream: 256 KiB); + /// always capped at 256 KiB. pub packet_size: u32, } @@ -183,7 +228,8 @@ pub struct ServerConfig { pub max_response_payload_bytes: u32, pub max_response_batch_items: u32, pub auth_token: u64, - /// 0 = auto-detect from SO_SNDBUF. + /// 0 = transport limit (SEQPACKET: SO_SNDBUF - 32; stream: 256 KiB); + /// always capped at 256 KiB. pub packet_size: u32, /// listen() backlog, 0 = default (16). pub backlog: i32, @@ -229,20 +275,50 @@ pub struct UdsSession { // In-flight message_id set (client-side only) inflight_ids: HashSet, + + // Inbound packets read while a pipelined send waited for buffer space + pending: Pending, + + // Stream baseline: a receive failed after reading part of a message, so + // the stream is off a packet boundary and every later receive fails. + #[cfg_attr(not(any(target_os = "macos", target_os = "freebsd")), allow(dead_code))] + recv_failed: bool, } impl UdsSession { - fn fail_all_inflight(&mut self) { - if self.role == Role::Client { - self.inflight_ids.clear(); + /// The framing of the messages this side sends (`sending`) or receives. + /// Clients send requests and receive responses; servers the opposite. + fn framing(&self, sending: bool) -> Framing { + let client = self.role == Role::Client; + let (max_payload, max_items) = if client == sending { + (self.max_request_payload_bytes, self.max_request_batch_items) + } else { + ( + self.max_response_payload_bytes, + self.max_response_batch_items, + ) + }; + Framing { + client, + max_payload, + max_items, + packet_size: self.packet_size, } } - /// Get the raw fd for poll/epoll integration. + /// Get the raw fd for poll/epoll integration. A pipelined send can read + /// responses into the session's pending queue, where the fd no longer + /// shows them: check [`Self::has_pending`] before waiting on the fd. pub fn fd(&self) -> RawFd { self.fd } + /// True when the next receive can return data read earlier by a + /// pipelined send, without the fd becoming readable. + pub fn has_pending(&self) -> bool { + !self.recv_failed && !self.pending.is_empty() + } + /// Get the session role. pub fn role(&self) -> Role { self.role @@ -261,14 +337,28 @@ impl UdsSession { service_name: &str, config: &ClientConfig, ) -> Result { + Self::connect_with_timeout(run_dir, service_name, config, 0, None) + } + + /// `connect()` bounded by `timeout_ms`: connect and the wait for + /// HELLO_ACK together end with `UdsError::Timeout` after `timeout_ms` + /// (0 = no deadline), and with `UdsError::Aborted` once `abort` is set. + pub fn connect_with_timeout( + run_dir: &str, + service_name: &str, + config: &ClientConfig, + timeout_ms: u32, + abort: Option<&AtomicBool>, + ) -> Result { + let wait = ReceiveWait::new(timeout_ms, abort); let path = build_socket_path(run_dir, service_name)?; - let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_SEQPACKET, 0) }; + let fd = uds_socket_cloexec(); if fd < 0 { return Err(UdsError::Socket(errno())); } - let result = connect_and_handshake(fd, &path, config); + let result = connect_and_handshake(fd, &path, config, &wait); if result.is_err() { unsafe { libc::close(fd); @@ -284,127 +374,86 @@ impl UdsSession { /// If the total message (32 + payload_len) exceeds packet_size, the /// message is chunked transparently. pub fn send(&mut self, hdr: &mut Header, payload: &[u8]) -> Result<(), UdsError> { + self.send_with_control(hdr, payload, 0, None) + } + + /// `send` with an optional deadline and abort flag for a client session. + /// + /// `timeout_ms == 0` waits without a deadline. While the socket lacks + /// buffer space the send reads the peer's bytes as a pipelined send + /// does. If the deadline passes or `abort` is set before the message is + /// sent, returns `UdsError::Timeout` or `UdsError::Aborted` and shuts + /// the session down, since part of the message may be on the wire. + pub fn send_with_control( + &mut self, + hdr: &mut Header, + payload: &[u8], + timeout_ms: u32, + abort: Option<&AtomicBool>, + ) -> Result<(), UdsError> { if self.fd < 0 { return Err(UdsError::BadParam("session closed".into())); } - // Validate payload against negotiated directional limits before transmitting. - // The u32 cast below requires payload.len() <= u32::MAX. - let (max_payload, max_items) = if self.role == Role::Client { - (self.max_request_payload_bytes, self.max_request_batch_items) - } else { - ( - self.max_response_payload_bytes, - self.max_response_batch_items, - ) - }; - if payload.len() > max_payload as usize - || payload.len() > (u32::MAX as usize).saturating_sub(HEADER_SIZE) - { - return Err(UdsError::LimitExceeded); - } - if hdr.item_count > max_items { - return Err(UdsError::LimitExceeded); - } + let framing = self.framing(true); + let fd = self.fd; - // Client-side: track in-flight message_ids for requests - if self.role == Role::Client && hdr.kind == KIND_REQUEST { - if !self.inflight_ids.insert(hdr.message_id) { - return Err(UdsError::DuplicateMsgId(hdr.message_id)); - } + // A client send with a deadline or abort, or with another request in + // flight (a pipelining client can deadlock with a server blocked + // sending to it), waits in the progress loop; anything else keeps the + // plain blocking send. + let controlled = timeout_ms != 0 || abort.is_some(); + let inflight = self.inflight_ids.len() + + usize::from(framing.client && hdr.kind == crate::protocol::KIND_REQUEST); + if !(framing.client && (inflight > 1 || controlled)) { + return framing::send_message(framing, &mut self.inflight_ids, hdr, payload, |h, p| { + send_packet(fd, h, p) + }); } - // Fill envelope fields - hdr.magic = MAGIC_MSG; - hdr.version = VERSION; - hdr.header_len = protocol::HEADER_LEN; - hdr.payload_len = payload.len() as u32; - - let tracked = self.role == Role::Client && hdr.kind == KIND_REQUEST; - let msg_id = hdr.message_id; - - let result = self.send_inner(hdr, payload); - - if let Err(err) = &result { - if tracked { - match err { - UdsError::Send(_) => self.fail_all_inflight(), - _ => { - self.inflight_ids.remove(&msg_id); - } + // A bounded send of an untracked message can run with nothing in + // flight; it still gets one response's room. + let bound = pending_bound( + self.max_response_payload_bytes, + self.packet_size, + inflight.max(1), + ); + let read_len = self.packet_size as usize; + let pending = &mut self.pending; + // After a failed stream receive the inbound bytes belong to an + // abandoned message: never queue them. + let recv_failed = self.recv_failed; + let wait = controlled.then(|| ReceiveWait::new(timeout_ms, abort)); + let mut failed = false; + let result = + framing::send_message(framing, &mut self.inflight_ids, hdr, payload, |h, p| { + let r = send_with_progress( + fd, + h, + p, + pending, + read_len, + bound, + recv_failed, + wait.as_ref(), + ); + if matches!( + r, + Err(UdsError::Protocol(_) | UdsError::Timeout | UdsError::Aborted) + ) { + failed = true; } - } + r + }); + if failed { + // Inbound bytes were dropped, or the message stopped part way: + // the session cannot continue. + unsafe { libc::shutdown(fd, libc::SHUT_RDWR) }; + self.inflight_ids.clear(); } - result } - /// Inner send logic, separated so the caller can rollback on failure. - fn send_inner(&mut self, hdr: &mut Header, payload: &[u8]) -> Result<(), UdsError> { - let total_msg = HEADER_SIZE + payload.len(); - // total_message_len is a u32 wire field; larger totals are - // unrepresentable (parity with the C nipc_uds_header_payload_len). - if total_msg > u32::MAX as usize { - return Err(UdsError::BadParam( - "total message length exceeds protocol limit".into(), - )); - } - - // Single packet? - if total_msg <= self.packet_size as usize { - let mut hdr_buf = [0u8; HEADER_SIZE]; - hdr.encode(&mut hdr_buf); - return raw_send_iov(self.fd, &hdr_buf, payload); - } - - // Chunked send - let chunk_payload_budget = self.packet_size as usize - HEADER_SIZE; - if chunk_payload_budget == 0 { - return Err(UdsError::BadParam("packet_size too small".into())); - } - - let first_chunk_payload = payload.len().min(chunk_payload_budget); - let remaining_after_first = payload.len() - first_chunk_payload; - - let continuation_chunks = if remaining_after_first > 0 { - 1 + ((remaining_after_first - 1) / chunk_payload_budget) - } else { - 0 - }; - let chunk_count = 1 + continuation_chunks as u32; - - // Send first chunk: outer header + first part of payload - let mut hdr_buf = [0u8; HEADER_SIZE]; - hdr.encode(&mut hdr_buf); - raw_send_iov(self.fd, &hdr_buf, &payload[..first_chunk_payload])?; - - // Send continuation chunks - let mut offset = first_chunk_payload; - for ci in 1..chunk_count { - let remaining = payload.len() - offset; - let this_chunk = remaining.min(chunk_payload_budget); - - let chk = ChunkHeader { - magic: MAGIC_CHUNK, - version: VERSION, - flags: 0, - message_id: hdr.message_id, - total_message_len: total_msg as u32, - chunk_index: ci, - chunk_count, - chunk_payload_len: this_chunk as u32, - }; - - let mut chk_buf = [0u8; HEADER_SIZE]; - chk.encode(&mut chk_buf); - raw_send_iov(self.fd, &chk_buf, &payload[offset..offset + this_chunk])?; - - offset += this_chunk; - } - - Ok(()) - } - /// Receive one logical message. Blocks until a complete message arrives. /// /// On success, returns (header, payload_view). The payload view is valid @@ -426,185 +475,85 @@ impl UdsSession { if self.fd < 0 { return Err(UdsError::BadParam("session closed".into())); } + // Rejected before any byte is read, so the session stays usable. + if buf.len() < HEADER_SIZE { + return Err(UdsError::BadParam("receive buffer too small".into())); + } let controlled = timeout_ms != 0 || abort.is_some(); + #[cfg_attr(any(target_os = "macos", target_os = "freebsd"), allow(unused_mut))] let mut wait = ReceiveWait::new(timeout_ms, abort); - // Read first packet - let n = match raw_recv_control(self.fd, buf, controlled, &mut wait) { - Ok(n) => n, - Err(err) => { - self.fail_all_inflight(); - return Err(err); - } - }; - - if n < HEADER_SIZE { - return Err(UdsError::Protocol("packet too short for header".into())); - } - - let hdr = Header::decode(&buf[..n]) - .map_err(|e| UdsError::Protocol(format!("header decode: {e}")))?; - - // Validate payload_len against negotiated directional limit. - // Server receives requests; client receives responses. - let max_payload = if self.role == Role::Server { - self.max_request_payload_bytes - } else { - self.max_response_payload_bytes - }; - if hdr.payload_len > max_payload { - return Err(UdsError::LimitExceeded); - } + let framing = self.framing(false); + let fd = self.fd; - // Validate item_count against negotiated directional batch limit. - let max_batch = if self.role == Role::Server { - self.max_request_batch_items - } else { - self.max_response_batch_items - }; - if hdr.item_count > max_batch { - return Err(UdsError::LimitExceeded); - } - - // Client-side: validate response message_id is in-flight - if self.role == Role::Client && hdr.kind == KIND_RESPONSE { - if !self.inflight_ids.remove(&hdr.message_id) { - return Err(UdsError::UnknownMsgId(hdr.message_id)); - } - } - - let total_msg = HEADER_SIZE + hdr.payload_len as usize; - // total_message_len is a u32 wire field; reject unrepresentable totals - // so the chunk-header comparison below cannot truncate-match (parity - // with the C nipc_uds_header_payload_len). - if total_msg > u32::MAX as usize { - return Err(UdsError::Protocol( - "total message length exceeds protocol limit".into(), - )); - } - - if n > total_msg { - return Err(UdsError::Protocol( - "packet exceeds declared payload_len".into(), - )); - } - - // Non-chunked: entire message in one packet - if n == total_msg { - let payload = &buf[HEADER_SIZE..HEADER_SIZE + hdr.payload_len as usize]; - - // Validate batch directory - if hdr.flags & FLAG_BATCH != 0 && hdr.item_count > 1 { - let dir_bytes = hdr.item_count as usize * 8; - let dir_aligned = align8(dir_bytes); - if payload.len() < dir_aligned { - return Err(UdsError::Protocol("batch directory exceeds payload".into())); - } - let packed_area_len = (payload.len() - dir_aligned) as u32; - protocol::batch_dir_validate( - &payload[..dir_bytes], - hdr.item_count, - packed_area_len, - ) - .map_err(|e| UdsError::Protocol(format!("batch directory: {e:?}")))?; - } - - return Ok((hdr, payload)); - } - - // Chunked: first packet has partial payload - let first_payload_bytes = n - HEADER_SIZE; - - // Grow recv_buf to hold full payload - let needed = hdr.payload_len as usize; - if self.recv_buf.len() < needed { - self.recv_buf.resize(needed, 0); - } - - // Copy first chunk's payload - self.recv_buf[..first_payload_bytes] - .copy_from_slice(&buf[HEADER_SIZE..HEADER_SIZE + first_payload_bytes]); - - let mut assembled = first_payload_bytes; - let chunk_payload_budget = self.packet_size as usize - HEADER_SIZE; - - // Expected chunk count - let remaining_after_first = hdr.payload_len as usize - first_payload_bytes; - let expected_continuations = if remaining_after_first > 0 && chunk_payload_budget > 0 { - 1 + ((remaining_after_first - 1) / chunk_payload_budget) - } else { - 0 - }; - let expected_chunk_count = 1 + expected_continuations as u32; - - if self.pkt_buf.len() < self.packet_size as usize { - self.pkt_buf.resize(self.packet_size as usize, 0); - } - - let mut ci = 1u32; - while assembled < hdr.payload_len as usize { - let cn = match raw_recv_control(self.fd, &mut self.pkt_buf, controlled, &mut wait) { - Ok(n) => n, - Err(err) => { - self.fail_all_inflight(); - return Err(err); - } - }; - - if cn < HEADER_SIZE { - return Err(UdsError::Chunk("continuation too short".into())); - } - - let chk = ChunkHeader::decode(&self.pkt_buf[..cn]) - .map_err(|e| UdsError::Chunk(format!("chunk header: {e}")))?; - - // Validate chunk header - if chk.message_id != hdr.message_id { - return Err(UdsError::Chunk("message_id mismatch".into())); - } - if chk.chunk_index != ci { - return Err(UdsError::Chunk(format!( - "chunk_index mismatch: expected {ci}, got {}", - chk.chunk_index - ))); - } - if chk.chunk_count != expected_chunk_count { - return Err(UdsError::Chunk("chunk_count mismatch".into())); - } - if chk.total_message_len != total_msg as u32 { - return Err(UdsError::Chunk("total_message_len mismatch".into())); - } - - let chunk_data = cn - HEADER_SIZE; - if chunk_data != chk.chunk_payload_len as usize { - return Err(UdsError::Chunk("chunk_payload_len mismatch".into())); + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + { + // A failure before the first byte (a timeout or abort, also one + // that fires after the wait below) leaves the stream on a packet + // boundary. A failure after it does not: the rest of the message + // is still queued, so every later receive fails. The + // session records that instead of shutting the read side, whose + // effect differs by OS (FreeBSD 15 fails the peer's sends, + // FreeBSD 14 still returns queued bytes). Sends stay valid; they + // always write whole messages. + if self.recv_failed { + return Err(UdsError::Recv(0)); } - if assembled + chunk_data > hdr.payload_len as usize { - return Err(UdsError::Chunk("chunk exceeds payload_len".into())); + let wait = controlled.then_some(&wait); + if let Some(wait) = wait + && self.pending.is_empty() + { + wait_fd_readable(fd, wait)?; } - - self.recv_buf[assembled..assembled + chunk_data] - .copy_from_slice(&self.pkt_buf[HEADER_SIZE..HEADER_SIZE + chunk_data]); - assembled += chunk_data; - ci += 1; - } - - let payload = &self.recv_buf[..hdr.payload_len as usize]; - - // Validate batch directory - if hdr.flags & FLAG_BATCH != 0 && hdr.item_count > 1 { - let dir_bytes = hdr.item_count as usize * 8; - let dir_aligned = align8(dir_bytes); - if payload.len() < dir_aligned { - return Err(UdsError::Protocol("batch directory exceeds payload".into())); + let pending = &mut self.pending; + let mut first = true; + let mut consumed = false; + let result = framing::receive_message( + framing, + &mut self.inflight_ids, + buf, + &mut self.recv_buf, + &mut self.pkt_buf, + |b| { + let r = stream::recv_session_packet( + fd, + pending, + b, + first, + framing.packet_size, + framing.max_payload, + framing.max_items, + wait, + &mut consumed, + ); + first = false; + r + }, + ); + if result.is_err() && consumed { + self.recv_failed = true; + // Queued bytes are part of the abandoned stream too. + self.pending = Pending::default(); } - let packed_area_len = (payload.len() - dir_aligned) as u32; - protocol::batch_dir_validate(&payload[..dir_bytes], hdr.item_count, packed_area_len) - .map_err(|e| UdsError::Protocol(format!("batch directory: {e:?}")))?; + result } - Ok((hdr, payload)) + #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] + framing::receive_message( + framing, + &mut self.inflight_ids, + buf, + &mut self.recv_buf, + &mut self.pkt_buf, + |b| { + if self.pending.is_empty() { + raw_recv_control(fd, b, controlled, &mut wait) + } else { + Ok(self.pending.pop_packet(b)) + } + }, + ) } } @@ -629,33 +578,186 @@ pub struct UdsListener { config: ServerConfig, path: PathBuf, next_session_id: AtomicU64, + /// `{service_name}.lock`, flock'ed for the listener's lifetime. Fields + /// drop after `Drop::drop` removed the socket file, so the endpoint is + /// released only once its socket file is gone. + _lock: EndpointLock, +} + +/// Test-only hook run inside `UdsListener::bind` between bind() and +/// listen() for the given socket path. +#[cfg(test)] +pub(crate) type ListenHook = (String, Box); +#[cfg(test)] +pub(crate) static LISTEN_HOOK: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// Runs just before a new endpoint lock file is linked to its final name. +#[cfg(test)] +pub(crate) type LockPublishHook = Box; +#[cfg(test)] +pub(crate) static LOCK_PUBLISH_HOOK: std::sync::Mutex> = + std::sync::Mutex::new(None); + +/// A file created under a umask without owner bits stays unopenable if the +/// process dies before fchmod(), so a missing lock gets its name only once +/// it is 0600. Best effort: the caller's creating open covers every failure, +/// such as a filesystem without hard links, and a losing link means another +/// starter published the file first. +fn publish_endpoint_lock(lock_path: &str) { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + static SEQ: AtomicU64 = AtomicU64::new(0); + let tmp = format!( + "{lock_path}.{}.{}.tmp", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + ); + let Ok(file) = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) + .open(&tmp) + else { + return; + }; + let restricted = file + .set_permissions(std::fs::Permissions::from_mode(0o600)) + .is_ok(); + drop(file); + if restricted { + #[cfg(test)] + if let Some(hook) = LOCK_PUBLISH_HOOK.lock().unwrap().as_ref() { + hook(&tmp); + } + let _ = std::fs::hard_link(&tmp, lock_path); + } + let _ = std::fs::remove_file(&tmp); +} + +/// Take the endpoint lock `{run_dir}/{service_name}.lock`. A server's +/// socket file exists from bind() until listen(), and connects in that +/// window are refused just as for a dead server's leftover file, so a +/// connect probe alone cannot tell a starting server from a stale one. The +/// lock can: its holder owns the endpoint, and the kernel drops it when that +/// process dies. The file stays on disk; removing it would let a starter +/// that already opened it lock an orphaned inode. +fn acquire_endpoint_lock(run_dir: &str, service_name: &str) -> Result { + use std::os::unix::fs::OpenOptionsExt; + use std::os::unix::io::AsRawFd; + + use std::os::unix::fs::PermissionsExt; + + let lock_path = format!("{run_dir}/{service_name}.lock"); + // A file left with mode bits missing, by an older build or a fallback + // create under a umask without owner read or write, may not reopen + // read-write, so a narrower open follows (flock() works on any access + // mode) and fchmod() restores 0600 for the next starter. fchmod() fails + // only for a file another account owns, which the lock still works on. + let open_mode = |read: bool, write: bool, create: bool| { + std::fs::OpenOptions::new() + .read(read) + .write(write) + .create(create) + .truncate(false) + .mode(0o600) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) + .open(&lock_path) + }; + let denied = |e: &io::Error| e.raw_os_error() == Some(libc::EACCES); + let file = open_mode(true, true, false) + .or_else(|e| { + if e.raw_os_error() == Some(libc::ENOENT) { + publish_endpoint_lock(&lock_path); + open_mode(true, true, true) + } else { + Err(e) + } + }) + .or_else(|e| { + if denied(&e) { + open_mode(false, true, false) + } else { + Err(e) + } + }) + .or_else(|e| { + if denied(&e) { + open_mode(true, false, false) + } else { + Err(e) + } + }) + .map_err(|e| UdsError::Socket(e.raw_os_error().unwrap_or(0)))?; + let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600)); + loop { + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + return Ok(EndpointLock(file)); + } + match errno() { + libc::EINTR => continue, + libc::EWOULDBLOCK => return Err(UdsError::AddrInUse), + e => return Err(UdsError::Socket(e)), + } + } +} + +/// The held endpoint lock. Dropping it unlocks before closing: a child +/// forked by another thread shares the open file description until it +/// execs, and its copy would keep the lock held. +struct EndpointLock(std::fs::File); + +impl Drop for EndpointLock { + fn drop(&mut self) { + use std::os::unix::io::AsRawFd; + unsafe { libc::flock(self.0.as_raw_fd(), libc::LOCK_UN) }; + } } impl UdsListener { /// Create a listener on `{run_dir}/{service_name}.sock`. - /// Performs stale endpoint recovery. + /// First takes an exclusive flock on `{run_dir}/{service_name}.lock`, + /// held until the listener drops; a held lock means another server owns + /// the endpoint (`AddrInUse`). With the lock held, an existing socket + /// file no live server accepts on is stale and is replaced. pub fn bind(run_dir: &str, service_name: &str, config: ServerConfig) -> Result { let path = build_socket_path(run_dir, service_name)?; + let lock = acquire_endpoint_lock(run_dir, service_name)?; - // Stale recovery + // The probe still guards against a live server that takes no lock + // (an older library version). match check_and_recover_stale(&path) { StaleResult::LiveServer => return Err(UdsError::AddrInUse), StaleResult::Stale | StaleResult::NotExist => { /* proceed */ } } - let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_SEQPACKET, 0) }; + let fd = uds_socket_cloexec(); if fd < 0 { return Err(UdsError::Socket(errno())); } - // Bind - if let Err(e) = bind_unix(fd, &path) { + if let Err(e) = bind_owner_only(fd, run_dir, service_name, &path) { unsafe { libc::close(fd); } return Err(e); } + #[cfg(test)] + { + // Run the hook once, outside the lock: other binds go on. + let hook = { + let mut slot = LISTEN_HOOK.lock().unwrap(); + match slot.as_ref() { + Some((hook_path, _)) if *hook_path == path => slot.take(), + _ => None, + } + }; + if let Some((_, hook)) = hook { + hook(); + } + } + let backlog = if config.backlog > 0 { config.backlog } else { @@ -676,6 +778,7 @@ impl UdsListener { config, path: PathBuf::from(&path), next_session_id: AtomicU64::new(1), + _lock: lock, }) } @@ -702,48 +805,307 @@ impl UdsListener { } /// Accept one client using a caller-provided per-session server config - /// and session ID. + /// and session ID. The handshake waits at most + /// `SERVER_HANDSHAKE_TIMEOUT_MS` for HELLO. pub fn accept_with_config( &self, session_id: u64, config: ServerConfig, ) -> Result { - let client_fd = - unsafe { libc::accept(self.fd, std::ptr::null_mut(), std::ptr::null_mut()) }; - if client_fd < 0 { - return Err(UdsError::Accept(errno())); - } + self.accept_pending()?.handshake( + session_id, + &config, + protocol::SERVER_HANDSHAKE_TIMEOUT_MS, + None, + ) + } - match server_handshake(client_fd, &config, session_id) { - Ok(session) => Ok(session), - Err(e) => { - unsafe { - libc::close(client_fd); - } - Err(e) - } - } + /// Accept one connection without handshaking, so the caller can run the + /// handshake on another thread. Blocks until a client connects. + pub fn accept_pending(&self) -> Result { + // A signal that interrupts the wait is retried: it must not end the + // server's accept loop. + let fd = retry_eintr( + #[cfg(test)] + &eintr_inject::ACCEPT, + || accept_cloexec(self.fd) as isize, + ) + .map_err(UdsError::Accept)?; + Ok(PendingConnection { fd: fd as RawFd }) } } -impl Drop for UdsListener { +/// An accepted connection whose handshake has not run yet. Dropping it +/// closes the connection. +#[derive(Debug)] +pub struct PendingConnection { + fd: RawFd, +} + +impl PendingConnection { + /// Complete the server side of the handshake. Waits at most `timeout_ms` + /// for HELLO (`UdsError::Timeout`; 0 waits without a deadline) and returns + /// `UdsError::Aborted` once `abort` is set. + pub fn handshake( + self, + session_id: u64, + config: &ServerConfig, + timeout_ms: u32, + abort: Option<&AtomicBool>, + ) -> Result { + self.handshake_inner(session_id, config, timeout_ms, abort, None) + } + + /// [`handshake`](Self::handshake) with a prepare step. Once a profile is + /// selected and the payload limits are agreed, and before HELLO_ACK is + /// sent, `prepare(profile, max_request_payload_bytes, + /// max_response_payload_bytes)` sets up that profile's resources (an SHM + /// region). Returning false drops the profile and the handshake selects + /// again; with no profile left the client is rejected as unsupported. + pub fn handshake_prepared( + self, + session_id: u64, + config: &ServerConfig, + timeout_ms: u32, + abort: Option<&AtomicBool>, + prepare: &mut dyn FnMut(u32, u32, u32) -> bool, + ) -> Result { + self.handshake_inner(session_id, config, timeout_ms, abort, Some(prepare)) + } + + fn handshake_inner( + self, + session_id: u64, + config: &ServerConfig, + timeout_ms: u32, + abort: Option<&AtomicBool>, + prepare: Option<&mut dyn FnMut(u32, u32, u32) -> bool>, + ) -> Result { + // One deadline covers the wait for HELLO and, on a stream, its bytes. + let wait = ReceiveWait::new(timeout_ms, abort); + wait_fd_readable(self.fd, &wait)?; + let session = server_handshake(self.fd, config, session_id, prepare, &wait)?; + // The session owns the descriptor now. + std::mem::forget(self); + Ok(session) + } +} + +impl Drop for PendingConnection { fn drop(&mut self) { + unsafe { + libc::close(self.fd); + } + } +} + +impl UdsListener { + /// Stop accepting: close the listening socket and remove the socket + /// file, keeping the endpoint lock until the listener drops, so no other + /// server takes the endpoint while this one still serves its sessions. + /// Idempotent. + pub fn stop_listening(&mut self) { if self.fd >= 0 { unsafe { libc::close(self.fd); } self.fd = -1; } - if self.path.exists() { + if !self.path.as_os_str().is_empty() { let _ = std::fs::remove_file(&self.path); + self.path = PathBuf::new(); } } } +impl Drop for UdsListener { + fn drop(&mut self) { + self.stop_listening(); + } +} + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- +// SO_NOSIGPIPE is macOS's documented way to keep a write to a closed peer +// from raising SIGPIPE in the embedding process; sends also pass +// MSG_NOSIGNAL. +#[cfg(target_os = "macos")] +fn set_nosigpipe(fd: i32) { + let on: libc::c_int = 1; + unsafe { + libc::setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_NOSIGPIPE, + &on as *const _ as *const libc::c_void, + std::mem::size_of::() as libc::socklen_t, + ) + }; +} + +// Sockets must not leak into children a consumer forks and execs: an +// inherited session socket keeps the peer from ever seeing EOF, which is +// the dead-peer signal. Platforms without SOCK_CLOEXEC set the flag right +// after creation. +#[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] +fn uds_socket_cloexec() -> i32 { + unsafe { libc::socket(libc::AF_UNIX, SOCK_TYPE | libc::SOCK_CLOEXEC, 0) } +} + +#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "freebsd")))] +fn uds_socket_cloexec() -> i32 { + let fd = unsafe { libc::socket(libc::AF_UNIX, SOCK_TYPE, 0) }; + if fd >= 0 { + unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) }; + #[cfg(target_os = "macos")] + set_nosigpipe(fd); + } + fd +} + +#[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] +fn accept_cloexec(listen_fd: i32) -> i32 { + unsafe { + libc::accept4( + listen_fd, + std::ptr::null_mut(), + std::ptr::null_mut(), + libc::SOCK_CLOEXEC, + ) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "freebsd")))] +fn accept_cloexec(listen_fd: i32) -> i32 { + let fd = unsafe { libc::accept(listen_fd, std::ptr::null_mut(), std::ptr::null_mut()) }; + if fd >= 0 { + unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) }; + #[cfg(target_os = "macos")] + set_nosigpipe(fd); + } + fd +} + +/// Bind as owner-only (0600, narrowed by the umask). Linux and FreeBSD give +/// the socket file the mode of the unbound socket, so fchmod() before bind() +/// sets it without a post-bind window and without touching the +/// process-global umask, which other threads of the embedding process use. +#[cfg(not(target_os = "macos"))] +fn bind_owner_only( + fd: RawFd, + _run_dir: &str, + _service_name: &str, + path: &str, +) -> Result<(), UdsError> { + if unsafe { libc::fchmod(fd, libc::S_IRUSR | libc::S_IWUSR) } < 0 { + return Err(UdsError::Socket(errno())); + } + bind_unix(fd, path) +} + +/// macOS rejects fchmod() on an unbound socket and creates the socket file +/// 0777 & ~umask. The socket is bound inside a private 0700 directory +/// `{service_name}.d`, narrowed to owner-only there, then renamed into +/// place, so it is never reachable with a wider mode and the process-global +/// umask is untouched. The caller holds the endpoint lock, so a leftover +/// directory belongs to a crashed server and is removed first. fchmodat() +/// and open() resolve the directory by path without following a symlink, +/// and the owner check on the opened descriptor refuses a directory another +/// account swapped in. After that check every step but bind() goes through +/// the descriptor, so a later swap makes the listen fail instead of placing +/// a socket someone else can reach. +#[cfg(target_os = "macos")] +fn bind_owner_only( + fd: RawFd, + run_dir: &str, + service_name: &str, + path: &str, +) -> Result<(), UdsError> { + use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}; + use std::os::unix::io::AsRawFd; + + let io_err = |e: io::Error| UdsError::Socket(e.raw_os_error().unwrap_or(libc::EIO)); + let dir = format!("{run_dir}/{service_name}.d"); + let tmp = format!("{dir}/s"); + + // Anything but a real directory is unlinked without following it. + match std::fs::symlink_metadata(&dir) { + Ok(meta) if meta.is_dir() => { + match std::fs::remove_file(&tmp) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(io_err(e)), + } + std::fs::remove_dir(&dir).map_err(io_err)?; + } + Ok(_) => std::fs::remove_file(&dir).map_err(io_err)?, + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(io_err(e)), + } + + std::fs::DirBuilder::new() + .mode(0o700) + .create(&dir) + .map_err(io_err)?; + let result = (|| { + // fchmodat() ignores the umask, so a umask without owner bits cannot + // make the directory unopenable or unusable. + let c_dir = CString::new(dir.as_str()).map_err(|_| UdsError::PathTooLong)?; + if unsafe { + libc::fchmodat( + libc::AT_FDCWD, + c_dir.as_ptr(), + 0o700, + libc::AT_SYMLINK_NOFOLLOW, + ) + } < 0 + { + return Err(UdsError::Socket(errno())); + } + let dir_file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&dir) + .map_err(io_err)?; + if dir_file.metadata().map_err(io_err)?.uid() != unsafe { libc::geteuid() } { + return Err(UdsError::Socket(libc::EPERM)); + } + // The descriptor is the directory every later step uses; keep it + // 0700 even if another one of ours replaced the path. + dir_file + .set_permissions(std::fs::Permissions::from_mode(0o700)) + .map_err(io_err)?; + bind_unix(fd, &tmp)?; + let dfd = dir_file.as_raw_fd(); + let name = c"s"; + let placed = (|| { + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstatat(dfd, name.as_ptr(), &mut st, libc::AT_SYMLINK_NOFOLLOW) } < 0 + { + return Err(UdsError::Socket(errno())); + } + // The bound file carries 0777 & ~umask; keep the umask narrowing. + let mode = st.st_mode & 0o777 & (libc::S_IRUSR | libc::S_IWUSR); + if unsafe { libc::fchmodat(dfd, name.as_ptr(), mode, 0) } < 0 { + return Err(UdsError::Socket(errno())); + } + let c_path = CString::new(path).map_err(|_| UdsError::PathTooLong)?; + if unsafe { libc::renameat(dfd, name.as_ptr(), libc::AT_FDCWD, c_path.as_ptr()) } < 0 { + return Err(UdsError::Socket(errno())); + } + Ok(()) + })(); + if placed.is_err() { + unsafe { libc::unlinkat(dfd, name.as_ptr(), 0) }; + } + placed + })(); + let _ = std::fs::remove_dir(&dir); + result +} + fn errno() -> i32 { io::Error::last_os_error().raw_os_error().unwrap_or(0) } @@ -765,7 +1127,7 @@ fn validate_service_name(name: &str) -> Result<(), UdsError> { return Err(UdsError::BadParam(format!( "service name contains invalid character: {:?}", c as char - ))) + ))); } } } @@ -774,6 +1136,10 @@ fn validate_service_name(name: &str) -> Result<(), UdsError> { /// Build `{run_dir}/{service_name}.sock` and validate length. fn build_socket_path(run_dir: &str, service_name: &str) -> Result { + // An empty run_dir would put the socket at "/". + if run_dir.is_empty() { + return Err(UdsError::BadParam("empty run_dir".into())); + } validate_service_name(service_name)?; let path = format!("{run_dir}/{service_name}.sock"); @@ -788,8 +1154,25 @@ fn build_socket_path(run_dir: &str, service_name: &str) -> Result u32 { +/// Packet size this side may propose: `configured` (0 = no preference) +/// capped by what the kernel accepts as one datagram. +/// +/// Linux rejects a datagram larger than SO_SNDBUF minus 32 bytes of +/// accounting overhead with EMSGSIZE; very large buffers also hit ENOBUFS +/// near 1 MiB, hence the fixed cap. +#[cfg(any(target_os = "macos", target_os = "freebsd"))] +fn packet_size_limit(_fd: RawFd, configured: u32) -> u32 { + // A stream has no per-message size cap; the fixed cap keeps the first + // packet's buffer bounded and the chunking code shared. + if configured != 0 && configured < PACKET_SIZE_MAX { + configured + } else { + PACKET_SIZE_MAX + } +} + +#[cfg(not(any(target_os = "macos", target_os = "freebsd")))] +fn packet_size_limit(fd: RawFd, configured: u32) -> u32 { let mut val: libc::c_int = 0; let mut len: libc::socklen_t = std::mem::size_of::() as libc::socklen_t; @@ -803,34 +1186,353 @@ fn detect_packet_size(fd: RawFd) -> u32 { ) }; - if rc < 0 || val <= 0 { + let limit = if rc < 0 || val <= SNDBUF_OVERHEAD as libc::c_int { DEFAULT_PACKET_SIZE_FALLBACK } else { - val as u32 + val as u32 - SNDBUF_OVERHEAD } + .min(PACKET_SIZE_MAX); + + if configured != 0 && configured < limit { + configured + } else { + limit + } +} + +// --------------------------------------------------------------------------- +// Low-level I/O +// --------------------------------------------------------------------------- + +/// Send one packet: one SEQPACKET message, or all its bytes on a stream. +fn send_packet(fd: RawFd, hdr: &[u8], payload: &[u8]) -> Result<(), UdsError> { + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + return stream::send_all(fd, hdr, payload); + #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] + raw_send_iov(fd, hdr, payload) } -/// Highest set bit in a bitmask (0 if empty). -fn highest_bit(mask: u32) -> u32 { - if mask == 0 { - return 0; +/// Send one contiguous handshake packet. +fn send_control(fd: RawFd, pkt: &[u8]) -> Result<(), UdsError> { + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + return stream::send_all(fd, pkt, &[]); + #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] + raw_send(fd, pkt) +} + +/// Inbound bytes a pipelined client send read while it waited for buffer +/// space; receive consumes them before reading the socket. SEQPACKET stores +/// each datagram as a 4-byte length record followed by its bytes; the +/// stream stores raw bytes. +#[derive(Default)] +pub(crate) struct Pending { + buf: Vec, + head: usize, +} + +#[cfg(any(target_os = "macos", target_os = "freebsd"))] +const PENDING_RECORD_LEN: usize = 0; +#[cfg(not(any(target_os = "macos", target_os = "freebsd")))] +const PENDING_RECORD_LEN: usize = 4; + +impl Pending { + pub(crate) fn is_empty(&self) -> bool { + self.head == self.buf.len() + } + + fn consumed(&mut self) { + if self.head == self.buf.len() { + self.buf.clear(); + self.head = 0; + } + } + + /// Reads without blocking until the socket has nothing more to read, EOF + /// or an error (both set `eof`). Queued bytes beyond `bound` are a + /// protocol failure. Each datagram is stored whole, so its later + /// receive sees what a direct receive sees: one longer than `read_len`, + /// a protocol violation, is rejected there instead of being stored cut + /// short. + fn drain( + &mut self, + fd: RawFd, + read_len: usize, + bound: usize, + eof: &mut bool, + ) -> Result<(), UdsError> { + if self.head > 0 { + self.buf.drain(..self.head); + self.head = 0; + } + loop { + #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] + let read_len = { + // MSG_TRUNC reports the full length on Linux 3.4 and later; + // older kernels report at most the probe length. + let mut probe = 0u8; + let full = unsafe { + libc::recv( + fd, + &mut probe as *mut u8 as *mut libc::c_void, + 1, + libc::MSG_DONTWAIT | libc::MSG_PEEK | libc::MSG_TRUNC, + ) + }; + if full <= 0 { + match errno() { + _ if full == 0 => *eof = true, + libc::EINTR => continue, + e if e == libc::EAGAIN || e == libc::EWOULDBLOCK => {} + // A socket error: the retried send reports it. + _ => *eof = true, + } + return Ok(()); + } + let full = full as usize; + if full > bound { + return Err(UdsError::Protocol( + "inbound bytes exceed the in-flight responses".into(), + )); + } + read_len.max(full) + }; + let start = self.buf.len(); + self.buf.resize(start + PENDING_RECORD_LEN + read_len, 0); + let dst = &mut self.buf[start + PENDING_RECORD_LEN..]; + let n = unsafe { + libc::recv( + fd, + dst.as_mut_ptr() as *mut libc::c_void, + dst.len(), + libc::MSG_DONTWAIT, + ) + }; + if n <= 0 { + self.buf.truncate(start); + match errno() { + _ if n == 0 => *eof = true, + libc::EINTR => continue, + e if e == libc::EAGAIN || e == libc::EWOULDBLOCK => {} + // A socket error: the retried send reports it. + _ => *eof = true, + } + return Ok(()); + } + let n = n as usize; + #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] + self.buf[start..start + PENDING_RECORD_LEN].copy_from_slice(&(n as u32).to_ne_bytes()); + self.buf.truncate(start + PENDING_RECORD_LEN + n); + if self.buf.len() > bound { + return Err(UdsError::Protocol( + "inbound bytes exceed the in-flight responses".into(), + )); + } + } + } + + /// SEQPACKET: pops one queued datagram into `dst`, truncating it to + /// `dst.len()` as recv() does. Returns the bytes copied. + #[cfg_attr(any(target_os = "macos", target_os = "freebsd"), allow(dead_code))] + fn pop_packet(&mut self, dst: &mut [u8]) -> usize { + let h = self.head; + let rec = u32::from_ne_bytes(self.buf[h..h + 4].try_into().unwrap()) as usize; + let n = rec.min(dst.len()); + dst[..n].copy_from_slice(&self.buf[h + 4..h + 4 + n]); + self.head = h + 4 + rec; + self.consumed(); + n + } + + /// Stream: copies up to `dst.len()` queued bytes. Returns the count. + #[cfg_attr(not(any(target_os = "macos", target_os = "freebsd")), allow(dead_code))] + pub(crate) fn take(&mut self, dst: &mut [u8]) -> usize { + let n = dst.len().min(self.buf.len() - self.head); + dst[..n].copy_from_slice(&self.buf[self.head..self.head + n]); + self.head += n; + self.consumed(); + n } - 1u32 << (31 - mask.leading_zeros() as u32) } -fn apply_default(val: u32, def: u32) -> u32 { - if val == 0 { - def +/// Wire bytes `inflight` responses can occupy: the pending queue bound. +fn pending_bound(max_response_payload: u32, packet_size: u32, inflight: usize) -> usize { + let payload = max_response_payload as usize; + let budget = (packet_size as usize).saturating_sub(HEADER_SIZE).max(1); + let packets = if payload == 0 { + 1 } else { - val + 1 + (payload - 1) / budget + }; + packets + .saturating_mul(HEADER_SIZE + PENDING_RECORD_LEN) + .saturating_add(payload) + .saturating_mul(inflight) +} + +/// `send_with_progress_loop`; macOS ignores MSG_DONTWAIT on a stream send +/// that lacks buffer space and blocks, so on the stream baseline (macOS, +/// FreeBSD) the descriptor is made non-blocking for the send instead. +#[allow(clippy::too_many_arguments)] +fn send_with_progress( + fd: RawFd, + hdr: &[u8], + payload: &[u8], + pending: &mut Pending, + read_len: usize, + bound: usize, + recv_failed: bool, + wait: Option<&ReceiveWait<'_>>, +) -> Result<(), UdsError> { + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + { + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 { + return Err(UdsError::Send(errno())); + } + let result = send_with_progress_loop( + fd, + hdr, + payload, + pending, + read_len, + bound, + recv_failed, + wait, + ); + // A descriptor left non-blocking would break later blocking calls; + // a Send error also fails every in-flight request. + if unsafe { libc::fcntl(fd, libc::F_SETFL, flags) } < 0 { + let e = errno(); + unsafe { libc::shutdown(fd, libc::SHUT_RDWR) }; + return Err(UdsError::Send(e)); + } + result } + #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] + send_with_progress_loop( + fd, + hdr, + payload, + pending, + read_len, + bound, + recv_failed, + wait, + ) } -// --------------------------------------------------------------------------- -// Low-level I/O -// --------------------------------------------------------------------------- +/// Sends one packet without blocking; while the socket is full, reads what +/// the peer sent into `pending`, so both peers keep making progress. +/// Without a wait it waits as long as a blocking send would; with one it +/// ends at the deadline or abort. +#[allow(clippy::too_many_arguments)] +fn send_with_progress_loop( + fd: RawFd, + hdr: &[u8], + payload: &[u8], + pending: &mut Pending, + read_len: usize, + bound: usize, + recv_failed: bool, + wait: Option<&ReceiveWait<'_>>, +) -> Result<(), UdsError> { + // A SEQPACKET send moves the whole packet or nothing, so only the + // stream advances these. + #[cfg_attr( + not(any(target_os = "macos", target_os = "freebsd")), + allow(unused_mut) + )] + let mut parts = [hdr, payload]; + #[cfg_attr( + not(any(target_os = "macos", target_os = "freebsd")), + allow(unused_mut) + )] + let mut first = 0; + let mut eof = recv_failed; + loop { + let mut iov = [libc::iovec { + iov_base: std::ptr::null_mut(), + iov_len: 0, + }; 2]; + let mut iovcnt = 0; + for part in &parts[first..] { + if !part.is_empty() { + iov[iovcnt] = libc::iovec { + iov_base: part.as_ptr() as *mut libc::c_void, + iov_len: part.len(), + }; + iovcnt += 1; + } + } + // Zero-initialize: musl's 64-bit msghdr has private padding fields. + let mut msg: libc::msghdr = unsafe { std::mem::zeroed() }; + msg.msg_iov = iov.as_mut_ptr(); + msg.msg_iovlen = iovcnt as _; + + let n = unsafe { libc::sendmsg(fd, &msg, SEND_FLAGS | libc::MSG_DONTWAIT) }; + if n >= 0 { + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + { + if n == 0 { + return Err(UdsError::Send(0)); + } + let mut left = n as usize; + while first < parts.len() && left >= parts[first].len() { + left -= parts[first].len(); + first += 1; + } + if first == parts.len() { + return Ok(()); + } + parts[first] = &parts[first][left..]; + continue; + } + #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] + { + return if n as usize == hdr.len() + payload.len() { + Ok(()) + } else { + Err(UdsError::Send(errno())) + }; + } + } + match errno() { + libc::EINTR => continue, + e if e == libc::EAGAIN || e == libc::EWOULDBLOCK => {} + e => return Err(UdsError::Send(e)), + } + + // After EOF only writability matters; the retried send reports a + // closed peer. + let timeout = match wait { + Some(wait) => wait.poll_timeout_ms()?, + None => -1, + }; + let mut pfd = libc::pollfd { + fd, + events: libc::POLLOUT | if eof { 0 } else { libc::POLLIN }, + revents: 0, + }; + let rc = unsafe { libc::poll(&mut pfd, 1, timeout) }; + if rc < 0 { + let e = errno(); + if e == libc::EINTR { + continue; + } + return Err(UdsError::Send(e)); + } + if rc == 0 { + // poll_timeout_ms() reports the deadline and the abort flag. + continue; + } + if !eof && pfd.revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR) != 0 { + pending.drain(fd, read_len, bound, &mut eof)?; + } + } +} /// Send header + payload as one SEQPACKET message using sendmsg. +#[cfg_attr(any(target_os = "macos", target_os = "freebsd"), allow(dead_code))] fn raw_send_iov(fd: RawFd, hdr: &[u8], payload: &[u8]) -> Result<(), UdsError> { let total = hdr.len() + payload.len(); @@ -847,46 +1549,115 @@ fn raw_send_iov(fd: RawFd, hdr: &[u8], payload: &[u8]) -> Result<(), UdsError> { let iovcnt = if payload.is_empty() { 1 } else { 2 }; - let msg = libc::msghdr { - msg_name: std::ptr::null_mut(), - msg_namelen: 0, - msg_iov: iov.as_mut_ptr(), - msg_iovlen: iovcnt, - msg_control: std::ptr::null_mut(), - msg_controllen: 0, - msg_flags: 0, - }; + // Zero-initialize: musl's 64-bit msghdr has private padding fields, so a + // struct literal does not compile there. + let mut msg: libc::msghdr = unsafe { std::mem::zeroed() }; + msg.msg_iov = iov.as_mut_ptr(); + msg.msg_iovlen = iovcnt; + + let n = retry_eintr( + #[cfg(test)] + &eintr_inject::SENDMSG, + || unsafe { libc::sendmsg(fd, &msg, SEND_FLAGS) }, + ); + match n { + Ok(n) if n as usize == total => Ok(()), + Ok(_) => Err(UdsError::Send(errno())), + Err(e) => Err(UdsError::Send(e)), + } +} - let n = unsafe { libc::sendmsg(fd, &msg, libc::MSG_NOSIGNAL) }; - if n < 0 || n as usize != total { - return Err(UdsError::Send(errno())); +/// Run a socket call, retrying EINTR. SOCK_SEQPACKET sends and receives +/// whole messages or nothing, so an interrupted call moved no data. +/// Returns the call's result, or the errno of a failed call. +fn retry_eintr( + #[cfg(test)] inject: &'static std::thread::LocalKey>, + mut call: impl FnMut() -> isize, +) -> Result { + loop { + #[cfg(test)] + let injected = eintr_inject::take(inject); + #[cfg(not(test))] + let injected = false; + let n = if injected { -1 } else { call() }; + if n >= 0 { + return Ok(n); + } + let e = if injected { libc::EINTR } else { errno() }; + if e != libc::EINTR { + return Err(e); + } + } +} + +/// Test-only EINTR injection: the next N socket calls of a kind made on the +/// current thread fail with EINTR before reaching the kernel. +#[cfg(test)] +pub(crate) mod eintr_inject { + use std::cell::Cell; + use std::thread::LocalKey; + + thread_local! { + pub(crate) static SEND: Cell = const { Cell::new(0) }; + pub(crate) static SENDMSG: Cell = const { Cell::new(0) }; + pub(crate) static RECV: Cell = const { Cell::new(0) }; + pub(crate) static ACCEPT: Cell = const { Cell::new(0) }; + } + + pub(crate) fn take(counter: &'static LocalKey>) -> bool { + counter.with(|c| { + let left = c.get(); + if left == 0 { + return false; + } + c.set(left - 1); + true + }) + } + + /// Injections on this thread not consumed yet. + pub(crate) fn pending() -> u32 { + SEND.with(Cell::get) + + SENDMSG.with(Cell::get) + + RECV.with(Cell::get) + + ACCEPT.with(Cell::get) } - Ok(()) } /// Send a contiguous buffer as one SEQPACKET message. +#[cfg_attr(any(target_os = "macos", target_os = "freebsd"), allow(dead_code))] fn raw_send(fd: RawFd, data: &[u8]) -> Result<(), UdsError> { - let n = unsafe { - libc::send( - fd, - data.as_ptr() as *const libc::c_void, - data.len(), - libc::MSG_NOSIGNAL, - ) - }; - if n < 0 || n as usize != data.len() { - return Err(UdsError::Send(errno())); + let n = retry_eintr( + #[cfg(test)] + &eintr_inject::SEND, + || unsafe { + libc::send( + fd, + data.as_ptr() as *const libc::c_void, + data.len(), + SEND_FLAGS, + ) + }, + ); + match n { + Ok(n) if n as usize == data.len() => Ok(()), + Ok(_) => Err(UdsError::Send(errno())), + Err(e) => Err(UdsError::Send(e)), } - Ok(()) } /// Receive one SEQPACKET message. Returns bytes received. fn raw_recv(fd: RawFd, buf: &mut [u8]) -> Result { - let n = unsafe { libc::recv(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), 0) }; - if n <= 0 { - return Err(UdsError::Recv(if n == 0 { 0 } else { errno() })); + let n = retry_eintr( + #[cfg(test)] + &eintr_inject::RECV, + || unsafe { libc::recv(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), 0) }, + ); + match n { + Ok(0) => Err(UdsError::Recv(0)), + Ok(n) => Ok(n as usize), + Err(e) => Err(UdsError::Recv(e)), } - Ok(n as usize) } struct ReceiveWait<'a> { @@ -923,7 +1694,7 @@ impl<'a> ReceiveWait<'a> { deadline .duration_since(now) .as_millis() - .min(i32::MAX as u128) as u64, + .clamp(1, i32::MAX as u128) as u64, ) } else { None @@ -954,10 +1725,9 @@ fn wait_fd_readable(fd: RawFd, wait: &ReceiveWait<'_>) -> Result<(), UdsError> { return Err(UdsError::Recv(err)); } if rc == 0 { - if wait.aborted() { - return Err(UdsError::Aborted); - } - return Err(UdsError::Timeout); + // An empty slice only means the abort flag is due for a check; + // poll_timeout_ms() reports the real deadline. + continue; } if wait.aborted() { return Err(UdsError::Aborted); @@ -966,6 +1736,7 @@ fn wait_fd_readable(fd: RawFd, wait: &ReceiveWait<'_>) -> Result<(), UdsError> { } } +#[cfg_attr(any(target_os = "macos", target_os = "freebsd"), allow(dead_code))] fn raw_recv_with_wait( fd: RawFd, buf: &mut [u8], @@ -975,6 +1746,7 @@ fn raw_recv_with_wait( raw_recv(fd, buf) } +#[cfg_attr(any(target_os = "macos", target_os = "freebsd"), allow(dead_code))] fn raw_recv_control( fd: RawFd, buf: &mut [u8], @@ -1000,6 +1772,9 @@ fn bind_unix(fd: RawFd, path: &str) -> Result<(), UdsError> { addr.sun_family = libc::AF_UNIX as libc::sa_family_t; let path_bytes = c_path.as_bytes_with_nul(); + if path_bytes.len() > addr.sun_path.len() { + return Err(UdsError::PathTooLong); + } let sun_path_ptr = addr.sun_path.as_mut_ptr() as *mut u8; unsafe { std::ptr::copy_nonoverlapping(path_bytes.as_ptr(), sun_path_ptr, path_bytes.len()); @@ -1060,7 +1835,7 @@ fn check_and_recover_stale(path: &str) -> StaleResult { } // Try connecting to see if a live server is there - let probe = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_SEQPACKET, 0) }; + let probe = uds_socket_cloexec(); if probe < 0 { // Cannot probe liveness (fd exhaustion) — keep the endpoint; the // subsequent bind() fails instead of risking a live socket. @@ -1079,7 +1854,11 @@ fn check_and_recover_stale(path: &str) -> StaleResult { match std::fs::remove_file(path) { Ok(()) => StaleResult::Stale, Err(err) if err.kind() == io::ErrorKind::NotFound => StaleResult::NotExist, - Err(err) if err.raw_os_error() == Some(libc::EISDIR) => { + // Unlinking a directory fails with EISDIR on Linux and + // EPERM on FreeBSD and macOS. + Err(err) + if matches!(err.raw_os_error(), Some(libc::EISDIR) | Some(libc::EPERM)) => + { match std::fs::remove_dir(path) { Ok(()) => StaleResult::Stale, Err(_) => StaleResult::LiveServer, @@ -1100,31 +1879,78 @@ fn check_and_recover_stale(path: &str) -> StaleResult { // Handshake: client side // --------------------------------------------------------------------------- +/// Longest single connect wait, so an abort ends a connect promptly. +const CONNECT_SLICE_MS: u64 = 100; + +fn set_send_timeout(fd: RawFd, ms: u64) { + let tv = libc::timeval { + tv_sec: (ms / 1000) as _, + tv_usec: ((ms % 1000) * 1000) as _, + }; + unsafe { + libc::setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_SNDTIMEO, + &tv as *const libc::timeval as *const libc::c_void, + std::mem::size_of::() as libc::socklen_t, + ); + } +} + +/// A blocking AF_UNIX connect waits while the listener's backlog is full. +/// On Linux SO_SNDTIMEO bounds that wait and the connect fails with EAGAIN +/// when it expires; waiting in slices lets an abort end it. Other systems +/// refuse a full backlog at once. +fn connect_unix_bounded(fd: RawFd, path: &str, wait: &ReceiveWait<'_>) -> Result<(), UdsError> { + let result = loop { + if wait.aborted() { + break Err(UdsError::Aborted); + } + let mut slice = CONNECT_SLICE_MS; + if let Some(deadline) = wait.deadline { + let now = Instant::now(); + if now >= deadline { + break Err(UdsError::Timeout); + } + let left = deadline.duration_since(now).as_millis().max(1) as u64; + slice = slice.min(left); + } + set_send_timeout(fd, slice); + match connect_unix(fd, path) { + Ok(()) => break Ok(()), + Err(UdsError::Connect(e)) if e == libc::EISCONN => break Ok(()), + Err(UdsError::Connect(e)) + if e == libc::EAGAIN + || e == libc::EWOULDBLOCK + || e == libc::EINTR + || e == libc::EALREADY => {} + Err(e) => break Err(e), + } + }; + // Session sends block without a timeout. + set_send_timeout(fd, 0); + result +} + fn connect_and_handshake( fd: RawFd, path: &str, config: &ClientConfig, + wait: &ReceiveWait<'_>, ) -> Result { - connect_unix(fd, path)?; + connect_unix_bounded(fd, path, wait)?; - let pkt_size = if config.packet_size == 0 { - detect_packet_size(fd) - } else { - config.packet_size - }; + let pkt_size = packet_size_limit(fd, config.packet_size); - let supported = if config.supported_profiles == 0 { - PROFILE_BASELINE - } else { - config.supported_profiles - }; + let supported = apply_default(config.supported_profiles, PROFILE_BASELINE) & PLATFORM_PROFILES; // Build HELLO let hello = Hello { layout_version: 1, flags: 0, supported_profiles: supported, - preferred_profiles: config.preferred_profiles, + preferred_profiles: config.preferred_profiles & PLATFORM_PROFILES, max_request_payload_bytes: apply_default( config.max_request_payload_bytes, MAX_PAYLOAD_DEFAULT, @@ -1142,89 +1968,32 @@ fn connect_and_handshake( packet_size: pkt_size, }; - let mut hello_buf = [0u8; HELLO_PAYLOAD_SIZE]; - hello.encode(&mut hello_buf); - - // Build outer CONTROL header - let hdr = Header { - magic: MAGIC_MSG, - version: VERSION, - header_len: protocol::HEADER_LEN, - kind: protocol::KIND_CONTROL, - flags: 0, - code: protocol::CODE_HELLO, - transport_status: protocol::STATUS_OK, - payload_len: HELLO_PAYLOAD_SIZE as u32, - item_count: 1, - message_id: 0, - }; - - let mut pkt = [0u8; HEADER_SIZE + HELLO_PAYLOAD_SIZE]; - hdr.encode(&mut pkt[..HEADER_SIZE]); - pkt[HEADER_SIZE..].copy_from_slice(&hello_buf); - // Send HELLO - raw_send(fd, &pkt)?; + send_control(fd, &framing::hello_packet(&hello))?; - // Receive HELLO_ACK + // Receive HELLO_ACK; a server that never answers must not hold the + // client past its deadline. let mut buf = [0u8; 128]; - let n = raw_recv(fd, &mut buf)?; - - // Decode outer header - let ack_hdr = match Header::decode(&buf[..n]) { - Ok(hdr) => hdr, - Err(crate::protocol::NipcError::BadVersion) => { - return Err(UdsError::Incompatible("ack header version mismatch".into())) - } - Err(e) => return Err(UdsError::Protocol(format!("ack header: {e}"))), - }; - - if ack_hdr.kind != protocol::KIND_CONTROL || ack_hdr.code != protocol::CODE_HELLO_ACK { - return Err(UdsError::Protocol("expected HELLO_ACK".into())); - } - - // Check transport_status for rejection - if ack_hdr.transport_status == protocol::STATUS_AUTH_FAILED { - return Err(UdsError::AuthFailed); - } - if ack_hdr.transport_status == protocol::STATUS_UNSUPPORTED { - return Err(UdsError::NoProfile); - } - if ack_hdr.transport_status == protocol::STATUS_INCOMPATIBLE { - return Err(UdsError::Incompatible( - "ack transport_status incompatible".into(), - )); - } - if ack_hdr.transport_status == protocol::STATUS_LIMIT_EXCEEDED { - return Err(UdsError::LimitExceeded); - } - if ack_hdr.transport_status != protocol::STATUS_OK { - return Err(UdsError::Handshake(format!( - "transport_status={}", - ack_hdr.transport_status - ))); - } - - // Decode hello-ack payload - if n < HEADER_SIZE + HELLO_ACK_PAYLOAD_SIZE { - return Err(UdsError::Protocol("ack payload truncated".into())); - } - let ack = match HelloAck::decode(&buf[HEADER_SIZE..n]) { - Ok(ack) => ack, - Err(crate::protocol::NipcError::BadLayout) - if hello_ack_layout_incompatible(&buf[HEADER_SIZE..n]) => - { - return Err(UdsError::Incompatible( - "ack payload layout version mismatch".into(), - )) - } - Err(e) => return Err(UdsError::Protocol(format!("ack payload: {e}"))), + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + let n = stream::recv_packet(fd, &mut buf, 0, Some(wait))?; + #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] + let n = { + wait_fd_readable(fd, wait)?; + raw_recv(fd, &mut buf)? }; + let ack: HelloAck = framing::parse_hello_ack(&buf[..n], supported)?; // Sanity: reject a packet_size too small for chunking arithmetic if ack.agreed_packet_size <= HEADER_SIZE as u32 { return Err(UdsError::Protocol("agreed packet_size too small".into())); } + // A server may only lower our proposal; a larger size could exceed what + // this side's kernel accepts as one datagram. + if ack.agreed_packet_size > pkt_size { + return Err(UdsError::Protocol( + "agreed packet_size exceeds proposal".into(), + )); + } Ok(UdsSession { fd, @@ -1239,6 +2008,8 @@ fn connect_and_handshake( recv_buf: Vec::new(), pkt_buf: Vec::new(), inflight_ids: HashSet::new(), + pending: Pending::default(), + recv_failed: false, }) } @@ -1250,179 +2021,55 @@ fn server_handshake( fd: RawFd, config: &ServerConfig, session_id: u64, + prepare: Option<&mut dyn FnMut(u32, u32, u32) -> bool>, + #[cfg_attr( + not(any(target_os = "macos", target_os = "freebsd")), + allow(unused_variables) + )] + wait: &ReceiveWait<'_>, ) -> Result { - let server_pkt_size = if config.packet_size == 0 { - detect_packet_size(fd) - } else { - config.packet_size - }; - - let s_req_pay = apply_default(config.max_request_payload_bytes, MAX_PAYLOAD_DEFAULT); - let s_resp_pay = apply_default(config.max_response_payload_bytes, MAX_PAYLOAD_DEFAULT); - let s_profiles = if config.supported_profiles == 0 { - PROFILE_BASELINE - } else { - config.supported_profiles - }; - let s_preferred = config.preferred_profiles; - - let send_rejection = |status: u16| -> Result<(), UdsError> { - let ack = HelloAck { - layout_version: 1, - ..HelloAck::default() - }; - let mut ack_buf = [0u8; HELLO_ACK_PAYLOAD_SIZE]; - ack.encode(&mut ack_buf); - - let ack_hdr = Header { - magic: MAGIC_MSG, - version: VERSION, - header_len: protocol::HEADER_LEN, - kind: protocol::KIND_CONTROL, - flags: 0, - code: protocol::CODE_HELLO_ACK, - transport_status: status, - payload_len: HELLO_ACK_PAYLOAD_SIZE as u32, - item_count: 1, - message_id: 0, - }; - - let mut pkt = [0u8; HEADER_SIZE + HELLO_ACK_PAYLOAD_SIZE]; - ack_hdr.encode(&mut pkt[..HEADER_SIZE]); - pkt[HEADER_SIZE..].copy_from_slice(&ack_buf); - let _ = raw_send(fd, &pkt); - Ok(()) + let offer = ServerOffer { + packet_size: packet_size_limit(fd, config.packet_size), + supported_profiles: apply_default(config.supported_profiles, PROFILE_BASELINE) + & PLATFORM_PROFILES, + preferred_profiles: config.preferred_profiles & PLATFORM_PROFILES, + max_request_payload_bytes: apply_default( + config.max_request_payload_bytes, + MAX_PAYLOAD_DEFAULT, + ), + max_response_payload_bytes: apply_default( + config.max_response_payload_bytes, + MAX_PAYLOAD_DEFAULT, + ), + auth_token: config.auth_token, }; - // Receive HELLO + // Receive HELLO. The caller waited for it; a datagram arrives whole, + // a stream packet within the same deadline. let mut buf = [0u8; 128]; + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + let n = stream::recv_packet(fd, &mut buf, 0, Some(wait))?; + #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] let n = raw_recv(fd, &mut buf)?; - - let hdr = match Header::decode(&buf[..n]) { - Ok(hdr) => hdr, - Err(crate::protocol::NipcError::BadVersion) - if header_version_incompatible(&buf[..n], protocol::CODE_HELLO) => - { - send_rejection(protocol::STATUS_INCOMPATIBLE)?; - return Err(UdsError::Incompatible( - "hello header version mismatch".into(), - )); - } - Err(e) => return Err(UdsError::Protocol(format!("hello header: {e}"))), - }; - - if hdr.kind != protocol::KIND_CONTROL || hdr.code != protocol::CODE_HELLO { - return Err(UdsError::Protocol("expected HELLO".into())); - } - - let hello = match Hello::decode(&buf[HEADER_SIZE..n]) { - Ok(hello) => hello, - Err(crate::protocol::NipcError::BadLayout) - if hello_layout_incompatible(&buf[HEADER_SIZE..n]) => - { - send_rejection(protocol::STATUS_INCOMPATIBLE)?; - return Err(UdsError::Incompatible( - "hello payload layout version mismatch".into(), - )); - } - Err(e) => return Err(UdsError::Protocol(format!("hello payload: {e}"))), - }; - - // Compute intersection - let intersection = hello.supported_profiles & s_profiles; - - // Check intersection - if intersection == 0 { - send_rejection(protocol::STATUS_UNSUPPORTED)?; - return Err(UdsError::NoProfile); - } - - // Check auth - if hello.auth_token != config.auth_token { - send_rejection(protocol::STATUS_AUTH_FAILED)?; - return Err(UdsError::AuthFailed); - } - - // Select profile - let preferred_intersection = intersection & hello.preferred_profiles & s_preferred; - let selected = if preferred_intersection != 0 { - highest_bit(preferred_intersection) - } else { - highest_bit(intersection) - }; - - if hello.max_request_payload_bytes > s_req_pay { - send_rejection(protocol::STATUS_LIMIT_EXCEEDED)?; - return Err(UdsError::LimitExceeded); - } - - // Negotiate limits: - // - request payload and batch size are client-proposed and echoed - // - response payload is server-authoritative - // - response batch size is symmetric with request batch size - let agreed_req_pay = hello.max_request_payload_bytes; - let agreed_req_bat = hello.max_request_batch_items; - let agreed_resp_pay = s_resp_pay; - let agreed_resp_bat = agreed_req_bat; - let agreed_pkt = hello.packet_size.min(server_pkt_size); - - // packet_size must be large enough for a usable message packet - if agreed_pkt <= HEADER_SIZE as u32 { - send_rejection(protocol::STATUS_INCOMPATIBLE)?; - return Err(UdsError::Incompatible( - "packet size too small for negotiated session".into(), - )); - } - - // Send HELLO_ACK (success) - let ack = HelloAck { - layout_version: 1, - flags: 0, - server_supported_profiles: s_profiles, - intersection_profiles: intersection, - selected_profile: selected, - agreed_max_request_payload_bytes: agreed_req_pay, - agreed_max_request_batch_items: agreed_req_bat, - agreed_max_response_payload_bytes: agreed_resp_pay, - agreed_max_response_batch_items: agreed_resp_bat, - agreed_packet_size: agreed_pkt, - session_id, - }; - - let mut ack_buf = [0u8; HELLO_ACK_PAYLOAD_SIZE]; - ack.encode(&mut ack_buf); - - let ack_hdr = Header { - magic: MAGIC_MSG, - version: VERSION, - header_len: protocol::HEADER_LEN, - kind: protocol::KIND_CONTROL, - flags: 0, - code: protocol::CODE_HELLO_ACK, - transport_status: protocol::STATUS_OK, - payload_len: HELLO_ACK_PAYLOAD_SIZE as u32, - item_count: 1, - message_id: 0, - }; - - let mut pkt = [0u8; HEADER_SIZE + HELLO_ACK_PAYLOAD_SIZE]; - ack_hdr.encode(&mut pkt[..HEADER_SIZE]); - pkt[HEADER_SIZE..].copy_from_slice(&ack_buf); - raw_send(fd, &pkt)?; + let ack = framing::server_negotiate(&buf[..n], &offer, session_id, prepare, |pkt| { + send_control(fd, pkt) + })?; Ok(UdsSession { fd, role: Role::Server, - max_request_payload_bytes: agreed_req_pay, - max_request_batch_items: agreed_req_bat, - max_response_payload_bytes: agreed_resp_pay, - max_response_batch_items: agreed_resp_bat, - packet_size: agreed_pkt, - selected_profile: selected, + max_request_payload_bytes: ack.agreed_max_request_payload_bytes, + max_request_batch_items: ack.agreed_max_request_batch_items, + max_response_payload_bytes: ack.agreed_max_response_payload_bytes, + max_response_batch_items: ack.agreed_max_response_batch_items, + packet_size: ack.agreed_packet_size, + selected_profile: ack.selected_profile, session_id, recv_buf: Vec::new(), pkt_buf: Vec::new(), inflight_ids: HashSet::new(), + pending: Pending::default(), + recv_failed: false, }) } diff --git a/src/crates/netipc/src/transport/posix_stream.rs b/src/crates/netipc/src/transport/posix_stream.rs new file mode 100644 index 00000000..dba31a78 --- /dev/null +++ b/src/crates/netipc/src/transport/posix_stream.rs @@ -0,0 +1,479 @@ +//! Stream-socket packet I/O, the macOS and FreeBSD baseline (macOS has no +//! AF_UNIX SOCK_SEQPACKET; FreeBSD 15 SEQPACKET keeps record boundaries only +//! with MSG_EOR). A packet is the 32-byte header plus the bytes it declares, +//! so the bytes on the wire match the SEQPACKET transport. Built on every +//! POSIX system so its tests run everywhere; only macOS and FreeBSD sessions +//! use it. + +use super::{Pending, ReceiveWait, UdsError, raw_recv, wait_fd_readable}; +use crate::protocol::{HEADER_SIZE, Header, MAGIC_CHUNK, MAGIC_MSG}; +use std::os::unix::io::RawFd; + +fn field_u32(hdr: &[u8], offset: usize) -> u32 { + u32::from_ne_bytes(hdr[offset..offset + 4].try_into().unwrap()) +} + +/// Bytes that follow a received 32-byte header: `min(payload_len, +/// packet_size - 32)` after an outer header, `chunk_payload_len` after a +/// continuation header, none after any other magic. `packet_size` 0 +/// (handshake) caps an outer header at `buf_len`. Bytes beyond `buf_len` +/// are not counted, as a SEQPACKET recv into a short buffer drops them; the +/// caller's length checks then fail. +pub(super) fn body_len(hdr: &[u8], buf_len: usize, packet_size: u32) -> usize { + let declared = match field_u32(hdr, 0) { + MAGIC_MSG => { + let cap = if packet_size as usize > HEADER_SIZE { + packet_size as usize - HEADER_SIZE + } else { + buf_len - HEADER_SIZE + }; + (field_u32(hdr, 16) as usize).min(cap) + } + MAGIC_CHUNK => field_u32(hdr, 28) as usize, + _ => 0, + }; + declared.min(buf_len - HEADER_SIZE) +} + +/// Read exactly `dst.len()` bytes, waiting before each read when `wait` is +/// set (None blocks). EOF before the first byte is a disconnect (`Recv`); +/// EOF inside a packet (any byte read, or `mid_packet`) is a truncated +/// packet (`Protocol`). +pub(super) fn read_exact( + fd: RawFd, + dst: &mut [u8], + wait: Option<&ReceiveWait<'_>>, + mid_packet: bool, +) -> Result<(), UdsError> { + read_exact_counted(fd, dst, wait, mid_packet, &mut 0) +} + +/// `read_exact` that records in `got` the bytes it read, also on failure. +fn read_exact_counted( + fd: RawFd, + dst: &mut [u8], + wait: Option<&ReceiveWait<'_>>, + mid_packet: bool, + got: &mut usize, +) -> Result<(), UdsError> { + *got = 0; + while *got < dst.len() { + if let Some(wait) = wait { + wait_fd_readable(fd, wait)?; + } + #[cfg(test)] + let end = if short_io::take(&short_io::RECV) && dst.len() - *got > 1 { + *got + 1 + } else { + dst.len() + }; + #[cfg(not(test))] + let end = dst.len(); + match raw_recv(fd, &mut dst[*got..end]) { + Ok(n) => *got += n, + Err(UdsError::Recv(0)) if mid_packet || *got > 0 => { + return Err(UdsError::Protocol("truncated packet".into())); + } + Err(e) => return Err(e), + } + } + Ok(()) +} + +/// Read one packet (header, then its body). `buf` must hold the header. +pub(super) fn recv_packet( + fd: RawFd, + buf: &mut [u8], + packet_size: u32, + wait: Option<&ReceiveWait<'_>>, +) -> Result { + if buf.len() < HEADER_SIZE { + return Err(UdsError::BadParam("packet buffer too small".into())); + } + read_exact(fd, &mut buf[..HEADER_SIZE], wait, false)?; + let body = body_len(&buf[..HEADER_SIZE], buf.len(), packet_size); + read_exact(fd, &mut buf[HEADER_SIZE..HEADER_SIZE + body], wait, true)?; + Ok(HEADER_SIZE + body) +} + +/// `read_exact` that takes the session's pending bytes first. Sets +/// `consumed` when it takes any byte, also on failure. +fn read_exact_pending( + fd: RawFd, + pending: &mut Pending, + dst: &mut [u8], + wait: Option<&ReceiveWait<'_>>, + mid_packet: bool, + consumed: &mut bool, +) -> Result<(), UdsError> { + let got = pending.take(dst); + *consumed |= got > 0; + if got == dst.len() { + return Ok(()); + } + let mut read = 0; + let result = read_exact_counted(fd, &mut dst[got..], wait, mid_packet || got > 0, &mut read); + *consumed |= read > 0; + result +} + +/// Read one packet of a session's message, from its pending bytes first. The first packet's header is +/// checked before its body is read: one that is not an outer header, or +/// declares more than this direction's limits, comes back alone so the +/// framing rejects it without waiting for bytes a stream peer may never +/// send. A truncated continuation is a chunk error, as on SEQPACKET. +/// `consumed` is set once any byte of the message has been taken. +#[allow(clippy::too_many_arguments)] +pub(super) fn recv_session_packet( + fd: RawFd, + pending: &mut Pending, + buf: &mut [u8], + first: bool, + packet_size: u32, + max_payload: u32, + max_items: u32, + wait: Option<&ReceiveWait<'_>>, + consumed: &mut bool, +) -> Result { + if buf.len() < HEADER_SIZE { + return Err(UdsError::Protocol("packet too short for header".into())); + } + let result = (|| { + read_exact_pending(fd, pending, &mut buf[..HEADER_SIZE], wait, false, consumed)?; + let hdr = &buf[..HEADER_SIZE]; + if first + && Header::decode(hdr).map_or(true, |h| { + h.payload_len > max_payload || h.item_count > max_items + }) + { + return Ok(HEADER_SIZE); + } + let body = body_len(hdr, buf.len(), packet_size); + read_exact_pending( + fd, + pending, + &mut buf[HEADER_SIZE..HEADER_SIZE + body], + wait, + true, + consumed, + )?; + Ok(HEADER_SIZE + body) + })(); + match result { + Err(UdsError::Protocol(msg)) if !first => Err(UdsError::Chunk(msg)), + r => r, + } +} + +/// Send header + payload completely, resuming after partial writes. +pub(super) fn send_all(fd: RawFd, hdr: &[u8], payload: &[u8]) -> Result<(), UdsError> { + let mut parts = [hdr, payload]; + let mut first = 0; + while first < parts.len() { + if parts[first].is_empty() { + first += 1; + continue; + } + let mut iov = [libc::iovec { + iov_base: std::ptr::null_mut(), + iov_len: 0, + }; 2]; + let mut iovcnt = 0; + for part in &parts[first..] { + if !part.is_empty() { + iov[iovcnt] = libc::iovec { + iov_base: part.as_ptr() as *mut libc::c_void, + iov_len: part.len(), + }; + iovcnt += 1; + } + } + #[cfg(test)] + if short_io::take(&short_io::SENDMSG) { + iov[0].iov_len = 1; + iovcnt = 1; + } + + // Zero-initialize: musl's 64-bit msghdr has private padding fields. + let mut msg: libc::msghdr = unsafe { std::mem::zeroed() }; + msg.msg_iov = iov.as_mut_ptr(); + msg.msg_iovlen = iovcnt as _; + + // A stream write may move part of the data; EINTR means none moved. + let n = super::retry_eintr( + #[cfg(test)] + &super::eintr_inject::SENDMSG, + || unsafe { libc::sendmsg(fd, &msg, super::SEND_FLAGS) }, + ) + .map_err(UdsError::Send)?; + if n == 0 { + return Err(UdsError::Send(0)); + } + + let mut left = n as usize; + while first < parts.len() && left >= parts[first].len() { + left -= parts[first].len(); + first += 1; + } + if first < parts.len() { + parts[first] = &parts[first][left..]; + } + } + Ok(()) +} + +/// Test-only short I/O: the next N reads of more than one byte, or writes, +/// made on the current thread move one byte, as a stream socket may. +#[cfg(test)] +pub(crate) mod short_io { + use std::cell::Cell; + use std::thread::LocalKey; + + thread_local! { + pub(crate) static RECV: Cell = const { Cell::new(0) }; + pub(crate) static SENDMSG: Cell = const { Cell::new(0) }; + } + + pub(crate) fn take(counter: &'static LocalKey>) -> bool { + counter.with(|c| { + let left = c.get(); + if left == 0 { + return false; + } + c.set(left - 1); + true + }) + } + + pub(crate) fn pending() -> u32 { + RECV.with(Cell::get) + SENDMSG.with(Cell::get) + } +} + +#[cfg(test)] +mod tests { + use super::super::eintr_inject; + use super::*; + use crate::protocol::{ChunkHeader, KIND_REQUEST, VERSION}; + + fn stream_pair() -> (RawFd, RawFd) { + let mut fds = [0; 2]; + let rc = unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, fds.as_mut_ptr()) }; + assert_eq!(rc, 0); + (fds[0], fds[1]) + } + + fn close(fd: RawFd) { + unsafe { libc::close(fd) }; + } + + fn outer(declared: u32, body: &[u8]) -> Vec { + let hdr = Header { + magic: MAGIC_MSG, + version: VERSION, + header_len: HEADER_SIZE as u16, + kind: KIND_REQUEST, + code: 1, + item_count: 1, + message_id: 9, + payload_len: declared, + ..Default::default() + }; + let mut pkt = vec![0u8; HEADER_SIZE]; + hdr.encode(&mut pkt); + pkt.extend_from_slice(body); + pkt + } + + fn continuation(declared: u32, body: &[u8]) -> Vec { + let chk = ChunkHeader { + magic: MAGIC_CHUNK, + version: VERSION, + message_id: 9, + chunk_index: 1, + chunk_count: 2, + chunk_payload_len: declared, + ..Default::default() + }; + let mut pkt = vec![0u8; HEADER_SIZE]; + chk.encode(&mut pkt); + pkt.extend_from_slice(body); + pkt + } + + #[test] + fn test_body_len_comes_from_the_header() { + let pkt = outer(100, &[]); + assert_eq!(body_len(&pkt, 1024, 1024), 100, "whole payload fits"); + assert_eq!(body_len(&pkt, 1024, 48), 16, "first chunk fills the packet"); + assert_eq!(body_len(&pkt, 80, 0), 48, "handshake: capped by the buffer"); + assert_eq!(body_len(&pkt, 64, 1024), 32, "bytes past the buffer"); + assert_eq!(body_len(&continuation(7, &[]), 1024, 48), 7); + let mut unknown = vec![0u8; HEADER_SIZE]; + unknown[..4].copy_from_slice(&0x1234_5678u32.to_ne_bytes()); + assert_eq!(body_len(&unknown, 1024, 1024), 0, "unknown magic"); + } + + #[test] + fn test_packets_round_trip_and_eof_mapping() { + let (a, b) = stream_pair(); + let pkt1 = outer(5, b"hello"); + let pkt2 = continuation(3, b"abc"); + send_all(a, &pkt1[..HEADER_SIZE], &pkt1[HEADER_SIZE..]).unwrap(); + send_all(a, &pkt2, &[]).unwrap(); + + let mut buf = [0u8; 64]; + assert_eq!(recv_packet(b, &mut buf, 64, None).unwrap(), pkt1.len()); + assert_eq!(&buf[..pkt1.len()], &pkt1[..]); + assert_eq!(recv_packet(b, &mut buf, 64, None).unwrap(), pkt2.len()); + assert_eq!(&buf[..pkt2.len()], &pkt2[..]); + + let wait = ReceiveWait::new(1, None); + assert_eq!( + recv_packet(b, &mut buf, 64, Some(&wait)), + Err(UdsError::Timeout), + "idle stream times out" + ); + + let truncated = outer(5, b"he"); + send_all(a, &truncated, &[]).unwrap(); + unsafe { libc::shutdown(a, libc::SHUT_WR) }; + assert!( + matches!( + recv_packet(b, &mut buf, 64, None), + Err(UdsError::Protocol(_)) + ), + "EOF inside the body" + ); + close(a); + close(b); + + let (a, b) = stream_pair(); + send_all(a, &truncated[..10], &[]).unwrap(); + unsafe { libc::shutdown(a, libc::SHUT_WR) }; + assert!( + matches!( + recv_packet(b, &mut buf, 64, None), + Err(UdsError::Protocol(_)) + ), + "EOF inside the header" + ); + close(a); + close(b); + + let (a, b) = stream_pair(); + close(a); + assert_eq!( + recv_packet(b, &mut buf, 64, None), + Err(UdsError::Recv(0)), + "EOF between packets is a disconnect" + ); + close(b); + } + + #[test] + fn test_session_packet_checks_the_first_header_first() { + let (a, b) = stream_pair(); + let mut buf = [0u8; 256]; + + // Over the payload limit: the header comes back alone, the body is + // never waited for. + send_all(a, &outer(200, &[]), &[]).unwrap(); + assert_eq!( + recv_session_packet( + b, + &mut Pending::default(), + &mut buf, + true, + 256, + 100, + 1, + None, + &mut false + ) + .unwrap(), + HEADER_SIZE + ); + + // Any header that does not decode, here a wrong version: header only. + let mut bad_version = outer(50, &[]); + bad_version[4..6].copy_from_slice(&(VERSION + 1).to_ne_bytes()); + send_all(a, &bad_version, &[]).unwrap(); + assert_eq!( + recv_session_packet( + b, + &mut Pending::default(), + &mut buf, + true, + 256, + 100, + 1, + None, + &mut false + ) + .unwrap(), + HEADER_SIZE + ); + + // A continuation header where a message starts: header only. + send_all(a, &continuation(3, b"abc"), &[]).unwrap(); + assert_eq!( + recv_session_packet( + b, + &mut Pending::default(), + &mut buf, + true, + 256, + 100, + 1, + None, + &mut false + ) + .unwrap(), + HEADER_SIZE + ); + // Its body is still queued; read it off as a raw packet body. + read_exact(b, &mut buf[..3], None, true).unwrap(); + + // A truncated continuation is a chunk error. + send_all(a, &continuation(5, b"ab"), &[]).unwrap(); + unsafe { libc::shutdown(a, libc::SHUT_WR) }; + assert!(matches!( + recv_session_packet( + b, + &mut Pending::default(), + &mut buf, + false, + 256, + 100, + 1, + None, + &mut false + ), + Err(UdsError::Chunk(_)) + )); + close(a); + close(b); + } + + #[test] + fn test_one_byte_transfers_and_eintr_yield_whole_packets() { + let (a, b) = stream_pair(); + let pkt = outer(5, b"hello"); + + // 36 one-byte writes leave one byte for the last call; reads of more + // than one byte are cut to one, 31 in the header and 4 in the body. + short_io::SENDMSG.with(|c| c.set(pkt.len() as u32 - 1)); + eintr_inject::SENDMSG.with(|c| c.set(2)); + send_all(a, &pkt[..HEADER_SIZE], &pkt[HEADER_SIZE..]).unwrap(); + + short_io::RECV.with(|c| c.set(pkt.len() as u32 - 2)); + eintr_inject::RECV.with(|c| c.set(2)); + let mut buf = [0u8; 64]; + assert_eq!(recv_packet(b, &mut buf, 64, None).unwrap(), pkt.len()); + assert_eq!(&buf[..pkt.len()], &pkt[..]); + assert_eq!(short_io::pending() + eintr_inject::pending(), 0); + close(a); + close(b); + } +} diff --git a/src/crates/netipc/src/transport/posix_tests.rs b/src/crates/netipc/src/transport/posix_tests.rs index 718cc6c2..7e77d02a 100644 --- a/src/crates/netipc/src/transport/posix_tests.rs +++ b/src/crates/netipc/src/transport/posix_tests.rs @@ -1,7 +1,10 @@ use super::*; -use crate::protocol; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use crate::protocol::{ + self, ChunkHeader, FLAG_BATCH, KIND_REQUEST, KIND_RESPONSE, MAGIC_CHUNK, MAGIC_MSG, VERSION, +}; +use crate::transport::framing::{HELLO_ACK_PAYLOAD_SIZE, HELLO_PAYLOAD_SIZE}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::thread; use std::time::Duration; @@ -44,16 +47,30 @@ fn default_client_config() -> ClientConfig { } } +/// On a stream a short or over-long packet is only visible once the peer +/// stops sending: end the peer's writes there so the receiver sees EOF where +/// a SEQPACKET receiver sees the packet end. +fn end_stream_writes(fd: RawFd) { + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + unsafe { + libc::shutdown(fd, libc::SHUT_WR) + }; + #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] + let _ = fd; +} + /// Unique service name to avoid parallel test collisions. static TEST_COUNTER: AtomicU32 = AtomicU32::new(0); fn unique_service(prefix: &str) -> String { let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed); - format!("{prefix}_{n}_{}", std::process::id()) + let service = format!("{prefix}_{n}_{}", std::process::id()); + crate::test_locks::track(TEST_RUN_DIR, &service); + service } fn socketpair_seqpacket() -> (RawFd, RawFd) { let mut fds = [-1; 2]; - let rc = unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_SEQPACKET, 0, fds.as_mut_ptr()) }; + let rc = unsafe { libc::socketpair(libc::AF_UNIX, SOCK_TYPE, 0, fds.as_mut_ptr()) }; assert_eq!(rc, 0, "socketpair failed: {}", errno()); (fds[0], fds[1]) } @@ -72,12 +89,25 @@ fn test_session(fd: RawFd, role: Role, packet_size: u32) -> UdsSession { recv_buf: Vec::new(), pkt_buf: Vec::new(), inflight_ids: HashSet::new(), + pending: Pending::default(), + recv_failed: false, } } +/// Read one HELLO from a raw server socket. A stream socket may return it +/// in parts, so read until the whole packet is in. +fn recv_hello(fd: RawFd, buf: &mut [u8]) -> usize { + let want = HEADER_SIZE + HELLO_PAYLOAD_SIZE; + let mut got = 0; + while got < want { + got += raw_recv(fd, &mut buf[got..want]).expect("recv hello"); + } + got +} + fn raw_listener_fd(service: &str) -> RawFd { let path = build_socket_path(TEST_RUN_DIR, service).expect("socket path"); - let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_SEQPACKET, 0) }; + let fd = unsafe { libc::socket(libc::AF_UNIX, SOCK_TYPE, 0) }; assert!(fd >= 0, "socket failed: {}", errno()); bind_unix(fd, &path).expect("bind raw listener"); let rc = unsafe { libc::listen(fd, DEFAULT_BACKLOG) }; @@ -85,6 +115,66 @@ fn raw_listener_fd(service: &str) -> RawFd { fd } +/// A signal that interrupts a socket call before data moved must not fail +/// the handshake or the session: each client send, sendmsg and recv below +/// fails once with EINTR first. +#[test] +fn test_eintr_retried() { + ensure_run_dir(); + let svc = unique_service("rs_eintr"); + cleanup_socket(&svc); + + let listener = UdsListener::bind(TEST_RUN_DIR, &svc, default_server_config()).expect("listen"); + let server_thread = thread::spawn(move || { + // The server's first accept is interrupted before a client arrives. + eintr_inject::ACCEPT.with(|c| c.set(1)); + let mut session = listener.accept().expect("accept survives EINTR"); + assert_eq!(eintr_inject::pending(), 0, "the accept EINTR was hit"); + let mut buf = [0u8; 8192]; + let (hdr, payload) = session.receive(&mut buf).expect("recv"); + let payload = payload.to_vec(); + let mut resp = hdr; + resp.kind = protocol::KIND_RESPONSE; + resp.transport_status = protocol::STATUS_OK; + session.send(&mut resp, &payload).expect("send"); + }); + + // HELLO goes through send (sendmsg on a stream), the request + // through sendmsg; HELLO_ACK and the response through recv. Injection is + // per thread: only this client's calls fail. + #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] + { + eintr_inject::SEND.with(|c| c.set(1)); + eintr_inject::SENDMSG.with(|c| c.set(1)); + } + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + eintr_inject::SENDMSG.with(|c| c.set(2)); + eintr_inject::RECV.with(|c| c.set(2)); + + let mut session = UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()) + .expect("connect survives EINTR"); + let payload = [0x05u8, 0x06]; + let mut hdr = Header { + kind: protocol::KIND_REQUEST, + code: protocol::METHOD_INCREMENT, + item_count: 1, + message_id: 7, + ..Header::default() + }; + session + .send(&mut hdr, &payload) + .expect("send survives EINTR"); + let mut rbuf = [0u8; 4096]; + let (rhdr, rpayload) = session.receive(&mut rbuf).expect("receive survives EINTR"); + assert_eq!(rhdr.message_id, 7); + assert_eq!(rpayload, payload); + assert_eq!(eintr_inject::pending(), 0, "every injected EINTR was hit"); + + drop(session); + server_thread.join().unwrap(); + cleanup_socket(&svc); +} + // ----------------------------------------------------------------------- // Test 1: Single client ping-pong // ----------------------------------------------------------------------- @@ -96,14 +186,10 @@ fn test_ping_pong() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); + let listener = + UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); let server_thread = thread::spawn(move || { - let listener = - UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); - ready_clone.store(true, Ordering::Release); - let mut session = listener.accept().expect("accept"); let mut buf = [0u8; 8192]; @@ -118,9 +204,6 @@ fn test_ping_pong() { }); // Wait for server - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } let mut session = UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()).expect("connect"); @@ -161,14 +244,10 @@ fn test_multi_client() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); + let listener = + UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); let server_thread = thread::spawn(move || { - let listener = - UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); - ready_clone.store(true, Ordering::Release); - for _ in 0..2 { let mut session = listener.accept().expect("accept"); let mut buf = [0u8; 8192]; @@ -181,10 +260,6 @@ fn test_multi_client() { } }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let results: Vec<_> = (0..2) .map(|i| { let svc = svc.clone(); @@ -230,14 +305,10 @@ fn test_pipelining() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); + let listener = + UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); let server_thread = thread::spawn(move || { - let listener = - UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); - ready_clone.store(true, Ordering::Release); - let mut session = listener.accept().expect("accept"); for _ in 0..3 { @@ -251,10 +322,6 @@ fn test_pipelining() { } }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let mut session = UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()).expect("connect"); @@ -295,19 +362,14 @@ fn test_chunking() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); - + let scfg = ServerConfig { + packet_size: 128, + max_request_payload_bytes: 65536, + max_response_payload_bytes: 65536, + ..default_server_config() + }; + let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); let server_thread = thread::spawn(move || { - let scfg = ServerConfig { - packet_size: 128, - max_request_payload_bytes: 65536, - max_response_payload_bytes: 65536, - ..default_server_config() - }; - let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); - ready_clone.store(true, Ordering::Release); - let mut session = listener.accept().expect("accept"); let mut buf = [0u8; 256]; // small buf, forces recv_buf usage @@ -319,10 +381,6 @@ fn test_chunking() { session.send(&mut resp, &payload).expect("send"); }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let ccfg = ClientConfig { packet_size: 128, max_request_payload_bytes: 65536, @@ -369,21 +427,14 @@ fn test_bad_auth() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); + let listener = + UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); let server_thread = thread::spawn(move || { - let listener = - UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); - ready_clone.store(true, Ordering::Release); // accept will fail due to auth mismatch let _ = listener.accept(); }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let ccfg = ClientConfig { auth_token: 0xBAD, ..default_client_config() @@ -407,23 +458,15 @@ fn test_profile_mismatch() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); - + let scfg = ServerConfig { + supported_profiles: protocol::PROFILE_SHM_FUTEX, + ..default_server_config() + }; + let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); let server_thread = thread::spawn(move || { - let scfg = ServerConfig { - supported_profiles: protocol::PROFILE_SHM_FUTEX, - ..default_server_config() - }; - let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); - ready_clone.store(true, Ordering::Release); let _ = listener.accept(); }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let ccfg = ClientConfig { supported_profiles: PROFILE_BASELINE, ..default_client_config() @@ -443,21 +486,14 @@ fn test_request_payload_over_cap() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); + let listener = + UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); let server_thread = thread::spawn(move || { - let listener = - UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); - ready_clone.store(true, Ordering::Release); let result = listener.accept(); assert!(matches!(result, Err(UdsError::LimitExceeded))); }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let ccfg = ClientConfig { max_request_payload_bytes: protocol::MAX_PAYLOAD_CAP + 1, ..default_client_config() @@ -477,7 +513,7 @@ fn test_request_payload_over_cap() { /// server leaves behind. fn create_stale_socket_file(path: &str) { unsafe { - let sock = libc::socket(libc::AF_UNIX, libc::SOCK_SEQPACKET, 0); + let sock = libc::socket(libc::AF_UNIX, SOCK_TYPE, 0); assert!(sock >= 0); let c_path = CString::new(path).unwrap(); @@ -514,6 +550,261 @@ fn test_stale_recovery() { cleanup_socket(&svc); } +/// A server between bind() and listen() refuses connects just like a dead +/// server's leftover socket file. A second server starting in that window +/// must not take the endpoint over: it fails with AddrInUse, and the first +/// server stays reachable. +#[test] +fn test_listen_excludes_starting_server() { + use std::sync::mpsc; + ensure_run_dir(); + let svc = unique_service("rs_listen_window"); + cleanup_socket(&svc); + let path = format!("{TEST_RUN_DIR}/{svc}.sock"); + + let (bound_tx, bound_rx) = mpsc::channel::<()>(); + let (release_tx, release_rx) = mpsc::channel::<()>(); + let release_rx = std::sync::Mutex::new(release_rx); + *LISTEN_HOOK.lock().unwrap() = Some(( + path.clone(), + Box::new(move || { + let _ = bound_tx.send(()); + let _ = release_rx.lock().unwrap().recv(); + }), + )); + let svc_first = svc.clone(); + let first = + thread::spawn(move || UdsListener::bind(TEST_RUN_DIR, &svc_first, default_server_config())); + bound_rx + .recv_timeout(Duration::from_secs(10)) + .expect("first server bound"); + + let second = UdsListener::bind(TEST_RUN_DIR, &svc, default_server_config()); + assert!( + matches!(second, Err(UdsError::AddrInUse)), + "second server must get AddrInUse, got {:?}", + second.as_ref().map(|_| ()) + ); + drop(second); + + release_tx.send(()).unwrap(); + let first = first.join().unwrap().expect("first listen ok"); + + // A connect to the endpoint must reach the first server. + let cfd = uds_socket_cloexec(); + assert!(cfd >= 0); + connect_unix(cfd, &path).expect("connect to endpoint"); + let mut pfd = libc::pollfd { + fd: first.fd(), + events: libc::POLLIN, + revents: 0, + }; + let ready = unsafe { libc::poll(&mut pfd, 1, 10_000) }; + unsafe { libc::close(cfd) }; + assert_eq!(ready, 1, "first server receives the connection"); + drop(first); + + // After the owner closed, the endpoint can be taken again. + let again = UdsListener::bind(TEST_RUN_DIR, &svc, default_server_config()) + .expect("endpoint free after close"); + drop(again); + cleanup_socket(&svc); +} + +// A lock file left with a mode missing owner read or write (a server that +// ran under such a umask) must not block a start, and the start restores it. +#[test] +fn test_listen_over_leftover_lock_modes() { + use std::os::unix::fs::PermissionsExt; + ensure_run_dir(); + for mode in [0o200, 0o400] { + let svc = unique_service("rs_lock_mode"); + cleanup_socket(&svc); + let lock = format!("{TEST_RUN_DIR}/{svc}.lock"); + let _ = std::fs::remove_file(&lock); + std::fs::write(&lock, b"").expect("create lock"); + std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(mode)).unwrap(); + + let first = UdsListener::bind(TEST_RUN_DIR, &svc, default_server_config()) + .unwrap_or_else(|e| panic!("listen over a {mode:o} lock: {e:?}")); + let lock_mode = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777; + assert_eq!(lock_mode, 0o600, "lock mode restored from {mode:o}"); + let second = UdsListener::bind(TEST_RUN_DIR, &svc, default_server_config()); + assert!( + matches!(second, Err(UdsError::AddrInUse)), + "second server must get AddrInUse, got {:?}", + second.as_ref().map(|_| ()) + ); + drop(first); + cleanup_socket(&svc); + let _ = std::fs::remove_file(&lock); + } +} + +// An empty run_dir is a caller error: it would put the socket at "/". +#[test] +fn test_empty_run_dir_rejected() { + assert!(matches!( + UdsListener::bind("", "rs_empty_run_dir", default_server_config()), + Err(UdsError::BadParam(_)) + )); + assert!(matches!( + UdsSession::connect("", "rs_empty_run_dir", &default_client_config()), + Err(UdsError::BadParam(_)) + )); +} + +const LOCK_UMASK_HELPER_ENV: &str = "NETIPC_LOCK_UMASK_HELPER"; +const LOCK_UMASK_HELPER_TEST: &str = "transport::posix::tests::lock_umask_helper_process"; + +// The umask is process-wide, so the restrictive-umask case runs in a child +// test process instead of the multithreaded test runner. +#[test] +fn test_listen_lock_under_restrictive_umask() { + ensure_run_dir(); + for mask in ["477", "277", "777"] { + let status = std::process::Command::new(std::env::current_exe().expect("current_exe")) + .args([LOCK_UMASK_HELPER_TEST, "--exact", "--test-threads=1"]) + .env(LOCK_UMASK_HELPER_ENV, mask) + .stdout(std::process::Stdio::null()) + .status() + .expect("spawn umask helper"); + assert!( + status.success(), + "endpoint lock under umask {mask} failed: {status}" + ); + } +} + +/// Restores the umask on drop, also on panic, so files the process writes +/// at exit (coverage profiles) keep their normal mode. +struct UmaskGuard(libc::mode_t); + +impl Drop for UmaskGuard { + fn drop(&mut self) { + unsafe { libc::umask(self.0) }; + } +} + +/// Child-process entry point. Inert unless spawned by the test above. +#[test] +fn lock_umask_helper_process() { + use std::os::unix::fs::PermissionsExt; + + let Some(mask) = std::env::var_os(LOCK_UMASK_HELPER_ENV) else { + return; + }; + let mask = libc::mode_t::from_str_radix(mask.to_str().expect("mask"), 8).expect("octal mask"); + let svc = format!("rs_lock_umask_{mask:o}"); + let lock = format!("{TEST_RUN_DIR}/{svc}.lock"); + cleanup_socket(&svc); + let _ = std::fs::remove_file(&lock); + + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let (hook_seen, hook_lock) = (seen.clone(), lock.clone()); + *LOCK_PUBLISH_HOOK.lock().unwrap() = Some(Box::new(move |tmp: &str| { + let mode = std::fs::metadata(tmp) + .expect("tmp stat") + .permissions() + .mode() + & 0o777; + let visible = std::fs::symlink_metadata(&hook_lock).is_ok(); + hook_seen + .lock() + .unwrap() + .push((tmp.to_string(), mode, visible)); + })); + { + let _umask = UmaskGuard(unsafe { libc::umask(mask) }); + let first = UdsListener::bind(TEST_RUN_DIR, &svc, default_server_config()) + .unwrap_or_else(|e| panic!("listen under umask {mask:o}: {e:?}")); + *LOCK_PUBLISH_HOOK.lock().unwrap() = None; + let second = UdsListener::bind(TEST_RUN_DIR, &svc, default_server_config()); + assert!( + matches!(second, Err(UdsError::AddrInUse)), + "second server must get AddrInUse, got {:?}", + second.as_ref().map(|_| ()) + ); + drop(first); + let again = UdsListener::bind(TEST_RUN_DIR, &svc, default_server_config()) + .unwrap_or_else(|e| panic!("restart under umask {mask:o}: {e:?}")); + drop(again); + } + let lock_mode = std::fs::metadata(&lock) + .expect("lock stat") + .permissions() + .mode() + & 0o777; + assert_eq!(lock_mode, 0o600, "lock mode under umask {mask:o}"); + let seen = seen.lock().unwrap(); + assert_eq!(seen.len(), 1, "the new lock is published once"); + let (tmp, tmp_mode, lock_visible) = &seen[0]; + assert_eq!(*tmp_mode, 0o600, "mode before the lock gets its name"); + assert!(!*lock_visible, "the lock name appears only after the link"); + assert!( + std::fs::symlink_metadata(tmp).is_err(), + "the temporary name is gone" + ); + cleanup_socket(&svc); + let _ = std::fs::remove_file(&lock); +} + +// A child forked by another thread shares the lock's open file description +// until it execs. Closing the listener must still free the endpoint. +#[test] +fn test_listen_close_releases_lock_held_by_child() { + ensure_run_dir(); + let svc = unique_service("rs_listen_child_lock"); + cleanup_socket(&svc); + let first = UdsListener::bind(TEST_RUN_DIR, &svc, default_server_config()).expect("listen"); + + let mut gate = [0 as libc::c_int; 2]; + assert_eq!(unsafe { libc::pipe(gate.as_mut_ptr()) }, 0, "pipe"); + let child = unsafe { libc::fork() }; + if child == 0 { + // Keep every inherited fd until the parent closes the pipe. Only + // async-signal-safe calls after fork. + unsafe { + libc::close(gate[1]); + let mut c = 0u8; + while libc::read(gate[0], (&raw mut c).cast(), 1) < 0 && errno() == libc::EINTR {} + libc::_exit(0); + } + } + assert!(child > 0, "fork"); + unsafe { libc::close(gate[0]) }; + + drop(first); + let again = UdsListener::bind(TEST_RUN_DIR, &svc, default_server_config()); + unsafe { + libc::close(gate[1]); + libc::waitpid(child, std::ptr::null_mut(), 0); + } + assert!( + again.is_ok(), + "endpoint free while the child holds the lock fd: {:?}", + again.as_ref().map(|_| ()) + ); + drop(again); + cleanup_socket(&svc); +} + +#[test] +fn test_listen_socket_owner_only() { + use std::os::unix::fs::PermissionsExt; + ensure_run_dir(); + let svc = unique_service("rs_owner_only"); + cleanup_socket(&svc); + let listener = UdsListener::bind(TEST_RUN_DIR, &svc, default_server_config()).expect("listen"); + let mode = std::fs::metadata(format!("{TEST_RUN_DIR}/{svc}.sock")) + .expect("stat socket") + .permissions() + .mode(); + drop(listener); + cleanup_socket(&svc); + assert_eq!(mode & 0o777, 0o600, "listener socket must be owner-only"); +} + #[test] fn test_stale_recovery_in_group_writable_run_dir() { use std::os::unix::fs::PermissionsExt; @@ -524,6 +815,7 @@ fn test_stale_recovery_in_group_writable_run_dir() { std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o775)).expect("chmod 0775"); let svc = unique_service("rs_stale_gw"); + crate::test_locks::track(&dir, &svc); let path = format!("{dir}/{svc}.sock"); let _ = std::fs::remove_file(&path); create_stale_socket_file(&path); @@ -549,6 +841,29 @@ fn test_foreign_file_at_socket_path_is_reclaimed() { cleanup_socket(&svc); } +// Unlinking a directory fails with EISDIR on Linux and EPERM on FreeBSD and +// macOS; every platform must reclaim an empty one. +#[test] +fn test_empty_directory_at_socket_path_is_reclaimed() { + ensure_run_dir(); + let svc = unique_service("rs_stale_dir"); + cleanup_socket(&svc); + + let path = format!("{TEST_RUN_DIR}/{svc}.sock"); + let _ = std::fs::remove_dir(&path); + std::fs::create_dir(&path).expect("create directory at socket path"); + + let listener = UdsListener::bind(TEST_RUN_DIR, &svc, default_server_config()) + .expect("listen should reclaim an empty directory at the socket path"); + let meta = std::fs::symlink_metadata(&path).expect("socket path"); + assert!( + std::os::unix::fs::FileTypeExt::is_socket(&meta.file_type()), + "the socket must replace the directory" + ); + drop(listener); + cleanup_socket(&svc); +} + // ----------------------------------------------------------------------- // Test 8: Disconnect detection // ----------------------------------------------------------------------- @@ -560,14 +875,10 @@ fn test_disconnect_detection() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); + let listener = + UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); let server_thread = thread::spawn(move || { - let listener = - UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); - ready_clone.store(true, Ordering::Release); - let mut session = listener.accept().expect("accept"); // Read request then close without responding let mut buf = [0u8; 4096]; @@ -575,10 +886,6 @@ fn test_disconnect_detection() { drop(session); // close socket }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let mut session = UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()).expect("connect"); @@ -617,14 +924,10 @@ fn test_batch() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); + let listener = + UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); let server_thread = thread::spawn(move || { - let listener = - UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); - ready_clone.store(true, Ordering::Release); - let mut session = listener.accept().expect("accept"); let mut buf = [0u8; 8192]; let (hdr, payload) = session.receive(&mut buf).expect("recv"); @@ -635,10 +938,6 @@ fn test_batch() { session.send(&mut resp, &payload).expect("send"); }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let mut session = UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()).expect("connect"); @@ -705,19 +1004,14 @@ fn test_chunked_pipelining() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); - + let scfg = ServerConfig { + packet_size: 128, + max_request_payload_bytes: 65536, + max_response_payload_bytes: 65536, + ..default_server_config() + }; + let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); let server_thread = thread::spawn(move || { - let scfg = ServerConfig { - packet_size: 128, - max_request_payload_bytes: 65536, - max_response_payload_bytes: 65536, - ..default_server_config() - }; - let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); - ready_clone.store(true, Ordering::Release); - let mut session = listener.accept().expect("accept"); // Echo 3 messages @@ -731,10 +1025,6 @@ fn test_chunked_pipelining() { } }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let ccfg = ClientConfig { packet_size: 128, max_request_payload_bytes: 65536, @@ -782,14 +1072,10 @@ fn test_pipeline_10() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); + let listener = + UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); let server_thread = thread::spawn(move || { - let listener = - UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); - ready_clone.store(true, Ordering::Release); - let mut session = listener.accept().expect("accept"); for _ in 0..10 { @@ -803,10 +1089,6 @@ fn test_pipeline_10() { } }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let mut session = UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()).expect("connect"); @@ -849,18 +1131,13 @@ fn test_pipeline_100() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); - + let scfg = ServerConfig { + max_request_payload_bytes: 65536, + max_response_payload_bytes: 65536, + ..default_server_config() + }; + let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); let server_thread = thread::spawn(move || { - let scfg = ServerConfig { - max_request_payload_bytes: 65536, - max_response_payload_bytes: 65536, - ..default_server_config() - }; - let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); - ready_clone.store(true, Ordering::Release); - let mut session = listener.accept().expect("accept"); for _ in 0..100 { @@ -874,10 +1151,6 @@ fn test_pipeline_100() { } }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let ccfg = ClientConfig { max_request_payload_bytes: 65536, max_response_payload_bytes: 65536, @@ -923,21 +1196,17 @@ fn test_pipeline_mixed_sizes() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); let sizes = [8usize, 256, 1024, 8, 256, 1024, 8, 256, 1024]; let count = sizes.len(); + let scfg = ServerConfig { + max_request_payload_bytes: 65536, + max_response_payload_bytes: 65536, + ..default_server_config() + }; + let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); let server_thread = thread::spawn(move || { - let scfg = ServerConfig { - max_request_payload_bytes: 65536, - max_response_payload_bytes: 65536, - ..default_server_config() - }; - let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); - ready_clone.store(true, Ordering::Release); - let mut session = listener.accept().expect("accept"); for _ in 0..count { @@ -951,10 +1220,6 @@ fn test_pipeline_mixed_sizes() { } }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let ccfg = ClientConfig { max_request_payload_bytes: 65536, max_response_payload_bytes: 65536, @@ -1001,22 +1266,18 @@ fn test_pipeline_chunked_multi() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); let sizes = [200usize, 500, 300, 800, 150]; let count = sizes.len(); + let scfg = ServerConfig { + packet_size: 128, + max_request_payload_bytes: 65536, + max_response_payload_bytes: 65536, + ..default_server_config() + }; + let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); let server_thread = thread::spawn(move || { - let scfg = ServerConfig { - packet_size: 128, - max_request_payload_bytes: 65536, - max_response_payload_bytes: 65536, - ..default_server_config() - }; - let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); - ready_clone.store(true, Ordering::Release); - let mut session = listener.accept().expect("accept"); for _ in 0..count { @@ -1029,10 +1290,6 @@ fn test_pipeline_chunked_multi() { } }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let ccfg = ClientConfig { packet_size: 128, max_request_payload_bytes: 65536, @@ -1068,6 +1325,363 @@ fn test_pipeline_chunked_multi() { cleanup_socket(&svc); } +// Pipelining beyond the socket buffers. Small buffers make the deadlock +// reachable on every platform (macOS and FreeBSD 14 default to 8 KiB, FreeBSD +// 15 to 64 KiB, Linux to about 208 KiB). The client's send timeout only bounds a failing run: a +// passing run never waits on it, and the server has none, so a slow server +// cannot fail a passing run. +const PIPE_BUF_COUNT: usize = 16; +const PIPE_BUF_SIZE: usize = 16000; +const PIPE_BUF_PKT: u32 = 1024; +// Flood mode: small responses keep the client's queue bound small. +const PIPE_BUF_FLOOD_RESP: u32 = 4096; + +fn shrink_socket_buffers(fd: RawFd, bound_sends: bool) { + let size: libc::c_int = 4096; + let tv = libc::timeval { + tv_sec: 10, + tv_usec: 0, + }; + unsafe { + for opt in [libc::SO_SNDBUF, libc::SO_RCVBUF] { + libc::setsockopt( + fd, + libc::SOL_SOCKET, + opt, + &size as *const _ as *const libc::c_void, + std::mem::size_of_val(&size) as libc::socklen_t, + ); + } + if !bound_sends { + return; + } + libc::setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_SNDTIMEO, + &tv as *const _ as *const libc::c_void, + std::mem::size_of_val(&tv) as libc::socklen_t, + ); + } +} + +#[derive(Clone, Copy, PartialEq)] +enum SmallBufMode { + /// Receive one request, send its response. + Echo, + /// Receive one request, then close. + CloseAfterOne, + /// Receive one request, then send responses without reading until the + /// send fails. + Flood, +} + +/// Starts the server in `mode` and connects a client; both ends get small +/// socket buffers. +fn small_buffer_start( + prefix: &str, + mode: SmallBufMode, + max_response: u32, +) -> (String, thread::JoinHandle<()>, UdsSession) { + ensure_run_dir(); + let svc = unique_service(prefix); + cleanup_socket(&svc); + + let scfg = ServerConfig { + packet_size: PIPE_BUF_PKT, + max_request_payload_bytes: 65536, + max_response_payload_bytes: max_response, + ..default_server_config() + }; + let listener = UdsListener::bind(TEST_RUN_DIR, &svc, scfg).expect("listen"); + let server = thread::spawn(move || { + let mut session = listener.accept().expect("accept"); + shrink_socket_buffers(session.fd(), false); + let count = if mode == SmallBufMode::Echo { + PIPE_BUF_COUNT + } else { + 1 + }; + for _ in 0..count { + let mut buf = [0u8; PIPE_BUF_PKT as usize]; + let Ok((hdr, payload)) = session.receive(&mut buf) else { + return; + }; + if mode == SmallBufMode::Echo { + let payload = payload.to_vec(); + let mut resp = hdr; + resp.kind = protocol::KIND_RESPONSE; + if session.send(&mut resp, &payload).is_err() { + return; + } + } + } + if mode == SmallBufMode::Flood { + let payload = vec![0u8; PIPE_BUF_FLOOD_RESP as usize]; + loop { + let mut resp = Header { + kind: protocol::KIND_RESPONSE, + code: 1, + item_count: 1, + message_id: 1, + ..Header::default() + }; + if session.send(&mut resp, &payload).is_err() { + break; + } + } + } + }); + + let ccfg = ClientConfig { + packet_size: PIPE_BUF_PKT, + max_request_payload_bytes: 65536, + max_response_payload_bytes: max_response, + ..default_client_config() + }; + let session = UdsSession::connect(TEST_RUN_DIR, &svc, &ccfg).expect("connect"); + shrink_socket_buffers(session.fd(), true); + (svc, server, session) +} + +fn pipelined_payload(i: usize) -> Vec { + (0..PIPE_BUF_SIZE) + .map(|j| ((i * 7 + j) & 0xFF) as u8) + .collect() +} + +fn send_pipelined_request(session: &mut UdsSession, i: usize) -> Result<(), UdsError> { + let mut hdr = Header { + kind: protocol::KIND_REQUEST, + code: 1, + item_count: 1, + message_id: (i + 1) as u64, + ..Header::default() + }; + session.send(&mut hdr, &pipelined_payload(i)) +} + +/// A client that sends every request before reading any response must not +/// deadlock with the server when the bytes in flight exceed the buffers. +#[test] +fn test_pipeline_exceeds_socket_buffers() { + let (svc, server, mut session) = small_buffer_start("rs_pipebuf", SmallBufMode::Echo, 65536); + + assert!(!session.has_pending(), "no pending data before sending"); + for i in 0..PIPE_BUF_COUNT { + send_pipelined_request(&mut session, i).expect("pipelined send"); + } + // The buffers hold a fraction of the responses, so finishing the sends + // required reading responses into the queue. + assert!(session.has_pending(), "responses queued during the sends"); + for i in 0..PIPE_BUF_COUNT { + let mut rbuf = [0u8; PIPE_BUF_PKT as usize]; + let (rhdr, rpayload) = session.receive(&mut rbuf).expect("recv"); + assert_eq!(rhdr.message_id, (i + 1) as u64); + assert!(rpayload == pipelined_payload(i).as_slice(), "payload {i}"); + } + assert!(session.pending.is_empty(), "pending queue drained"); + assert!(!session.has_pending(), "no pending data after receiving"); + + drop(session); + server.join().expect("server join"); + cleanup_socket(&svc); +} + +/// Stream baseline: a receive that fails mid-stream marks the receive side failed, and +/// every later receive must fail. Bytes left in the pending queue are part +/// of that abandoned stream: here a forged response for an in-flight id +/// follows an over-limit header, and must not be delivered. +#[cfg(any(target_os = "macos", target_os = "freebsd"))] +#[test] +fn test_stream_failed_receive_drops_pending() { + let (svc, server, mut session) = + small_buffer_start("rs_pipestale", SmallBufMode::CloseAfterOne, 65536); + send_pipelined_request(&mut session, 0).expect("request 1"); + + let mut over = Header { + magic: protocol::MAGIC_MSG, + version: protocol::VERSION, + header_len: HEADER_SIZE as u16, + kind: protocol::KIND_RESPONSE, + item_count: 1, + message_id: 1, + payload_len: 65537, + ..Header::default() + }; + let mut buf = vec![0u8; 2 * HEADER_SIZE]; + over.encode(&mut buf[..HEADER_SIZE]); + over.payload_len = 0; + over.encode(&mut buf[HEADER_SIZE..]); + session.pending = Pending { buf, head: 0 }; + + let mut rbuf = [0u8; PIPE_BUF_PKT as usize]; + assert!(matches!( + session.receive(&mut rbuf), + Err(UdsError::LimitExceeded) + )); + assert!(session.receive(&mut rbuf).is_err(), "later receive fails"); + + drop(session); + server.join().expect("server join"); + cleanup_socket(&svc); +} + +/// The server closes while the pipelining client waits for buffer space: +/// the send fails instead of waiting forever. +#[test] +fn test_pipeline_peer_close_during_send() { + let (svc, server, mut session) = + small_buffer_start("rs_pipeclose", SmallBufMode::CloseAfterOne, 65536); + + let mut result = Ok(()); + for i in 0..PIPE_BUF_COUNT { + result = send_pipelined_request(&mut session, i); + if result.is_err() { + break; + } + } + assert!( + matches!(result, Err(UdsError::Send(_))), + "expected Send, got {result:?}" + ); + assert!(session.inflight_ids.is_empty(), "in-flight set cleared"); + + drop(session); + server.join().expect("server join"); + cleanup_socket(&svc); +} + +/// A receive that times out leaves the client's in-flight requests pending, +/// as in C and Go: only a lost peer fails them. The late response is then +/// still accepted. +#[test] +fn test_receive_timeout_keeps_inflight() { + let (fd0, fd1) = socketpair_seqpacket(); + let mut client = test_session(fd0, Role::Client, 4096); + let mut server = test_session(fd1, Role::Server, 4096); + client.inflight_ids.insert(9); + + let mut buf = [0u8; 256]; + let err = client + .receive_with_control(&mut buf, 1, None) + .expect_err("nothing to receive"); + assert!(matches!(err, UdsError::Timeout), "timeout: {err:?}"); + assert!(client.inflight_ids.contains(&9), "in-flight id kept"); + + let mut hdr = Header { + kind: KIND_RESPONSE, + code: 1, + item_count: 1, + message_id: 9, + ..Header::default() + }; + server.send(&mut hdr, &[1, 2, 3, 4]).expect("late response"); + let (h, _) = client.receive(&mut buf).expect("late response accepted"); + assert_eq!(h.message_id, 9); +} + +/// A receive into a buffer too small for a header fails without reading a +/// byte, so the message stays queued and the session stays usable. +#[test] +fn test_receive_undersized_buffer_keeps_session() { + let (fd0, fd1) = socketpair_seqpacket(); + let mut rx = test_session(fd0, Role::Server, 4096); + let mut tx = test_session(fd1, Role::Server, 4096); + + let payload = [1u8, 2, 3, 4, 5, 6, 7, 8]; + let mut hdr = Header { + kind: KIND_REQUEST, + code: 1, + item_count: 1, + message_id: 7, + ..Header::default() + }; + tx.send(&mut hdr, &payload).expect("request sent"); + + let mut small = [0u8; HEADER_SIZE - 1]; + let err = rx.receive(&mut small).expect_err("undersized buffer"); + assert!(matches!(err, UdsError::BadParam(_)), "undersized: {err:?}"); + + let mut buf = [0u8; 256]; + let (h, p) = rx + .receive_with_control(&mut buf, 1000, None) + .expect("request still received"); + assert_eq!(h.message_id, 7); + assert_eq!(p, &payload); +} + +/// A datagram longer than its header declares is a protocol error. The +/// pending queue, filled while a send waits for buffer space, must keep the +/// whole datagram so its receive rejects it as the direct receive does, +/// instead of storing it cut to packet_size and accepting it. +#[cfg(not(any(target_os = "macos", target_os = "freebsd")))] +#[test] +fn test_pending_oversized_datagram_rejected() { + let (fd0, fd1) = socketpair_seqpacket(); + let packet_size = 2 * HEADER_SIZE; + let mut session = test_session(fd0, Role::Server, packet_size as u32); + + // The header declares exactly packet_size; 16 more bytes follow. + let mut dgram = vec![0xABu8; packet_size + 16]; + let hdr = Header { + magic: MAGIC_MSG, + version: VERSION, + header_len: protocol::HEADER_LEN, + kind: KIND_REQUEST, + code: 1, + flags: 0, + transport_status: protocol::STATUS_OK, + payload_len: HEADER_SIZE as u32, + item_count: 1, + message_id: 1, + }; + hdr.encode(&mut dgram[..HEADER_SIZE]); + let mut buf = [0u8; 256]; + + raw_send(fd1, &dgram).expect("direct datagram"); + let err = session.receive(&mut buf).expect_err("direct receive"); + assert!(matches!(err, UdsError::Protocol(_)), "direct: {err:?}"); + + raw_send(fd1, &dgram).expect("queued datagram"); + let mut eof = false; + session + .pending + .drain(fd0, packet_size, 4096, &mut eof) + .expect("drain"); + assert!(!eof && !session.pending.is_empty(), "drain queues it"); + let err = session.receive(&mut buf).expect_err("pending receive"); + assert!(matches!(err, UdsError::Protocol(_)), "pending: {err:?}"); + assert!(session.pending.is_empty(), "pending queue consumed"); + + unsafe { libc::close(fd1) }; +} + +/// A server that sends more than the in-flight responses can occupy fails +/// the client session instead of growing its queue without limit. +#[test] +fn test_pipeline_pending_bound() { + let (svc, server, mut session) = + small_buffer_start("rs_pipebound", SmallBufMode::Flood, PIPE_BUF_FLOOD_RESP); + + let mut result = Ok(()); + for i in 0..PIPE_BUF_COUNT { + result = send_pipelined_request(&mut session, i); + if result.is_err() { + break; + } + } + assert!( + matches!(result, Err(UdsError::Protocol(_))), + "expected Protocol, got {result:?}" + ); + assert!(session.inflight_ids.is_empty(), "in-flight set cleared"); + + drop(session); + server.join().expect("server join"); + cleanup_socket(&svc); +} + #[test] fn test_invalid_service_name() { let bad_names = &["", ".", "..", "foo/bar", "../etc", "name space", "a@b"]; @@ -1161,21 +1775,14 @@ fn test_session_role() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); + let listener = + UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); let server_thread = thread::spawn(move || { - let listener = - UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); - ready_clone.store(true, Ordering::Release); let session = listener.accept().expect("accept"); assert_eq!(session.role(), Role::Server); }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let session = UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()).expect("connect"); assert_eq!(session.role(), Role::Client); @@ -1196,20 +1803,13 @@ fn test_send_on_closed_session() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); + let listener = + UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); let server_thread = thread::spawn(move || { - let listener = - UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); - ready_clone.store(true, Ordering::Release); let _session = listener.accept().expect("accept"); }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let mut session = UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()).expect("connect"); @@ -1250,6 +1850,10 @@ fn test_accept_on_closed_listener_returns_accept_error() { let fd = listener.fd; assert!(fd >= 0, "listener fd should be valid"); assert_eq!(unsafe { libc::close(fd) }, 0, "close listener fd"); + // A parallel test thread may reuse the closed number at once; accepting on + // it would succeed and a later close would hit that thread's fd. Point the + // listener at a number that is never open instead. + listener.fd = 1 << 30; let err = match listener.accept() { Ok(_) => panic!("accept on closed listener should fail"), @@ -1273,22 +1877,16 @@ fn test_duplicate_message_id() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); + let listener = + UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); + let (done_tx, done_rx) = std::sync::mpsc::channel::<()>(); let server_thread = thread::spawn(move || { - let listener = - UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); - ready_clone.store(true, Ordering::Release); let _session = listener.accept().expect("accept"); - // Hold connection open while client tests - thread::sleep(Duration::from_millis(500)); + // Hold the connection open until the client is done. + let _ = done_rx.recv(); }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let mut session = UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()).expect("connect"); @@ -1314,6 +1912,7 @@ fn test_duplicate_message_id() { assert!(matches!(result, Err(UdsError::DuplicateMsgId(42)))); drop(session); + drop(done_tx); server_thread.join().expect("server join"); cleanup_socket(&svc); } @@ -1393,6 +1992,90 @@ fn test_receive_batch_count_exceeds_limit() { unsafe { libc::close(fd1) }; } +// ----------------------------------------------------------------------- +// Receive: abort-poll slices must not end the wait (SOW-0041) +// ----------------------------------------------------------------------- + +/// Send a one-byte response for message 1 on `fd` after `delay`. +fn send_response_after(fd: RawFd, delay: Duration) -> thread::JoinHandle<()> { + thread::spawn(move || { + thread::sleep(delay); + let hdr = Header { + magic: MAGIC_MSG, + version: VERSION, + header_len: protocol::HEADER_LEN, + kind: KIND_RESPONSE, + code: 1, + flags: 0, + transport_status: protocol::STATUS_OK, + payload_len: 1, + item_count: 1, + message_id: 1, + }; + let mut pkt = [0u8; HEADER_SIZE + 1]; + hdr.encode(&mut pkt[..HEADER_SIZE]); + pkt[HEADER_SIZE] = 0x5A; + raw_send(fd, &pkt).expect("send delayed response"); + }) +} + +#[test] +fn test_receive_with_abort_waits_past_poll_slice() { + // (timeout_ms, label): 0 means wait indefinitely. + for (timeout_ms, label) in [(0u32, "infinite"), (2000u32, "2000 ms")] { + let (fd0, fd1) = socketpair_seqpacket(); + let mut session = test_session(fd0, Role::Client, 4096); + session.inflight_ids.insert(1); + let abort = AtomicBool::new(false); + let sender = send_response_after(fd1, Duration::from_millis(300)); + + let mut buf = [0u8; 64]; + let started = std::time::Instant::now(); + let result = session + .receive_with_control(&mut buf, timeout_ms, Some(&abort)) + .map(|(hdr, payload)| (hdr.message_id, payload.to_vec())); + let elapsed = started.elapsed(); + + assert_eq!( + result.expect(label), + (1, vec![0x5A]), + "{label}: delayed response must arrive" + ); + assert!( + elapsed >= Duration::from_millis(250), + "{label}: {elapsed:?}" + ); + sender.join().expect("sender join"); + unsafe { libc::close(fd1) }; + } +} + +#[test] +fn test_receive_abort_after_poll_slice_reports_aborted() { + let (fd0, fd1) = socketpair_seqpacket(); + let mut session = test_session(fd0, Role::Client, 4096); + session.inflight_ids.insert(1); + let abort = Arc::new(AtomicBool::new(false)); + let abort_setter = abort.clone(); + let setter = thread::spawn(move || { + thread::sleep(Duration::from_millis(300)); + abort_setter.store(true, Ordering::Release); + }); + + let mut buf = [0u8; 64]; + let started = std::time::Instant::now(); + let err = session + .receive_with_control(&mut buf, 5000, Some(&abort)) + .expect_err("abort must end the wait"); + let elapsed = started.elapsed(); + + assert!(matches!(err, UdsError::Aborted), "got {err:?}"); + assert!(elapsed >= Duration::from_millis(250), "{elapsed:?}"); + assert!(elapsed < Duration::from_millis(2000), "{elapsed:?}"); + setter.join().expect("setter join"); + unsafe { libc::close(fd1) }; +} + #[test] fn test_directional_limit_negotiation() { ensure_run_dir(); @@ -1400,20 +2083,15 @@ fn test_directional_limit_negotiation() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); - + let scfg = ServerConfig { + max_request_payload_bytes: 4096, + max_request_batch_items: 8, + max_response_payload_bytes: 8192, + max_response_batch_items: 32, + ..default_server_config() + }; + let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); let server_thread = thread::spawn(move || { - let scfg = ServerConfig { - max_request_payload_bytes: 4096, - max_request_batch_items: 8, - max_response_payload_bytes: 8192, - max_response_batch_items: 32, - ..default_server_config() - }; - let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); - ready_clone.store(true, Ordering::Release); - let session = listener.accept().expect("accept"); assert_eq!(session.max_request_payload_bytes, 4096); assert_eq!(session.max_request_batch_items, 16); @@ -1422,10 +2100,6 @@ fn test_directional_limit_negotiation() { assert_ne!(session.session_id, 0); }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let ccfg = ClientConfig { max_request_payload_bytes: 4096, max_request_batch_items: 16, @@ -1491,6 +2165,7 @@ fn test_receive_packet_too_short_for_header() { let mut session = test_session(fd0, Role::Server, 4096); raw_send(fd1, &[0x01]).expect("send short packet"); + end_stream_writes(fd1); let mut buf = [0u8; 64]; let err = session.receive(&mut buf).expect_err("packet too short"); @@ -1522,13 +2197,24 @@ fn test_receive_packet_longer_than_declared_payload() { pkt[HEADER_SIZE..].copy_from_slice(&payload); raw_send(fd1, &pkt).expect("send packet with trailing bytes"); + end_stream_writes(fd1); let mut buf = [0u8; 128]; + // A stream has no packet end to check: the message completes and the + // trailing byte is rejected as the next packet, a truncated header. + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + { + let (_, payload) = session.receive(&mut buf).expect("message delivered"); + assert_eq!(payload, &[0xBE]); + } let err = session .receive(&mut buf) .expect_err("trailing bytes should be rejected"); + #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] assert!(matches!(err, UdsError::Protocol(ref msg) if msg.contains("exceeds declared payload_len"))); + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + assert!(matches!(err, UdsError::Protocol(_))); unsafe { libc::close(fd1) }; } @@ -1750,7 +2436,8 @@ fn test_receive_chunked_batch_directory_too_short() { #[test] fn test_receive_chunked_batch_directory_invalid() { let (fd0, fd1) = socketpair_seqpacket(); - let mut session = test_session(fd0, Role::Server, (HEADER_SIZE + 8) as u32); + // The first chunk below carries 16 payload bytes, a full packet. + let mut session = test_session(fd0, Role::Server, (HEADER_SIZE + 16) as u32); let mut full_payload = [0u8; 24]; full_payload[0..4].copy_from_slice(&0u32.to_ne_bytes()); @@ -1887,6 +2574,7 @@ fn test_receive_chunk_payload_len_mismatch() { chk.encode(&mut second_pkt[..HEADER_SIZE]); second_pkt[HEADER_SIZE..].copy_from_slice(&second_payload); raw_send(fd1, &second_pkt).expect("send bad chunk length"); + end_stream_writes(fd1); let mut buf = [0u8; 64]; let err = session @@ -1920,6 +2608,7 @@ fn test_receive_continuation_too_short() { first_pkt[HEADER_SIZE..].copy_from_slice(&first_payload); raw_send(fd1, &first_pkt).expect("send first chunk"); raw_send(fd1, &[0x01]).expect("send truncated continuation"); + end_stream_writes(fd1); let mut buf = [0u8; 64]; let err = session @@ -2055,35 +2744,99 @@ fn test_profile_preference_selects_shm_futex() { ensure_run_dir(); let svc = unique_service("rs_pref_prof"); cleanup_socket(&svc); + // Without POSIX SHM the platform limit leaves baseline on both sides. + let want = if cfg!(any(target_os = "linux", target_os = "freebsd")) { + protocol::PROFILE_SHM_FUTEX + } else { + PROFILE_BASELINE + }; let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); - + let scfg = ServerConfig { + supported_profiles: PROFILE_BASELINE | protocol::PROFILE_SHM_FUTEX, + preferred_profiles: protocol::PROFILE_SHM_FUTEX, + ..default_server_config() + }; + let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); let server_thread = thread::spawn(move || { - let scfg = ServerConfig { - supported_profiles: PROFILE_BASELINE | protocol::PROFILE_SHM_FUTEX, - preferred_profiles: protocol::PROFILE_SHM_FUTEX, - ..default_server_config() - }; - let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); - ready_clone.store(true, Ordering::Release); let session = listener.accept().expect("accept"); - assert_eq!(session.selected_profile, protocol::PROFILE_SHM_FUTEX); + assert_eq!(session.selected_profile, want); }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let ccfg = ClientConfig { supported_profiles: PROFILE_BASELINE | protocol::PROFILE_SHM_FUTEX, preferred_profiles: protocol::PROFILE_SHM_FUTEX, ..default_client_config() }; let session = UdsSession::connect(TEST_RUN_DIR, &svc, &ccfg).expect("connect"); - assert_eq!(session.selected_profile, protocol::PROFILE_SHM_FUTEX); + assert_eq!(session.selected_profile, want); + + drop(session); + server_thread.join().expect("server join"); + cleanup_socket(&svc); +} + +// An unassigned profile bit offered and preferred by both peers is never +// selected: they would otherwise run baseline under that label. +#[test] +fn test_unassigned_profile_bit_not_selected() { + ensure_run_dir(); + let svc = unique_service("rs_unassigned_prof"); + cleanup_socket(&svc); + const UNASSIGNED: u32 = 0x10; + + let svc_clone = svc.clone(); + let scfg = ServerConfig { + supported_profiles: PROFILE_BASELINE | UNASSIGNED, + preferred_profiles: UNASSIGNED, + ..default_server_config() + }; + let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); + let server_thread = thread::spawn(move || { + let session = listener.accept().expect("accept"); + assert_eq!(session.selected_profile, PROFILE_BASELINE); + }); + + let ccfg = ClientConfig { + supported_profiles: PROFILE_BASELINE | UNASSIGNED, + preferred_profiles: UNASSIGNED, + ..default_client_config() + }; + let session = UdsSession::connect(TEST_RUN_DIR, &svc, &ccfg).expect("connect"); + assert_eq!(session.selected_profile, PROFILE_BASELINE); + + drop(session); + server_thread.join().expect("server join"); + cleanup_socket(&svc); +} + +// A deadline-bounded send of an untracked message with nothing in flight +// still has room for one response in its pending queue. +#[test] +fn test_bounded_control_send_idle() { + ensure_run_dir(); + let svc = unique_service("rs_bounded_control"); + cleanup_socket(&svc); + let listener = UdsListener::bind(TEST_RUN_DIR, &svc, default_server_config()).expect("listen"); + let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); + let server_thread = thread::spawn(move || { + let session = listener.accept().expect("accept"); + let _ = release_rx.recv(); + drop(session); + }); + + let mut session = + UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()).expect("connect"); + let mut hdr = Header { + kind: protocol::KIND_CONTROL, + message_id: 1, + ..Header::default() + }; + session + .send_with_control(&mut hdr, &[], 5000, None) + .expect("bounded control send"); + drop(release_tx); drop(session); server_thread.join().expect("server join"); cleanup_socket(&svc); @@ -2096,25 +2849,17 @@ fn test_zero_supported_profiles_defaults_to_baseline() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); - + let scfg = ServerConfig { + supported_profiles: 0, + preferred_profiles: 0, + ..default_server_config() + }; + let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); let server_thread = thread::spawn(move || { - let scfg = ServerConfig { - supported_profiles: 0, - preferred_profiles: 0, - ..default_server_config() - }; - let listener = UdsListener::bind(TEST_RUN_DIR, &svc_clone, scfg).expect("listen"); - ready_clone.store(true, Ordering::Release); let session = listener.accept().expect("accept"); assert_eq!(session.selected_profile, PROFILE_BASELINE); }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let ccfg = ClientConfig { supported_profiles: 0, preferred_profiles: 0, @@ -2154,17 +2899,14 @@ fn test_connect_rejects_bad_hello_ack_kind() { let svc = unique_service("rs_bad_ack_kind"); cleanup_socket(&svc); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); let svc_clone = svc.clone(); + let fd = raw_listener_fd(&svc_clone); let server = thread::spawn(move || { - let fd = raw_listener_fd(&svc_clone); - ready_clone.store(true, Ordering::Release); let client_fd = unsafe { libc::accept(fd, std::ptr::null_mut(), std::ptr::null_mut()) }; assert!(client_fd >= 0, "accept failed: {}", errno()); let mut hello = [0u8; 128]; - let n = raw_recv(client_fd, &mut hello).expect("recv hello"); + let n = recv_hello(client_fd, &mut hello); assert!(n >= HEADER_SIZE + HELLO_PAYLOAD_SIZE); let ack = HelloAck { @@ -2196,10 +2938,6 @@ fn test_connect_rejects_bad_hello_ack_kind() { } }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let err = match UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()) { Ok(_) => panic!("bad hello ack kind should fail"), Err(err) => err, @@ -2210,23 +2948,96 @@ fn test_connect_rejects_bad_hello_ack_kind() { cleanup_socket(&svc); } +#[test] +fn test_connect_rejects_selected_profile_not_offered() { + ensure_run_dir(); + // The client offers baseline only. + for (i, selected) in [ + protocol::PROFILE_SHM_HYBRID, + 0, + PROFILE_BASELINE | protocol::PROFILE_SHM_HYBRID, + ] + .into_iter() + .enumerate() + { + let svc = unique_service(&format!("rs_ack_selected_{i}")); + cleanup_socket(&svc); + + let fd = raw_listener_fd(&svc); + let server = thread::spawn(move || { + let client_fd = unsafe { libc::accept(fd, std::ptr::null_mut(), std::ptr::null_mut()) }; + assert!(client_fd >= 0, "accept failed: {}", errno()); + + let mut buf = [0u8; 128]; + let n = recv_hello(client_fd, &mut buf); + let hello = Hello::decode(&buf[HEADER_SIZE..n]).expect("decode hello"); + + let ack = HelloAck { + layout_version: 1, + server_supported_profiles: PROFILE_BASELINE | protocol::PROFILE_SHM_HYBRID, + intersection_profiles: PROFILE_BASELINE, + selected_profile: selected, + agreed_max_request_payload_bytes: hello.max_request_payload_bytes, + agreed_max_request_batch_items: hello.max_request_batch_items, + agreed_max_response_payload_bytes: hello.max_response_payload_bytes, + agreed_max_response_batch_items: hello.max_response_batch_items, + agreed_packet_size: hello.packet_size, + session_id: 1, + ..HelloAck::default() + }; + let mut ack_buf = [0u8; HELLO_ACK_PAYLOAD_SIZE]; + ack.encode(&mut ack_buf); + let hdr = Header { + magic: MAGIC_MSG, + version: VERSION, + header_len: protocol::HEADER_LEN, + kind: protocol::KIND_CONTROL, + flags: 0, + code: protocol::CODE_HELLO_ACK, + transport_status: protocol::STATUS_OK, + payload_len: HELLO_ACK_PAYLOAD_SIZE as u32, + item_count: 1, + message_id: 0, + }; + let mut pkt = [0u8; HEADER_SIZE + HELLO_ACK_PAYLOAD_SIZE]; + hdr.encode(&mut pkt[..HEADER_SIZE]); + pkt[HEADER_SIZE..].copy_from_slice(&ack_buf); + raw_send(client_fd, &pkt).expect("send ack"); + + unsafe { + libc::close(client_fd); + libc::close(fd); + } + }); + + let err = match UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()) { + Ok(_) => panic!("HELLO_ACK selecting 0x{selected:x} should fail"), + Err(err) => err, + }; + assert!( + matches!(&err, UdsError::Protocol(msg) if msg.contains("not offered")), + "selected 0x{selected:x}: {err:?}" + ); + + server.join().expect("server join"); + cleanup_socket(&svc); + } +} + #[test] fn test_connect_rejects_bad_hello_ack_status() { ensure_run_dir(); let svc = unique_service("rs_bad_ack_status"); cleanup_socket(&svc); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); let svc_clone = svc.clone(); + let fd = raw_listener_fd(&svc_clone); let server = thread::spawn(move || { - let fd = raw_listener_fd(&svc_clone); - ready_clone.store(true, Ordering::Release); let client_fd = unsafe { libc::accept(fd, std::ptr::null_mut(), std::ptr::null_mut()) }; assert!(client_fd >= 0, "accept failed: {}", errno()); let mut hello = [0u8; 128]; - let _ = raw_recv(client_fd, &mut hello).expect("recv hello"); + let _ = recv_hello(client_fd, &mut hello); let ack = HelloAck { layout_version: 1, @@ -2257,10 +3068,6 @@ fn test_connect_rejects_bad_hello_ack_status() { } }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let err = match UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()) { Ok(_) => panic!("bad hello ack status should fail"), Err(err) => err, @@ -2277,17 +3084,14 @@ fn test_connect_rejects_bad_hello_ack_version_as_incompatible() { let svc = unique_service("rs_bad_ack_version"); cleanup_socket(&svc); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); let svc_clone = svc.clone(); + let fd = raw_listener_fd(&svc_clone); let server = thread::spawn(move || { - let fd = raw_listener_fd(&svc_clone); - ready_clone.store(true, Ordering::Release); let client_fd = unsafe { libc::accept(fd, std::ptr::null_mut(), std::ptr::null_mut()) }; assert!(client_fd >= 0, "accept failed: {}", errno()); let mut hello = [0u8; 128]; - let _ = raw_recv(client_fd, &mut hello).expect("recv hello"); + let _ = recv_hello(client_fd, &mut hello); let ack = HelloAck { layout_version: 1, @@ -2318,10 +3122,6 @@ fn test_connect_rejects_bad_hello_ack_version_as_incompatible() { } }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let err = match UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()) { Ok(_) => panic!("bad hello ack version should fail"), Err(err) => err, @@ -2338,17 +3138,14 @@ fn test_connect_rejects_incompatible_hello_ack_status() { let svc = unique_service("rs_ack_incompat_status"); cleanup_socket(&svc); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); let svc_clone = svc.clone(); + let fd = raw_listener_fd(&svc_clone); let server = thread::spawn(move || { - let fd = raw_listener_fd(&svc_clone); - ready_clone.store(true, Ordering::Release); let client_fd = unsafe { libc::accept(fd, std::ptr::null_mut(), std::ptr::null_mut()) }; assert!(client_fd >= 0, "accept failed: {}", errno()); let mut hello = [0u8; 128]; - let _ = raw_recv(client_fd, &mut hello).expect("recv hello"); + let _ = recv_hello(client_fd, &mut hello); let ack = HelloAck { layout_version: 1, @@ -2379,10 +3176,6 @@ fn test_connect_rejects_incompatible_hello_ack_status() { } }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let err = match UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()) { Ok(_) => panic!("incompatible hello ack status should fail"), Err(err) => err, @@ -2399,17 +3192,14 @@ fn test_connect_rejects_bad_hello_ack_layout_as_incompatible() { let svc = unique_service("rs_bad_ack_layout"); cleanup_socket(&svc); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); let svc_clone = svc.clone(); + let fd = raw_listener_fd(&svc_clone); let server = thread::spawn(move || { - let fd = raw_listener_fd(&svc_clone); - ready_clone.store(true, Ordering::Release); let client_fd = unsafe { libc::accept(fd, std::ptr::null_mut(), std::ptr::null_mut()) }; assert!(client_fd >= 0, "accept failed: {}", errno()); let mut hello = [0u8; 128]; - let _ = raw_recv(client_fd, &mut hello).expect("recv hello"); + let _ = recv_hello(client_fd, &mut hello); let ack = HelloAck { layout_version: 2, @@ -2440,10 +3230,6 @@ fn test_connect_rejects_bad_hello_ack_layout_as_incompatible() { } }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let err = match UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()) { Ok(_) => panic!("bad hello ack layout should fail"), Err(err) => err, @@ -2460,17 +3246,14 @@ fn test_connect_rejects_truncated_hello_ack() { let svc = unique_service("rs_bad_ack_trunc"); cleanup_socket(&svc); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); let svc_clone = svc.clone(); + let fd = raw_listener_fd(&svc_clone); let server = thread::spawn(move || { - let fd = raw_listener_fd(&svc_clone); - ready_clone.store(true, Ordering::Release); let client_fd = unsafe { libc::accept(fd, std::ptr::null_mut(), std::ptr::null_mut()) }; assert!(client_fd >= 0, "accept failed: {}", errno()); let mut hello = [0u8; 128]; - let _ = raw_recv(client_fd, &mut hello).expect("recv hello"); + let _ = recv_hello(client_fd, &mut hello); let hdr = Header { magic: MAGIC_MSG, @@ -2494,10 +3277,6 @@ fn test_connect_rejects_truncated_hello_ack() { } }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let err = match UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()) { Ok(_) => panic!("truncated hello ack should fail"), Err(err) => err, @@ -2520,7 +3299,7 @@ fn test_accept_rejects_bad_hello_kind() { let svc_clone = svc.clone(); let client = thread::spawn(move || { let path = build_socket_path(TEST_RUN_DIR, &svc_clone).expect("socket path"); - let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_SEQPACKET, 0) }; + let fd = unsafe { libc::socket(libc::AF_UNIX, SOCK_TYPE, 0) }; assert!(fd >= 0, "socket failed: {}", errno()); connect_unix(fd, &path).expect("connect"); @@ -2579,7 +3358,7 @@ fn test_accept_rejects_bad_hello_version_as_incompatible() { let svc_clone = svc.clone(); let client = thread::spawn(move || { let path = build_socket_path(TEST_RUN_DIR, &svc_clone).expect("socket path"); - let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_SEQPACKET, 0) }; + let fd = unsafe { libc::socket(libc::AF_UNIX, SOCK_TYPE, 0) }; assert!(fd >= 0, "socket failed: {}", errno()); connect_unix(fd, &path).expect("connect"); @@ -2638,7 +3417,7 @@ fn test_accept_rejects_bad_hello_layout_as_incompatible() { let svc_clone = svc.clone(); let client = thread::spawn(move || { let path = build_socket_path(TEST_RUN_DIR, &svc_clone).expect("socket path"); - let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_SEQPACKET, 0) }; + let fd = unsafe { libc::socket(libc::AF_UNIX, SOCK_TYPE, 0) }; assert!(fd >= 0, "socket failed: {}", errno()); connect_unix(fd, &path).expect("connect"); @@ -2696,14 +3475,10 @@ fn test_unknown_response_msg_id() { cleanup_socket(&svc); let svc_clone = svc.clone(); - let ready = Arc::new(AtomicBool::new(false)); - let ready_clone = ready.clone(); + let listener = + UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); let server_thread = thread::spawn(move || { - let listener = - UdsListener::bind(TEST_RUN_DIR, &svc_clone, default_server_config()).expect("listen"); - ready_clone.store(true, Ordering::Release); - let mut session = listener.accept().expect("accept"); let mut buf = [0u8; 8192]; let (hdr, payload) = session.receive(&mut buf).expect("recv"); @@ -2721,10 +3496,6 @@ fn test_unknown_response_msg_id() { session.send(&mut resp, &payload).expect("send"); }); - while !ready.load(Ordering::Acquire) { - thread::sleep(Duration::from_millis(1)); - } - let mut session = UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()).expect("connect"); @@ -2757,28 +3528,154 @@ fn test_path_too_long() { assert!(matches!(result, Err(UdsError::PathTooLong))); } -// ----------------------------------------------------------------------- -// Helpers: highest_bit, apply_default -// ----------------------------------------------------------------------- +#[cfg(not(any(target_os = "macos", target_os = "freebsd")))] +#[test] +fn test_packet_size_limit_invalid_fd_uses_fallback() { + assert_eq!(packet_size_limit(-1, 0), DEFAULT_PACKET_SIZE_FALLBACK); + assert_eq!(packet_size_limit(-1, 1000), 1000); + assert_eq!( + packet_size_limit(-1, DEFAULT_PACKET_SIZE_FALLBACK + 1), + DEFAULT_PACKET_SIZE_FALLBACK + ); +} +#[cfg(not(any(target_os = "macos", target_os = "freebsd")))] +fn set_sndbuf(fd: RawFd, bytes: libc::c_int) { + let rc = unsafe { + libc::setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_SNDBUF, + &bytes as *const _ as *const libc::c_void, + std::mem::size_of::() as libc::socklen_t, + ) + }; + assert_eq!(rc, 0, "setsockopt(SO_SNDBUF) failed: {}", errno()); +} + +#[cfg(any(target_os = "macos", target_os = "freebsd"))] #[test] -fn test_highest_bit() { - assert_eq!(highest_bit(0), 0); - assert_eq!(highest_bit(1), 1); - assert_eq!(highest_bit(0b0101), 4); - assert_eq!(highest_bit(0b1000), 8); - assert_eq!(highest_bit(0xFF), 128); +fn test_packet_size_limit_is_fixed_on_a_stream() { + assert_eq!(packet_size_limit(-1, 0), PACKET_SIZE_MAX); + assert_eq!(packet_size_limit(-1, 1000), 1000); + assert_eq!(packet_size_limit(-1, u32::MAX), PACKET_SIZE_MAX); + + // A full packet crosses even though it is far above SO_SNDBUF. + let (fd0, fd1) = socketpair_seqpacket(); + let mut pkt = vec![0xA5u8; PACKET_SIZE_MAX as usize]; + let hdr = Header { + magic: MAGIC_MSG, + version: VERSION, + header_len: protocol::HEADER_LEN, + kind: KIND_REQUEST, + item_count: 1, + payload_len: PACKET_SIZE_MAX - HEADER_SIZE as u32, + ..Header::default() + }; + hdr.encode(&mut pkt[..HEADER_SIZE]); + let reader = thread::spawn(move || { + let mut buf = vec![0u8; PACKET_SIZE_MAX as usize]; + let n = stream::recv_packet(fd1, &mut buf, PACKET_SIZE_MAX, None).expect("recv"); + unsafe { libc::close(fd1) }; + (n, buf[n - 1]) + }); + send_packet(fd0, &pkt[..HEADER_SIZE], &pkt[HEADER_SIZE..]).expect("send"); + assert_eq!(reader.join().unwrap(), (PACKET_SIZE_MAX as usize, 0xA5)); + unsafe { libc::close(fd0) }; } +#[cfg(not(any(target_os = "macos", target_os = "freebsd")))] #[test] -fn test_apply_default() { - assert_eq!(apply_default(0, 42), 42); - assert_eq!(apply_default(10, 42), 10); +fn test_packet_size_limit_matches_kernel_datagram_limit() { + // None keeps the system default SO_SNDBUF. + for sndbuf in [None, Some(4096), Some(65536)] { + let (fd0, fd1) = socketpair_seqpacket(); + if let Some(bytes) = sndbuf { + set_sndbuf(fd0, bytes); + } + let limit = packet_size_limit(fd0, 0); + assert!(limit > HEADER_SIZE as u32 && limit <= PACKET_SIZE_MAX); + assert_eq!(packet_size_limit(fd0, u32::MAX), limit, "{sndbuf:?}"); + + let pkt = vec![0u8; limit as usize]; + raw_send(fd0, &pkt).unwrap_or_else(|e| panic!("{sndbuf:?}: {limit} bytes: {e:?}")); + let mut buf = vec![0u8; limit as usize]; + assert_eq!(raw_recv(fd1, &mut buf).expect("recv"), limit as usize); + + if limit < PACKET_SIZE_MAX { + // Guards the SO_SNDBUF - 32 rule: one more byte must not fit. + let over = vec![0u8; limit as usize + 1]; + assert!(raw_send(fd0, &over).is_err(), "{sndbuf:?}: {limit} + 1"); + } + unsafe { + libc::close(fd0); + libc::close(fd1); + } + } } #[test] -fn test_detect_packet_size_invalid_fd_returns_fallback() { - assert_eq!(detect_packet_size(-1), DEFAULT_PACKET_SIZE_FALLBACK); +fn test_connect_rejects_agreed_packet_size_above_proposal() { + ensure_run_dir(); + let svc = unique_service("rs_ack_pkt_above"); + cleanup_socket(&svc); + + let svc_clone = svc.clone(); + let fd = raw_listener_fd(&svc_clone); + let server = thread::spawn(move || { + let client_fd = unsafe { libc::accept(fd, std::ptr::null_mut(), std::ptr::null_mut()) }; + assert!(client_fd >= 0, "accept failed: {}", errno()); + + let mut hello_buf = [0u8; 128]; + let n = recv_hello(client_fd, &mut hello_buf); + let hello = Hello::decode(&hello_buf[HEADER_SIZE..n]).expect("decode hello"); + + let ack = HelloAck { + layout_version: 1, + server_supported_profiles: PROFILE_BASELINE, + intersection_profiles: PROFILE_BASELINE, + selected_profile: PROFILE_BASELINE, + agreed_max_request_payload_bytes: 4096, + agreed_max_request_batch_items: 16, + agreed_max_response_payload_bytes: 4096, + agreed_max_response_batch_items: 16, + agreed_packet_size: hello.packet_size + 1, + session_id: 1, + ..HelloAck::default() + }; + let mut ack_buf = [0u8; HELLO_ACK_PAYLOAD_SIZE]; + ack.encode(&mut ack_buf); + let hdr = Header { + magic: MAGIC_MSG, + version: VERSION, + header_len: protocol::HEADER_LEN, + kind: protocol::KIND_CONTROL, + flags: 0, + code: protocol::CODE_HELLO_ACK, + transport_status: protocol::STATUS_OK, + payload_len: HELLO_ACK_PAYLOAD_SIZE as u32, + item_count: 1, + message_id: 0, + }; + let mut pkt = [0u8; HEADER_SIZE + HELLO_ACK_PAYLOAD_SIZE]; + hdr.encode(&mut pkt[..HEADER_SIZE]); + pkt[HEADER_SIZE..].copy_from_slice(&ack_buf); + raw_send(client_fd, &pkt).expect("send ack"); + + unsafe { + libc::close(client_fd); + libc::close(fd); + } + }); + + let err = match UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()) { + Ok(_) => panic!("agreed packet_size above the proposal must fail"), + Err(err) => err, + }; + assert!(matches!(err, UdsError::Protocol(_)), "got {err:?}"); + + server.join().expect("server join"); + cleanup_socket(&svc); } #[test] @@ -2795,3 +3692,151 @@ fn test_raw_send_closed_peer_returns_send_error() { libc::close(fd0); } } + +// Sockets must not leak into children a consumer forks and execs: an +// inherited session socket keeps the peer from ever seeing EOF, which is the +// dead-peer signal. +#[test] +fn test_listener_and_session_sockets_are_cloexec() { + ensure_run_dir(); + let svc = unique_service("rs_cloexec"); + cleanup_socket(&svc); + + let cloexec = |fd: RawFd| unsafe { libc::fcntl(fd, libc::F_GETFD) } & libc::FD_CLOEXEC != 0; + let listener = UdsListener::bind(TEST_RUN_DIR, &svc, default_server_config()).expect("listen"); + let listener_fd = listener.fd(); + let server_thread = thread::spawn(move || { + let session = listener.accept().expect("accept"); + (cloexec(listener.fd()), cloexec(session.fd())) + }); + + let session = + UdsSession::connect(TEST_RUN_DIR, &svc, &default_client_config()).expect("connect"); + let (listener_ok, accepted_ok) = server_thread.join().expect("server thread"); + assert!(listener_fd >= 0); + assert!(listener_ok, "listener socket must be close-on-exec"); + assert!(accepted_ok, "accepted session socket must be close-on-exec"); + assert!( + cloexec(session.fd()), + "client session socket must be close-on-exec" + ); + cleanup_socket(&svc); +} + +// ----------------------------------------------------------------------- +// Stream baseline (macOS, FreeBSD) +// ----------------------------------------------------------------------- + +/// A receive that consumed part of a message cannot leave the session +/// usable: the rest is still queued and would be read as a header. +#[cfg(any(target_os = "macos", target_os = "freebsd"))] +#[test] +fn test_stream_partial_receive_ends_reads() { + let (fd0, fd1) = socketpair_seqpacket(); + let mut session = test_session(fd0, Role::Server, 1024); + let mut buf = [0u8; 2048]; + + assert_eq!( + session.receive_with_control(&mut buf, 1, None).err(), + Some(UdsError::Timeout), + "timeout before the first byte" + ); + + let hdr = Header { + magic: MAGIC_MSG, + version: VERSION, + header_len: protocol::HEADER_LEN, + kind: KIND_REQUEST, + code: 1, + item_count: 1, + message_id: 9, + payload_len: 5, + ..Header::default() + }; + let mut pkt = [0u8; HEADER_SIZE + 5]; + hdr.encode(&mut pkt[..HEADER_SIZE]); + pkt[HEADER_SIZE..].copy_from_slice(b"hello"); + send_packet(fd1, &pkt, &[]).unwrap(); + let (_, payload) = session + .receive(&mut buf) + .expect("intact after a clean timeout"); + assert_eq!(payload, b"hello"); + + send_packet(fd1, &pkt[..10], &[]).unwrap(); + assert_eq!( + session.receive_with_control(&mut buf, 1, None).err(), + Some(UdsError::Timeout), + "timeout inside a message" + ); + send_packet(fd1, &pkt[10..], &[]).unwrap(); + assert_eq!( + session.receive(&mut buf).err(), + Some(UdsError::Recv(0)), + "later receives fail" + ); + + // Sends always write whole messages, so they stay valid. + let mut resp = Header { + kind: protocol::KIND_RESPONSE, + code: 1, + item_count: 1, + message_id: 9, + ..Header::default() + }; + session.send(&mut resp, b"ok").expect("sends still work"); + let n = stream::recv_packet(fd1, &mut buf, 1024, None).expect("peer gets the response"); + assert_eq!(n, HEADER_SIZE + 2); + unsafe { libc::close(fd1) }; +} + +/// macOS binds in `{service}.d/` and renames the socket into place. +#[cfg(target_os = "macos")] +#[test] +fn test_listen_bind_dir() { + use std::os::unix::fs::PermissionsExt; + + ensure_run_dir(); + let svc = unique_service("rs_bind_dir"); + cleanup_socket(&svc); + let dir = format!("{TEST_RUN_DIR}/{svc}.d"); + let path = build_socket_path(TEST_RUN_DIR, &svc).unwrap(); + let listen = || UdsListener::bind(TEST_RUN_DIR, &svc, default_server_config()); + let gone = |p: &str| std::fs::symlink_metadata(p).is_err(); + + let listener = listen().expect("listen"); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "socket is owner-only"); + assert!(gone(&dir), "bind directory removed"); + drop(listener); + + // A crashed server's leftover directory is reclaimed. + std::fs::create_dir(&dir).unwrap(); + std::fs::write(format!("{dir}/s"), b"").unwrap(); + drop(listen().expect("listen over a leftover directory")); + assert!(gone(&dir)); + + // A non-directory at the name is junk, as at the socket name. + std::fs::write(&dir, b"").unwrap(); + drop(listen().expect("listen over a leftover file")); + assert!(gone(&dir)); + + // Foreign content is never deleted: listen fails instead. + std::fs::create_dir(&dir).unwrap(); + let other = format!("{dir}/other"); + std::fs::write(&other, b"").unwrap(); + assert!(matches!(listen(), Err(UdsError::Socket(_)))); + assert!(std::fs::metadata(&other).is_ok(), "foreign content kept"); + std::fs::remove_file(&other).unwrap(); + + // A directory where the socket goes is foreign content too. + let sub = format!("{dir}/s"); + std::fs::create_dir(&sub).unwrap(); + assert!(matches!(listen(), Err(UdsError::Socket(_)))); + assert!( + std::fs::metadata(&sub).unwrap().is_dir(), + "foreign subdirectory kept" + ); + std::fs::remove_dir(&sub).unwrap(); + std::fs::remove_dir(&dir).unwrap(); + cleanup_socket(&svc); +} diff --git a/src/crates/netipc/src/transport/shm.rs b/src/crates/netipc/src/transport/shm.rs index f6cdf7c0..76ac56f3 100644 --- a/src/crates/netipc/src/transport/shm.rs +++ b/src/crates/netipc/src/transport/shm.rs @@ -1,6 +1,7 @@ -//! L1 POSIX SHM transport (Linux only). +//! L1 POSIX SHM transport (Linux and FreeBSD). //! -//! Shared memory data plane with spin+futex synchronization. +//! Shared memory data plane with spin+futex synchronization (FreeBSD: +//! `_umtx_op` wait/wake on the same signal words). //! The SHM region carries the same outer protocol envelope as the UDS //! transport. Higher levels see no difference. //! @@ -30,7 +31,9 @@ const OFF_REQ_SIGNAL: usize = 56; const OFF_RESP_SIGNAL: usize = 60; // futex operations +#[cfg(target_os = "linux")] const FUTEX_WAIT: i32 = 0; +#[cfg(target_os = "linux")] const FUTEX_WAKE: i32 = 1; // --------------------------------------------------------------------------- @@ -155,7 +158,6 @@ pub struct ShmContext { local_resp_seq: u64, spin_tries: u32, - owner_generation: u32, path: PathBuf, } @@ -174,39 +176,6 @@ impl ShmContext { self.fd } - /// Check if the region's owner process is still alive. - pub fn owner_alive(&self) -> bool { - if self.base.is_null() || self.region_size < HEADER_LEN as usize { - return false; - } - // Read the PID and generation fields by computing their byte offsets - // directly from self.base, avoiding any pointer dereference through - // a reference type. This is safer than casting to *const RegionHeader - // and dereferencing because we never materialize a borrow of the - // header struct — we just read raw bytes at known offsets. - // - // SAFETY: self.base is a non-null mmap'd region of at least HEADER_LEN - // bytes (checked above). The field offsets below are within HEADER_LEN. - // owner_pid is at offset 8, owner_generation at offset 12 - let pid: i32 = unsafe { - let p = self.base.add(8) as *const i32; - ptr::read_volatile(p) - }; - if !pid_alive(pid) { - return false; - } - if self.owner_generation != 0 { - let cur_gen: u32 = unsafe { - let p = self.base.add(12) as *const u32; - ptr::read_volatile(p) - }; - if cur_gen != self.owner_generation { - return false; - } - } - true - } - /// Create a SHM region (server side). /// /// Creates `{run_dir}/{service_name}-{session_id:016x}.ipcshm` with O_EXCL. @@ -242,13 +211,13 @@ impl ShmContext { let mut fd = unsafe { libc::open( c_path.as_ptr(), - libc::O_RDWR | libc::O_CREAT | libc::O_EXCL, + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC, 0o600, ) }; // If O_EXCL failed (file exists), do stale recovery and retry. - if fd < 0 && unsafe { *libc::__errno_location() } == libc::EEXIST { + if fd < 0 && errno() == libc::EEXIST { let stale = check_shm_stale(&path); if stale == StaleResult::LiveServer { return Err(ShmError::AddrInUse); @@ -257,20 +226,36 @@ impl ShmContext { fd = unsafe { libc::open( c_path.as_ptr(), - libc::O_RDWR | libc::O_CREAT | libc::O_EXCL, + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC, 0o600, ) }; // If retry still fails with EEXIST, the stale check couldn't // unlink (e.g., EACCES) — treat as address-in-use rather than // leaking EEXIST up the stack. - if fd < 0 && unsafe { *libc::__errno_location() } == libc::EEXIST { + if fd < 0 && errno() == libc::EEXIST { return Err(ShmError::AddrInUse); } } if fd < 0 { return Err(ShmError::Open(errno())); } + // The file takes 0600 & ~umask; a umask without owner read or write + // would stop the client from attaching read-write. A region the + // client cannot attach is not created: the session then drops SHM. + loop { + if unsafe { libc::fchmod(fd, libc::S_IRUSR | libc::S_IWUSR) } == 0 { + break; + } + let e = errno(); + if e != libc::EINTR { + unsafe { + libc::close(fd); + libc::unlink(c_path.as_ptr()); + } + return Err(ShmError::Open(e)); + } + } if let Err(e) = allocate_region(fd, region_size) { unsafe { @@ -331,7 +316,6 @@ impl ShmContext { local_req_seq: 0, local_resp_seq: 0, spin_tries: DEFAULT_SPIN_TRIES, - owner_generation: generation, path, }) } @@ -345,7 +329,7 @@ impl ShmContext { let path = build_shm_path(run_dir, service_name, session_id)?; let c_path = path_to_cstring(&path)?; - let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDWR) }; + let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) }; if fd < 0 { return Err(ShmError::Open(errno())); } @@ -443,11 +427,12 @@ impl ShmContext { return Err(ShmError::BadSize); } - // Validate region size - let req_end = req_off as usize + req_cap as usize; - let resp_end = resp_off as usize + resp_cap as usize; + // Validate region size in u64: usize sums wrap on 32-bit targets and + // would let a forged header pass the file-size check. + let req_end = u64::from(req_off) + u64::from(req_cap); + let resp_end = u64::from(resp_off) + u64::from(resp_cap); let needed = req_end.max(resp_end); - if file_size < needed { + if (file_size as u64) < needed { unsafe { libc::munmap(base as *mut libc::c_void, file_size); libc::close(fd); @@ -458,7 +443,6 @@ impl ShmContext { // Read current sequence numbers let cur_req_seq = atomic_load_u64(base, OFF_REQ_SEQ); let cur_resp_seq = atomic_load_u64(base, OFF_RESP_SEQ); - let generation = unsafe { (*hdr).owner_generation }; Ok(ShmContext { role: ShmRole::Client, @@ -472,7 +456,6 @@ impl ShmContext { local_req_seq: cur_req_seq, local_resp_seq: cur_resp_seq, spin_tries: DEFAULT_SPIN_TRIES, - owner_generation: generation, path, }) } @@ -748,7 +731,7 @@ pub fn cleanup_stale(run_dir: &str, service_name: &str) { }; // Open read-only to inspect the header - let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDONLY) }; + let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC) }; if fd < 0 { // Entries that cannot be opened are junk (vanished, unreadable, // dangling symlink) — remove them. Keep the entry only when fd @@ -834,7 +817,7 @@ fn validate_service_name(name: &str) -> Result<(), ShmError> { return Err(ShmError::BadParam(format!( "service name contains invalid character: {:?}", c as char - ))) + ))); } } } @@ -842,6 +825,9 @@ fn validate_service_name(name: &str) -> Result<(), ShmError> { } fn build_shm_path(run_dir: &str, service_name: &str, session_id: u64) -> Result { + if run_dir.is_empty() { + return Err(ShmError::BadParam("empty run_dir".into())); + } validate_service_name(service_name)?; let path = Path::new(run_dir).join(format!("{service_name}-{session_id:016x}.ipcshm")); if path.to_string_lossy().len() >= 256 { @@ -856,42 +842,54 @@ fn path_to_cstring(path: &Path) -> Result { } fn errno() -> i32 { - unsafe { *libc::__errno_location() } + std::io::Error::last_os_error().raw_os_error().unwrap_or(0) } /// Reserve all backing storage before exposing the region through mmap. /// -/// Linux may interrupt fallocate before it changes the file, so retry EINTR. -/// Every other error is returned to the caller; SHM has no unsafe sparse-file -/// fallback. +/// The allocation may be interrupted before it changes the file, so retry +/// EINTR. Every other error is returned to the caller; SHM has no unsafe +/// sparse-file fallback. fn allocate_region(fd: i32, region_size: usize) -> Result<(), i32> { loop { #[cfg(test)] record_fallocate_call(); #[cfg(test)] - let result = match take_fallocate_fault() { - Some(error) => { - unsafe { *libc::__errno_location() = error }; - -1 - } - None => unsafe { libc::fallocate(fd, 0, 0, region_size as libc::off_t) }, + let error = match take_fallocate_fault() { + Some(error) => error, + None => reserve_region(fd, region_size), }; #[cfg(not(test))] - let result = unsafe { libc::fallocate(fd, 0, 0, region_size as libc::off_t) }; + let error = reserve_region(fd, region_size); - if result == 0 { - return Ok(()); + match error { + 0 => return Ok(()), + libc::EINTR => continue, + e => return Err(e), } + } +} - let error = errno(); - if error != libc::EINTR { - return Err(error); - } +/// Reserve the whole file: 0 or the error number. +#[cfg(target_os = "linux")] +fn reserve_region(fd: i32, region_size: usize) -> i32 { + if unsafe { libc::fallocate(fd, 0, 0, region_size as libc::off_t) } == 0 { + 0 + } else { + errno() } } +/// Reserve the whole file: 0 or the error number. posix_fallocate returns +/// the error itself; ZFS rejects it with EINVAL, so SHM preparation fails +/// and the session uses baseline. +#[cfg(target_os = "freebsd")] +fn reserve_region(fd: i32, region_size: usize) -> i32 { + unsafe { libc::posix_fallocate(fd, 0, region_size as libc::off_t) } +} + unsafe fn map_region(fd: i32, region_size: usize) -> *mut libc::c_void { #[cfg(test)] record_mmap_call(); @@ -1016,6 +1014,7 @@ fn atomic_add_u32(base: *mut u8, offset: usize, val: u32) { unsafe { (*ptr).fetch_add(val, std::sync::atomic::Ordering::Release) }; } +#[cfg(target_os = "linux")] fn futex_wake(addr: *mut u32, count: i32) -> i32 { unsafe { libc::syscall( @@ -1030,6 +1029,7 @@ fn futex_wake(addr: *mut u32, count: i32) -> i32 { } } +#[cfg(target_os = "linux")] fn futex_wait(addr: *mut u32, expected: u32, timeout: Option<&libc::timespec>) -> i32 { // SYS_futex consumes kernel words, not libc's timespec. In particular, // 32-bit musl uses time64 even when SYS_futex is the legacy syscall. @@ -1064,6 +1064,47 @@ fn futex_wait(addr: *mut u32, expected: u32, timeout: Option<&libc::timespec>) - } } +// The shared (not _PRIVATE) umtx operations key on the mapped object, so they +// cross processes. A wait whose expected value is already stale returns 0, +// where a Linux futex returns EAGAIN; callers re-check either way. +#[cfg(target_os = "freebsd")] +fn futex_wake(addr: *mut u32, count: i32) -> i32 { + unsafe { + libc::_umtx_op( + addr.cast(), + libc::UMTX_OP_WAKE, + count as libc::c_ulong, + ptr::null_mut(), + ptr::null_mut(), + ) + } +} + +#[cfg(target_os = "freebsd")] +fn futex_wait(addr: *mut u32, expected: u32, timeout: Option<&libc::timespec>) -> i32 { + let mut t = timeout.map(|ts| libc::_umtx_time { + _timeout: *ts, + _flags: 0, + _clockid: libc::CLOCK_MONOTONIC as u32, + }); + let (size, tp) = match t.as_mut() { + Some(t) => ( + std::mem::size_of::() as *mut libc::c_void, + (t as *mut libc::_umtx_time).cast(), + ), + None => (ptr::null_mut(), ptr::null_mut()), + }; + unsafe { + libc::_umtx_op( + addr.cast(), + libc::UMTX_OP_WAIT_UINT, + expected as libc::c_ulong, + size, + tp, + ) + } +} + // --------------------------------------------------------------------------- // Stale region recovery // --------------------------------------------------------------------------- @@ -1088,8 +1129,10 @@ fn unlink_stale_path(c_path: &CString) -> bool { if unsafe { libc::unlink(c_path.as_ptr()) } == 0 || errno() == libc::ENOENT { return true; } - // A directory squatting on the region path is junk too. - errno() == libc::EISDIR && unsafe { libc::rmdir(c_path.as_ptr()) } == 0 + // A directory squatting on the region path is junk too. Unlinking one + // fails with EISDIR on Linux and EPERM on FreeBSD. + let e = errno(); + (e == libc::EISDIR || e == libc::EPERM) && unsafe { libc::rmdir(c_path.as_ptr()) } == 0 } #[allow(dead_code)] @@ -1111,7 +1154,7 @@ fn check_shm_stale(path: &Path) -> StaleResult { return StaleResult::Invalid; } - let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDONLY) }; + let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC) }; if fd < 0 { let e = errno(); if e == libc::ENOENT { diff --git a/src/crates/netipc/src/transport/shm_tests.rs b/src/crates/netipc/src/transport/shm_tests.rs index abc8be75..58523b3a 100644 --- a/src/crates/netipc/src/transport/shm_tests.rs +++ b/src/crates/netipc/src/transport/shm_tests.rs @@ -9,6 +9,9 @@ use std::time::Duration; const TEST_RUN_DIR: &str = "/tmp/nipc_shm_rust_test"; const SERVER_READY_TIMEOUT: Duration = Duration::from_secs(2); +// Above every OS PID limit, so never a live process; a small fixed PID such +// as 99999 can be in use on hosts with a large pid_max. +const DEAD_PID: i32 = i32::MAX; fn ensure_run_dir() { let _ = std::fs::create_dir_all(TEST_RUN_DIR); @@ -175,7 +178,7 @@ fn test_stale_recovery() { let mut first = ShmContext::server_create(TEST_RUN_DIR, svc, sid, 1024, 1024).expect("first create"); let hdr = first.base as *mut RegionHeader; - unsafe { (*hdr).owner_pid = 99999 }; // very unlikely alive + unsafe { (*hdr).owner_pid = DEAD_PID }; first.close(); // close without unlink // cleanup_stale should remove the stale file @@ -205,7 +208,7 @@ fn test_stale_recovery_in_group_writable_run_dir() { let mut first = ShmContext::server_create(&dir, svc, sid, 1024, 1024).expect("first create"); let hdr = first.base as *mut RegionHeader; - unsafe { (*hdr).owner_pid = 99999 }; // very unlikely alive + unsafe { (*hdr).owner_pid = DEAD_PID }; first.close(); // close without unlink // server_create must reclaim the dead-owner region through its EEXIST path @@ -623,19 +626,22 @@ fn test_server_create_does_not_fallback_when_fallocate_is_unsupported() { ensure_run_dir(); let svc = "rs_shm_alloc_unsupported"; let sid = 0xa110_c003; - cleanup_shm(svc, sid); - let _fault = FallocateFaultGuard::install(&[libc::EOPNOTSUPP]); - let err = match ShmContext::server_create(TEST_RUN_DIR, svc, sid, 1024, 1024) { - Ok(_) => panic!("unsupported fallocate must disable SHM creation"), - Err(err) => err, - }; + // EINVAL is how FreeBSD ZFS rejects posix_fallocate. + for errno in [libc::EOPNOTSUPP, libc::EINVAL] { + cleanup_shm(svc, sid); + let _fault = FallocateFaultGuard::install(&[errno]); + let err = match ShmContext::server_create(TEST_RUN_DIR, svc, sid, 1024, 1024) { + Ok(_) => panic!("unsupported allocation ({errno}) must disable SHM creation"), + Err(err) => err, + }; - assert_eq!(err, ShmError::Allocate(libc::EOPNOTSUPP)); - assert_eq!(syscall_test_counts(), (1, 0), "mmap must not be reached"); + assert_eq!(err, ShmError::Allocate(errno)); + assert_eq!(syscall_test_counts(), (1, 0), "mmap must not be reached"); - let path = build_shm_path(TEST_RUN_DIR, svc, sid).expect("SHM path"); - assert!(!path.exists(), "failed allocation must unlink its file"); + let path = build_shm_path(TEST_RUN_DIR, svc, sid).expect("SHM path"); + assert!(!path.exists(), "failed allocation must unlink its file"); + } } // ----------------------------------------------------------------------- @@ -666,119 +672,6 @@ fn test_shm_role_and_fd() { cleanup_shm(svc, sid); } -// ----------------------------------------------------------------------- -// ShmContext::owner_alive() (lines 174-191) -// ----------------------------------------------------------------------- - -#[test] -fn test_owner_alive() { - ensure_run_dir(); - let svc = "rs_shm_alive"; - let sid: u64 = 51; - cleanup_shm(svc, sid); - - let server = - ShmContext::server_create(TEST_RUN_DIR, svc, sid, 1024, 1024).expect("server create"); - - // Owner is the current process, so should be alive - assert!(server.owner_alive()); - - // Client should also report alive (same process owns it) - let client = ShmContext::client_attach(TEST_RUN_DIR, svc, sid).expect("client attach"); - assert!(client.owner_alive()); - - let mut c = client; - let mut s = server; - c.close(); - s.destroy(); - cleanup_shm(svc, sid); -} - -#[test] -fn test_owner_alive_dead_pid() { - ensure_run_dir(); - let svc = "rs_shm_dead"; - let sid: u64 = 52; - cleanup_shm(svc, sid); - - let mut server = - ShmContext::server_create(TEST_RUN_DIR, svc, sid, 1024, 1024).expect("server create"); - - // Forge a dead PID - let hdr = server.base as *mut RegionHeader; - unsafe { (*hdr).owner_pid = 99999 }; - - assert!(!server.owner_alive()); - - server.destroy(); - cleanup_shm(svc, sid); -} - -#[test] -fn test_owner_alive_null_base() { - ensure_run_dir(); - let svc = "rs_shm_null"; - let sid: u64 = 53; - cleanup_shm(svc, sid); - - let mut server = - ShmContext::server_create(TEST_RUN_DIR, svc, sid, 1024, 1024).expect("server create"); - - // Null base -> not alive - let saved_base = server.base; - server.base = std::ptr::null_mut(); - assert!(!server.owner_alive()); - - // Restore for cleanup - server.base = saved_base; - server.destroy(); - cleanup_shm(svc, sid); -} - -#[test] -fn test_owner_alive_generation_mismatch() { - ensure_run_dir(); - let svc = "rs_shm_gen"; - let sid: u64 = 54; - cleanup_shm(svc, sid); - - let mut server = - ShmContext::server_create(TEST_RUN_DIR, svc, sid, 1024, 1024).expect("server create"); - - // Forge a different generation in the header - let hdr = server.base as *mut RegionHeader; - unsafe { (*hdr).owner_generation = server.owner_generation + 1 }; - - // Generation mismatch -> not alive - assert!(!server.owner_alive()); - - server.destroy(); - cleanup_shm(svc, sid); -} - -#[test] -fn test_owner_alive_legacy_generation_skips_generation_check() { - ensure_run_dir(); - let svc = "rs_shm_gen_legacy"; - let sid: u64 = 55; - cleanup_shm(svc, sid); - - let mut server = - ShmContext::server_create(TEST_RUN_DIR, svc, sid, 1024, 1024).expect("server create"); - - let hdr = server.base as *mut RegionHeader; - unsafe { (*hdr).owner_generation = server.owner_generation.wrapping_add(123) }; - server.owner_generation = 0; - - assert!( - server.owner_alive(), - "cached generation 0 should skip owner-generation mismatch checks" - ); - - server.destroy(); - cleanup_shm(svc, sid); -} - // ----------------------------------------------------------------------- // ShmContext::send() error paths (lines 436-437, 458) // ----------------------------------------------------------------------- @@ -1234,14 +1127,42 @@ fn test_pid_alive() { assert!(!pid_alive(-1)); // Current process should be alive assert!(pid_alive(unsafe { libc::getpid() })); - // Very unlikely PID - assert!(!pid_alive(99999)); + assert!(!pid_alive(DEAD_PID)); } // ----------------------------------------------------------------------- // Client attach: nonexistent file (line 325) // ----------------------------------------------------------------------- +#[test] +fn test_client_attach_rejects_response_end_wrapping_u32() { + ensure_run_dir(); + let svc = "rs_shm_badwrap"; + let sid: u64 = 65; + cleanup_shm(svc, sid); + + let mut server = + ShmContext::server_create(TEST_RUN_DIR, svc, sid, 1024, 1024).expect("server create"); + + // The end wraps to 0 in u32; on 32-bit usize the old check accepted it. + let hdr = server.base as *mut RegionHeader; + let (saved_off, saved_cap) = unsafe { ((*hdr).response_offset, (*hdr).response_capacity) }; + unsafe { + (*hdr).response_offset = 0xFFFF_FFC0; + (*hdr).response_capacity = 0x40; + } + + let result = ShmContext::client_attach(TEST_RUN_DIR, svc, sid); + assert!(matches!(result, Err(ShmError::BadSize))); + + unsafe { + (*hdr).response_offset = saved_off; + (*hdr).response_capacity = saved_cap; + } + server.destroy(); + cleanup_shm(svc, sid); +} + #[test] fn test_client_attach_nonexistent() { ensure_run_dir(); @@ -1349,9 +1270,11 @@ fn test_cleanup_stale_invalid_entries() { !short_path.exists(), "short invalid entry should be removed" ); - assert!(!build_shm_path(TEST_RUN_DIR, svc, magic_sid) - .unwrap() - .exists()); + assert!( + !build_shm_path(TEST_RUN_DIR, svc, magic_sid) + .unwrap() + .exists() + ); // An unreadable entry at a matching name is junk: it cannot be a live // endpoint we can verify, so cleanup reclaims it (under root chmod 000 // has no effect and the file is opened, inspected, and removed instead). @@ -1553,3 +1476,89 @@ fn test_cleanup_stale_unlinks_directory_symlink_when_mmap_fails() { std::fs::remove_dir_all(&target).expect("remove target dir"); } + +// SHM fds must not leak into children a consumer forks and execs. +#[test] +fn test_shm_fds_are_cloexec() { + ensure_run_dir(); + let svc = "rs_shm_cloexec"; + let sid: u64 = 4242; + cleanup_shm(svc, sid); + + let cloexec = |fd: i32| unsafe { libc::fcntl(fd, libc::F_GETFD) } & libc::FD_CLOEXEC != 0; + let mut server = + ShmContext::server_create(TEST_RUN_DIR, svc, sid, 4096, 4096).expect("server create"); + let mut client = ShmContext::client_attach(TEST_RUN_DIR, svc, sid).expect("client attach"); + let server_ok = cloexec(server.fd()); + let client_ok = cloexec(client.fd()); + client.close(); + server.destroy(); + cleanup_shm(svc, sid); + + assert!(server_ok, "server SHM fd must be close-on-exec"); + assert!(client_ok, "client SHM fd must be close-on-exec"); +} + +#[test] +fn test_empty_run_dir_rejected() { + assert!(matches!( + ShmContext::server_create("", "rs_shm_empty_dir", 1, 1024, 1024), + Err(ShmError::BadParam(_)) + )); +} + +const UMASK_HELPER_ENV: &str = "NETIPC_SHM_UMASK_HELPER"; +const UMASK_HELPER_TEST: &str = "transport::shm::tests::shm_umask_helper_process"; + +// The umask is process-wide, so the restrictive-umask case runs in a child +// test process instead of the multithreaded test runner. +#[test] +fn test_shm_attach_under_restrictive_umask() { + ensure_run_dir(); + let status = std::process::Command::new(std::env::current_exe().expect("current_exe")) + .args([UMASK_HELPER_TEST, "--exact", "--test-threads=1"]) + .env(UMASK_HELPER_ENV, "1") + .stdout(std::process::Stdio::null()) + .status() + .expect("spawn umask helper"); + assert!(status.success(), "attach under umask 0477 failed: {status}"); +} + +/// Restores the umask on drop, also on panic, so files the process writes +/// at exit (coverage profiles) keep their normal mode. +struct UmaskGuard(libc::mode_t); + +impl Drop for UmaskGuard { + fn drop(&mut self) { + unsafe { libc::umask(self.0) }; + } +} + +/// Child-process entry point. Inert unless spawned by the test above. +#[test] +fn shm_umask_helper_process() { + use std::os::unix::fs::PermissionsExt; + + if std::env::var_os(UMASK_HELPER_ENV).is_none() { + return; + } + let _umask = UmaskGuard(unsafe { libc::umask(0o477) }); + let svc = "rs_shm_umask"; + let sid: u64 = 7101; + cleanup_shm(svc, sid); + + let mut server = + ShmContext::server_create(TEST_RUN_DIR, svc, sid, 1024, 1024).expect("server create"); + let path = format!("{TEST_RUN_DIR}/{svc}-{sid:016x}.ipcshm"); + let mode = std::fs::metadata(&path) + .expect("region stat") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600, "region file mode"); + let mut client = ShmContext::client_attach(TEST_RUN_DIR, svc, sid).expect("client attach"); + + client.close(); + server.destroy(); + cleanup_shm(svc, sid); +} diff --git a/src/crates/netipc/src/transport/win_security.rs b/src/crates/netipc/src/transport/win_security.rs new file mode 100644 index 00000000..6da3a516 --- /dev/null +++ b/src/crates/netipc/src/transport/win_security.rs @@ -0,0 +1,340 @@ +//! Access policy shared by the Windows transports. +//! +//! Every NetIPC pipe, SHM mapping and event grants access only to SYSTEM, +//! Administrators and the account of the process that creates it. Clients +//! accept a server only when the pipe owner is one of those same principals. + +use std::ffi::{CStr, CString, c_char, c_void}; +use std::ptr; + +#[allow(non_snake_case, dead_code)] +mod ffi { + use std::ffi::{c_char, c_void}; + + pub type HANDLE = isize; + pub type BOOL = i32; + + pub const TOKEN_QUERY: u32 = 0x0008; + pub const TOKEN_USER_CLASS: u32 = 1; + pub const SE_KERNEL_OBJECT: i32 = 6; + pub const OWNER_SECURITY_INFORMATION: u32 = 0x1; + pub const DACL_SECURITY_INFORMATION: u32 = 0x4; + pub const SDDL_REVISION_1: u32 = 1; + + #[repr(C)] + pub struct SECURITY_ATTRIBUTES { + pub nLength: u32, + pub lpSecurityDescriptor: *mut c_void, + pub bInheritHandle: BOOL, + } + + #[link(name = "advapi32")] + unsafe extern "system" { + pub fn OpenProcessToken(process: HANDLE, access: u32, token: *mut HANDLE) -> BOOL; + pub fn GetTokenInformation( + token: HANDLE, + class: u32, + info: *mut c_void, + len: u32, + ret_len: *mut u32, + ) -> BOOL; + pub fn ConvertSidToStringSidA(sid: *mut c_void, out: *mut *mut c_char) -> BOOL; + pub fn ConvertStringSecurityDescriptorToSecurityDescriptorA( + sddl: *const c_char, + revision: u32, + sd: *mut *mut c_void, + sd_len: *mut u32, + ) -> BOOL; + pub fn ConvertSecurityDescriptorToStringSecurityDescriptorA( + sd: *mut c_void, + revision: u32, + info: u32, + out: *mut *mut c_char, + out_len: *mut u32, + ) -> BOOL; + pub fn GetSecurityInfo( + handle: HANDLE, + object_type: i32, + info: u32, + owner: *mut *mut c_void, + group: *mut *mut c_void, + dacl: *mut *mut c_void, + sacl: *mut *mut c_void, + sd: *mut *mut c_void, + ) -> u32; + } + + unsafe extern "system" { + pub fn GetCurrentProcess() -> HANDLE; + pub fn CloseHandle(handle: HANDLE) -> BOOL; + pub fn LocalFree(mem: *mut c_void) -> *mut c_void; + } +} + +const SYSTEM_SID: &str = "S-1-5-18"; +const ADMINISTRATORS_SID: &str = "S-1-5-32-544"; + +/// A SID or descriptor as an owned string; the Win32 buffer is LocalFree'd. +unsafe fn take_local_string(s: *mut c_char) -> String { + let out = unsafe { CStr::from_ptr(s) }.to_string_lossy().into_owned(); + unsafe { ffi::LocalFree(s.cast()) }; + out +} + +/// The SID string of this process's account. +pub(crate) fn process_user_sid() -> Option { + let mut token: ffi::HANDLE = 0; + if unsafe { ffi::OpenProcessToken(ffi::GetCurrentProcess(), ffi::TOKEN_QUERY, &mut token) } == 0 + { + return None; + } + + let mut len = 0u32; + unsafe { ffi::GetTokenInformation(token, ffi::TOKEN_USER_CLASS, ptr::null_mut(), 0, &mut len) }; + // TOKEN_USER starts with the SID pointer; u64 keeps it aligned. + let mut buf = vec![0u64; (len as usize).div_ceil(8).max(1)]; + let ok = unsafe { + ffi::GetTokenInformation( + token, + ffi::TOKEN_USER_CLASS, + buf.as_mut_ptr().cast(), + (buf.len() * 8) as u32, + &mut len, + ) + } != 0; + unsafe { ffi::CloseHandle(token) }; + if !ok { + return None; + } + + let sid = unsafe { *(buf.as_ptr() as *const *mut c_void) }; + let mut s: *mut c_char = ptr::null_mut(); + if unsafe { ffi::ConvertSidToStringSidA(sid, &mut s) } == 0 { + return None; + } + Some(unsafe { take_local_string(s) }) +} + +/// Security attributes carrying the owner-only DACL. The pointer from +/// `as_ptr` is valid while this value lives. +pub(crate) struct OwnerOnlyAttributes { + sa: ffi::SECURITY_ATTRIBUTES, +} + +impl OwnerOnlyAttributes { + /// Builds the descriptor, or returns the Win32 error. + pub(crate) fn new() -> Result { + let sid = process_user_sid().ok_or_else(last_error)?; + // Protected DACL: nothing is inherited, and no ACE for Everyone or + // Anonymous, so other accounts cannot even open an instance read-only. + let sddl = CString::new(format!("D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;{sid})")) + .map_err(|_| 87u32)?; + let mut sd: *mut c_void = ptr::null_mut(); + if unsafe { + ffi::ConvertStringSecurityDescriptorToSecurityDescriptorA( + sddl.as_ptr(), + ffi::SDDL_REVISION_1, + &mut sd, + ptr::null_mut(), + ) + } == 0 + { + return Err(last_error()); + } + Ok(Self { + sa: ffi::SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: sd, + bInheritHandle: 0, + }, + }) + } + + pub(crate) fn as_ptr(&self) -> *const c_void { + (&self.sa as *const ffi::SECURITY_ATTRIBUTES).cast() + } +} + +impl Drop for OwnerOnlyAttributes { + fn drop(&mut self) { + unsafe { ffi::LocalFree(self.sa.lpSecurityDescriptor) }; + } +} + +fn last_error() -> u32 { + std::io::Error::last_os_error().raw_os_error().unwrap_or(0) as u32 +} + +/// The object's owner as a SID string. +pub(crate) fn object_owner_sid(handle: isize) -> Option { + let mut owner: *mut c_void = ptr::null_mut(); + let mut sd: *mut c_void = ptr::null_mut(); + if unsafe { + ffi::GetSecurityInfo( + handle, + ffi::SE_KERNEL_OBJECT, + ffi::OWNER_SECURITY_INFORMATION, + &mut owner, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + &mut sd, + ) + } != 0 + { + return None; + } + let mut s: *mut c_char = ptr::null_mut(); + let ok = !owner.is_null() && unsafe { ffi::ConvertSidToStringSidA(owner, &mut s) } != 0; + unsafe { ffi::LocalFree(sd) }; + ok.then(|| unsafe { take_local_string(s) }) +} + +#[cfg(test)] +thread_local! { + /// Clients on this thread trust only pipes owned by this SID instead of + /// their own account, SYSTEM and Administrators. + pub(crate) static TRUSTED_OWNER_FOR_TESTS: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +/// True when the object's owner is this process's account, SYSTEM or +/// Administrators. +pub(crate) fn owner_trusted(handle: isize) -> bool { + let Some(owner) = object_owner_sid(handle) else { + return false; + }; + #[cfg(test)] + if let Some(trusted) = TRUSTED_OWNER_FOR_TESTS.with(|t| t.borrow().clone()) { + return owner == trusted; + } + owner == SYSTEM_SID + || owner == ADMINISTRATORS_SID + || process_user_sid().is_some_and(|me| owner == me) +} + +/// The object's DACL in SDDL form, for tests. +#[cfg(test)] +pub(crate) fn object_dacl_sddl(handle: isize) -> Option { + let mut sd: *mut c_void = ptr::null_mut(); + if unsafe { + ffi::GetSecurityInfo( + handle, + ffi::SE_KERNEL_OBJECT, + ffi::DACL_SECURITY_INFORMATION, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + &mut sd, + ) + } != 0 + { + return None; + } + let out = descriptor_dacl_sddl(sd); + unsafe { ffi::LocalFree(sd) }; + out +} + +/// The owner-only DACL for `mask` as Windows prints it, for tests. The round +/// trip applies Windows' own SID aliases, such as LA for the built-in +/// Administrator account that CI runners use. +#[cfg(test)] +pub(crate) fn owner_only_dacl_sddl(mask: &str) -> Option { + let me = process_user_sid()?; + let sddl = CString::new(format!( + "D:P(A;;{mask};;;SY)(A;;{mask};;;BA)(A;;{mask};;;{me})" + )) + .ok()?; + let mut sd: *mut c_void = ptr::null_mut(); + if unsafe { + ffi::ConvertStringSecurityDescriptorToSecurityDescriptorA( + sddl.as_ptr(), + ffi::SDDL_REVISION_1, + &mut sd, + ptr::null_mut(), + ) + } == 0 + { + return None; + } + let out = descriptor_dacl_sddl(sd); + unsafe { ffi::LocalFree(sd) }; + out +} + +#[cfg(test)] +fn descriptor_dacl_sddl(sd: *mut c_void) -> Option { + let mut s: *mut c_char = ptr::null_mut(); + let ok = unsafe { + ffi::ConvertSecurityDescriptorToStringSecurityDescriptorA( + sd, + ffi::SDDL_REVISION_1, + ffi::DACL_SECURITY_INFORMATION, + &mut s, + ptr::null_mut(), + ) + } != 0; + ok.then(|| unsafe { take_local_string(s) }) +} + +/// SecurityIdentification in SECURITY_IMPERSONATION_LEVEL. +#[cfg(test)] +pub(crate) const SECURITY_IDENTIFICATION_LEVEL: u32 = 1; + +/// The impersonation level a pipe client granted this server, for tests. +#[cfg(test)] +pub(crate) fn client_impersonation_level(pipe: isize) -> Option { + use test_ffi::*; + const TOKEN_IMPERSONATION_LEVEL_CLASS: u32 = 9; + + if unsafe { ImpersonateNamedPipeClient(pipe) } == 0 { + return None; + } + let mut token = 0isize; + let mut level = None; + if unsafe { OpenThreadToken(GetCurrentThread(), ffi::TOKEN_QUERY, 1, &mut token) } != 0 { + let mut value = 0u32; + let mut len = 0u32; + if unsafe { + GetTokenInformation( + token, + TOKEN_IMPERSONATION_LEVEL_CLASS, + (&mut value as *mut u32).cast(), + 4, + &mut len, + ) + } != 0 + { + level = Some(value); + } + unsafe { CloseHandle(token) }; + } + unsafe { RevertToSelf() }; + level +} + +#[cfg(test)] +mod test_ffi { + use std::ffi::c_void; + + #[link(name = "advapi32")] + unsafe extern "system" { + pub fn ImpersonateNamedPipeClient(pipe: isize) -> i32; + pub fn RevertToSelf() -> i32; + pub fn OpenThreadToken(thread: isize, access: u32, as_self: i32, token: *mut isize) -> i32; + pub fn GetTokenInformation( + token: isize, + class: u32, + info: *mut c_void, + len: u32, + ret_len: *mut u32, + ) -> i32; + } + + unsafe extern "system" { + pub fn GetCurrentThread() -> isize; + pub fn CloseHandle(handle: isize) -> i32; + } +} diff --git a/src/crates/netipc/src/transport/win_shm.rs b/src/crates/netipc/src/transport/win_shm.rs index 1180cfd8..a1a91728 100644 --- a/src/crates/netipc/src/transport/win_shm.rs +++ b/src/crates/netipc/src/transport/win_shm.rs @@ -6,6 +6,9 @@ //! //! Wire-compatible with the C and Go implementations. +#[cfg(windows)] +use crate::transport::win_security::OwnerOnlyAttributes; + #[cfg(windows)] mod ffi { #![allow(non_snake_case, non_camel_case_types, dead_code)] @@ -399,12 +402,16 @@ impl WinShmContext { .ok_or_else(|| WinShmError::BadParam("region size overflow".into()))? as usize; + // Session objects take the same owner-only DACL as the pipe, so + // other accounts cannot open them. + let attrs = OwnerOnlyAttributes::new().map_err(WinShmError::CreateMapping)?; + // Create page-file backed mapping unsafe { ffi::SetLastError(0) }; let mapping = unsafe { create_file_mapping( ffi::INVALID_HANDLE_VALUE, - std::ptr::null(), + attrs.as_ptr(), ffi::PAGE_READWRITE, (region_size >> 32) as u32, (region_size & 0xFFFFFFFF) as u32, @@ -450,7 +457,7 @@ impl WinShmContext { let (req_event, resp_event) = if profile == PROFILE_HYBRID { let re_name = build_object_name(hash, service_name, profile, session_id, "req_event")?; unsafe { ffi::SetLastError(0) }; - let re = unsafe { create_event(std::ptr::null(), 0, 0, re_name.as_ptr()) }; + let re = unsafe { create_event(attrs.as_ptr(), 0, 0, re_name.as_ptr()) }; if re == 0 { let e = last_error(); unsafe { @@ -471,7 +478,7 @@ impl WinShmContext { let rsp_name = build_object_name(hash, service_name, profile, session_id, "resp_event")?; unsafe { ffi::SetLastError(0) }; - let rsp = unsafe { create_event(std::ptr::null(), 0, 0, rsp_name.as_ptr()) }; + let rsp = unsafe { create_event(attrs.as_ptr(), 0, 0, rsp_name.as_ptr()) }; if rsp == 0 { let e = last_error(); unsafe { @@ -1125,7 +1132,7 @@ fn validate_service_name(name: &str) -> Result<(), WinShmError> { return Err(WinShmError::BadParam(format!( "service name contains invalid character: {:?}", c as char, - ))) + ))); } } } @@ -1438,6 +1445,33 @@ mod tests { server.destroy(); } + #[cfg(windows)] + #[test] + fn test_server_objects_dacl_owner_only_windows() { + use crate::transport::win_security::{object_dacl_sddl, owner_only_dacl_sddl}; + let run_dir = test_run_dir(); + let service = unique_service("rs_win_shm_dacl"); + let mut server = WinShmContext::server_create( + &run_dir, + &service, + 0x4243, + 23, + PROFILE_HYBRID, + 4096, + 4096, + ) + .expect("server_create"); + let dacl = |mask: &str| owner_only_dacl_sddl(mask).expect("expected DACL"); + // SECTION_ALL_ACCESS and EVENT_ALL_ACCESS. + assert_eq!( + object_dacl_sddl(server.mapping), + Some(dacl("CCDCLCSWRPSDRCWDWO")) + ); + assert_eq!(object_dacl_sddl(server.req_event), Some(dacl("0x1f0003"))); + assert_eq!(object_dacl_sddl(server.resp_event), Some(dacl("0x1f0003"))); + server.destroy(); + } + #[cfg(windows)] #[test] fn test_server_create_rejects_existing_objects_windows() { diff --git a/src/crates/netipc/src/transport/windows.rs b/src/crates/netipc/src/transport/windows.rs index 0894a7e2..4e02546c 100644 --- a/src/crates/netipc/src/transport/windows.rs +++ b/src/crates/netipc/src/transport/windows.rs @@ -4,10 +4,12 @@ //! and send/receive with transparent chunking over Win32 Named Pipes //! in message mode. Wire-compatible with the C and Go implementations. +use super::framing::{self, Framing, FramingError, ServerOffer, apply_default}; use crate::protocol::{ - self, align8, ChunkHeader, Header, Hello, HelloAck, FLAG_BATCH, HEADER_SIZE, KIND_REQUEST, - KIND_RESPONSE, MAGIC_CHUNK, MAGIC_MSG, MAX_PAYLOAD_DEFAULT, PROFILE_BASELINE, VERSION, + HEADER_SIZE, Header, Hello, HelloAck, MAX_PAYLOAD_DEFAULT, PROFILE_BASELINE, }; +#[cfg(windows)] +use crate::transport::win_security::{OwnerOnlyAttributes, owner_trusted}; use std::collections::HashSet; use std::ptr; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -35,6 +37,9 @@ mod ffi { pub const PIPE_TYPE_MESSAGE: DWORD = 0x00000004; pub const PIPE_READMODE_MESSAGE: DWORD = 0x00000002; pub const PIPE_WAIT: DWORD = 0x00000000; + pub const PIPE_REJECT_REMOTE_CLIENTS: DWORD = 0x00000008; + pub const SECURITY_SQOS_PRESENT: DWORD = 0x00100000; + pub const SECURITY_IDENTIFICATION: DWORD = 0x00010000; pub const PIPE_UNLIMITED_INSTANCES: DWORD = 255; pub const GENERIC_READ: DWORD = 0x80000000; pub const GENERIC_WRITE: DWORD = 0x40000000; @@ -46,6 +51,23 @@ mod ffi { pub const ERROR_PIPE_NOT_CONNECTED: DWORD = 233; pub const ERROR_ACCESS_DENIED: DWORD = 5; pub const ERROR_PIPE_BUSY: DWORD = 231; + pub const ERROR_SEM_TIMEOUT: DWORD = 121; + pub const FILE_FLAG_OVERLAPPED: DWORD = 0x40000000; + pub const ERROR_IO_PENDING: DWORD = 997; + pub const ERROR_IO_INCOMPLETE: DWORD = 996; + pub const WAIT_OBJECT_0: DWORD = 0; + pub const WAIT_TIMEOUT: DWORD = 258; + pub const WAIT_FAILED: DWORD = 0xFFFF_FFFF; + + /// OVERLAPPED; the offset union is unused for pipes. + #[repr(C)] + pub struct OVERLAPPED { + pub internal: usize, + pub internal_high: usize, + pub offset: u32, + pub offset_high: u32, + pub h_event: HANDLE, + } unsafe extern "system" { pub fn CreateNamedPipeW( @@ -61,9 +83,6 @@ mod ffi { pub fn ConnectNamedPipe(hNamedPipe: HANDLE, lpOverlapped: *mut core::ffi::c_void) -> BOOL; - pub fn DisconnectNamedPipe(hNamedPipe: HANDLE) -> BOOL; - pub fn FlushFileBuffers(hFile: HANDLE) -> BOOL; - pub fn CreateFileW( lpFileName: LPCWSTR, dwDesiredAccess: DWORD, @@ -105,12 +124,34 @@ mod ffi { pub fn SwitchToThread() -> BOOL; + pub fn WaitNamedPipeW(lpNamedPipeName: LPCWSTR, nTimeOut: DWORD) -> BOOL; + + pub fn DisconnectNamedPipe(hNamedPipe: HANDLE) -> BOOL; + pub fn SetNamedPipeHandleState( hNamedPipe: HANDLE, lpMode: *const DWORD, lpMaxCollectionCount: *const DWORD, lpCollectDataTimeout: *const DWORD, ) -> BOOL; + + pub fn CreateEventW( + lpEventAttributes: *const core::ffi::c_void, + bManualReset: BOOL, + bInitialState: BOOL, + lpName: LPCWSTR, + ) -> HANDLE; + + pub fn GetOverlappedResult( + hFile: HANDLE, + lpOverlapped: *mut OVERLAPPED, + lpNumberOfBytesTransferred: LPDWORD, + bWait: BOOL, + ) -> BOOL; + + pub fn CancelIoEx(hFile: HANDLE, lpOverlapped: *mut OVERLAPPED) -> BOOL; + + pub fn WaitForSingleObject(hHandle: HANDLE, dwMilliseconds: DWORD) -> DWORD; } } @@ -121,10 +162,12 @@ mod ffi { const DEFAULT_BATCH_ITEMS: u32 = 1; const DEFAULT_PACKET_SIZE: u32 = 65536; const DEFAULT_PIPE_BUF_SIZE: u32 = 65536; -const HELLO_PAYLOAD_SIZE: usize = 44; -const HELLO_ACK_PAYLOAD_SIZE: usize = 48; const MAX_PIPE_NAME_CHARS: usize = 256; +/// How long a connect waits for a free pipe instance when every instance is +/// busy (ERROR_PIPE_BUSY). +pub const CONNECT_BUSY_WAIT_MS: u32 = 1000; + // FNV-1a 64-bit constants const FNV1A_OFFSET_BASIS: u64 = 0xcbf29ce484222325; const FNV1A_PRIME: u64 = 0x00000100000001B3; @@ -178,6 +221,8 @@ pub enum NpError { UnknownMsgId(u64), /// Peer disconnected (graceful). Disconnected, + /// The pipe owner is not this account, SYSTEM or Administrators. + UntrustedServer, } impl std::fmt::Display for NpError { @@ -204,34 +249,48 @@ impl std::fmt::Display for NpError { NpError::DuplicateMsgId(id) => write!(f, "duplicate message_id: {id}"), NpError::UnknownMsgId(id) => write!(f, "unknown response message_id: {id}"), NpError::Disconnected => write!(f, "peer disconnected"), + NpError::UntrustedServer => write!(f, "pipe owned by an untrusted account"), } } } -fn header_version_incompatible(buf: &[u8], expected_code: u16) -> bool { - if buf.len() < HEADER_SIZE { - return false; +impl FramingError for NpError { + fn protocol(msg: String) -> Self { + NpError::Protocol(msg) + } + fn chunk(msg: String) -> Self { + NpError::Chunk(msg) + } + fn incompatible(msg: String) -> Self { + NpError::Incompatible(msg) + } + fn handshake(msg: String) -> Self { + NpError::Handshake(msg) + } + fn bad_param(msg: String) -> Self { + NpError::BadParam(msg) + } + fn limit_exceeded() -> Self { + NpError::LimitExceeded + } + fn auth_failed() -> Self { + NpError::AuthFailed + } + fn no_profile() -> Self { + NpError::NoProfile + } + fn duplicate_msg_id(id: u64) -> Self { + NpError::DuplicateMsgId(id) + } + fn unknown_msg_id(id: u64) -> Self { + NpError::UnknownMsgId(id) + } + fn fails_session(&self) -> bool { + matches!(self, NpError::Send(_) | NpError::Disconnected) + } + fn is_recv_failure(&self) -> bool { + matches!(self, NpError::Recv(_) | NpError::Disconnected) } - - let magic = u32::from_ne_bytes(buf[0..4].try_into().unwrap()); - let version = u16::from_ne_bytes(buf[4..6].try_into().unwrap()); - let header_len = u16::from_ne_bytes(buf[6..8].try_into().unwrap()); - let kind = u16::from_ne_bytes(buf[8..10].try_into().unwrap()); - let code = u16::from_ne_bytes(buf[12..14].try_into().unwrap()); - - magic == MAGIC_MSG - && version != VERSION - && header_len == protocol::HEADER_LEN - && kind == protocol::KIND_CONTROL - && code == expected_code -} - -fn hello_layout_incompatible(buf: &[u8]) -> bool { - buf.len() >= 2 && u16::from_ne_bytes(buf[0..2].try_into().unwrap()) != 1 -} - -fn hello_ack_layout_incompatible(buf: &[u8]) -> bool { - buf.len() >= 2 && u16::from_ne_bytes(buf[0..2].try_into().unwrap()) != 1 } impl std::error::Error for NpError {} @@ -379,42 +438,12 @@ pub fn build_pipe_name(run_dir: &str, service_name: &str) -> Result, Np // Internal helpers // --------------------------------------------------------------------------- -fn apply_default(val: u32, def: u32) -> u32 { - if val == 0 { - def - } else { - val - } -} - -fn min_u32(a: u32, b: u32) -> u32 { - a.min(b) -} - -fn max_u32(a: u32, b: u32) -> u32 { - a.max(b) -} - #[cfg(windows)] fn pipe_buffer_size(packet_size: u32) -> u32 { // The protocol packet size controls logical framing and chunk size. The // underlying pipe quota must stay large enough for full-duplex pipelining // even when tests force a tiny protocol packet size. - max_u32( - apply_default(packet_size, DEFAULT_PIPE_BUF_SIZE), - DEFAULT_PIPE_BUF_SIZE, - ) -} - -fn highest_bit(mask: u32) -> u32 { - if mask == 0 { - return 0; - } - let mut bit: u32 = 1 << 31; - while bit & mask == 0 { - bit >>= 1; - } - bit + apply_default(packet_size, DEFAULT_PIPE_BUF_SIZE).max(DEFAULT_PIPE_BUF_SIZE) } // --------------------------------------------------------------------------- @@ -458,23 +487,6 @@ fn raw_write(handle: ffi::HANDLE, data: &[u8]) -> Result<(), NpError> { Ok(()) } -/// Send header + payload as one pipe message. -#[cfg(windows)] -fn raw_send_msg( - handle: ffi::HANDLE, - scratch: &mut Vec, - hdr: &[u8], - payload: &[u8], -) -> Result<(), NpError> { - let total = hdr.len() + payload.len(); - if scratch.len() < total { - scratch.resize(total, 0); - } - scratch[..hdr.len()].copy_from_slice(hdr); - scratch[hdr.len()..total].copy_from_slice(payload); - raw_write(handle, &scratch[..total]) -} - /// Read one pipe message. Returns number of bytes read. #[cfg(windows)] fn raw_recv(handle: ffi::HANDLE, buf: &mut [u8]) -> Result { @@ -501,6 +513,473 @@ fn raw_recv(handle: ffi::HANDLE, buf: &mut [u8]) -> Result { Ok(read as usize) } +// Client pipe handles are overlapped (FILE_FLAG_OVERLAPPED), so a +// pipelining send can read responses while its write waits for buffer +// space. Every read and write on such a handle passes an OVERLAPPED and is +// waited to completion before the call returns, so the OVERLAPPED lives on +// that call's stack. Server handles stay synchronous. + +/// The events of a client handle's reads and writes. +#[cfg(windows)] +struct PipeIo { + read_event: ffi::HANDLE, + write_event: ffi::HANDLE, +} + +#[cfg(windows)] +impl PipeIo { + fn new() -> Result { + let read_event = create_manual_event()?; + let write_event = match create_manual_event() { + Ok(event) => event, + Err(e) => { + close_handle(read_event); + return Err(e); + } + }; + Ok(Self { + read_event, + write_event, + }) + } +} + +#[cfg(windows)] +impl Drop for PipeIo { + fn drop(&mut self) { + close_handle(self.read_event); + close_handle(self.write_event); + } +} + +#[cfg(windows)] +fn create_manual_event() -> Result { + let event = unsafe { ffi::CreateEventW(ptr::null(), 1, 0, ptr::null()) }; + if event == 0 { + return Err(NpError::Connect(last_error())); + } + Ok(event) +} + +#[cfg(windows)] +fn new_overlapped(event: ffi::HANDLE) -> ffi::OVERLAPPED { + ffi::OVERLAPPED { + internal: 0, + internal_high: 0, + offset: 0, + offset_high: 0, + h_event: event, + } +} + +#[cfg(windows)] +fn send_error(err: u32) -> NpError { + if is_disconnect_error(err) { + NpError::Disconnected + } else { + NpError::Send(err) + } +} + +#[cfg(windows)] +fn recv_error(err: u32) -> NpError { + if is_disconnect_error(err) { + NpError::Disconnected + } else { + NpError::Recv(err) + } +} + +/// Read one message on an overlapped handle and wait for it. A message +/// larger than `buf` (ERROR_MORE_DATA) fails, as on a synchronous handle. +#[cfg(windows)] +fn overlapped_read( + handle: ffi::HANDLE, + event: ffi::HANDLE, + buf: &mut [u8], +) -> Result { + let mut ov = new_overlapped(event); + let mut read: u32 = 0; + // Overlapped I/O takes no byte-count pointer (Win32 docs); the count + // comes from GetOverlappedResult. + let ok = unsafe { + ffi::ReadFile( + handle, + buf.as_mut_ptr() as ffi::LPVOID, + buf.len() as u32, + core::ptr::null_mut(), + &mut ov as *mut ffi::OVERLAPPED as *mut core::ffi::c_void, + ) + }; + if ok == 0 { + let err = last_error(); + if err != ffi::ERROR_IO_PENDING { + return Err(recv_error(err)); + } + } + if unsafe { ffi::GetOverlappedResult(handle, &mut ov, &mut read, 1) } == 0 { + return Err(recv_error(last_error())); + } + if read == 0 { + return Err(NpError::Disconnected); + } + Ok(read as usize) +} + +/// Post one message on an overlapped handle; `finish_overlapped_write` +/// collects its completion, also when WriteFile completed at once. +#[cfg(windows)] +fn start_overlapped_write( + handle: ffi::HANDLE, + ov: &mut ffi::OVERLAPPED, + data: &[u8], +) -> Result<(), NpError> { + // No byte-count pointer: the write may still be pending when this + // returns; finish_overlapped_write gets the count. + let ok = unsafe { + ffi::WriteFile( + handle, + data.as_ptr() as ffi::LPCVOID, + data.len() as u32, + core::ptr::null_mut(), + ov as *mut ffi::OVERLAPPED as *mut core::ffi::c_void, + ) + }; + if ok == 0 { + let err = last_error(); + if err != ffi::ERROR_IO_PENDING { + return Err(send_error(err)); + } + } + Ok(()) +} + +#[cfg(windows)] +fn finish_overlapped_write( + handle: ffi::HANDLE, + ov: &mut ffi::OVERLAPPED, + data: &[u8], +) -> Result<(), NpError> { + let mut written: u32 = 0; + if unsafe { ffi::GetOverlappedResult(handle, ov, &mut written, 1) } == 0 { + return Err(send_error(last_error())); + } + if written != data.len() as u32 { + return Err(NpError::Send(0)); + } + Ok(()) +} + +/// Cancel a posted write and wait until the kernel no longer uses its +/// OVERLAPPED and buffer. +#[cfg(windows)] +fn cancel_overlapped_write(handle: ffi::HANDLE, ov: &mut ffi::OVERLAPPED) { + let mut written: u32 = 0; + unsafe { + ffi::CancelIoEx(handle, ov); + ffi::GetOverlappedResult(handle, ov, &mut written, 1); + } +} + +/// Length prefix of each queued message. +#[cfg(windows)] +const PENDING_RECORD_LEN: usize = 4; + +/// Messages a pipelining client send read while its write waited; receive +/// returns them before reading the pipe. Records live in `buf[head..end]`; +/// `buf` only grows, so a read lands in storage zeroed once, not per message. +#[cfg(windows)] +#[derive(Default)] +struct PendingQueue { + buf: Vec, + head: usize, + end: usize, +} + +#[cfg(windows)] +impl PendingQueue { + fn queued(&self) -> bool { + self.head < self.end + } + + fn clear(&mut self) { + self.head = 0; + self.end = 0; + } + + fn compact(&mut self) { + if self.head > 0 { + self.buf.copy_within(self.head..self.end, 0); + self.end -= self.head; + self.head = 0; + } + } + + #[cfg(test)] + fn push(&mut self, msg: &[u8]) { + self.buf.truncate(self.end); + self.buf + .extend_from_slice(&(msg.len() as u32).to_ne_bytes()); + self.buf.extend_from_slice(msg); + self.end = self.buf.len(); + } + + /// Copy the oldest queued message into `dst`. A message larger than + /// `dst` fails, as a pipe read would with ERROR_MORE_DATA. + fn pop(&mut self, dst: &mut [u8]) -> Result { + let mut len_bytes = [0u8; PENDING_RECORD_LEN]; + len_bytes.copy_from_slice(&self.buf[self.head..self.head + PENDING_RECORD_LEN]); + let rec = u32::from_ne_bytes(len_bytes) as usize; + let start = self.head + PENDING_RECORD_LEN; + let result = if rec > dst.len() { + Err(NpError::Recv(ERROR_MORE_DATA)) + } else { + dst[..rec].copy_from_slice(&self.buf[start..start + rec]); + Ok(rec) + }; + self.head = start + rec; + if self.head == self.end { + self.clear(); + } + result + } + + /// Read every message available now. Reports whether it read any. A + /// disconnected pipe sets `eof`: the posted write reports it. Queued bytes beyond `bound` fail with `NpError::Protocol`. + fn drain( + &mut self, + handle: ffi::HANDLE, + event: ffi::HANDLE, + read_len: usize, + bound: usize, + eof: &mut bool, + ) -> Result { + let mut drained = false; + loop { + match peek_available(handle) { + Ok(0) => return Ok(drained), + Ok(_) => {} + Err(NpError::Disconnected) => { + *eof = true; + return Ok(drained); + } + // Stopping the drain on a live pipe would bring the deadlock back. + Err(e) => return Err(NpError::Protocol(format!("pending peek: {e}"))), + } + self.compact(); + let start = self.end; + let need = start + PENDING_RECORD_LEN + read_len; + if self.buf.len() < need { + self.buf.resize(need, 0); + } + match overlapped_read( + handle, + event, + &mut self.buf[start + PENDING_RECORD_LEN..need], + ) { + Ok(n) => { + self.buf[start..start + PENDING_RECORD_LEN] + .copy_from_slice(&(n as u32).to_ne_bytes()); + self.end = start + PENDING_RECORD_LEN + n; + } + Err(NpError::Disconnected) => { + *eof = true; + return Ok(drained); + } + Err(e) => { + // A message larger than a packet, or a read failure. + return Err(NpError::Protocol(format!("pending read: {e}"))); + } + } + if self.end > bound { + return Err(NpError::Protocol( + "inbound bytes exceed the in-flight responses".into(), + )); + } + drained = true; + } + } +} + +/// How long a stalled pipelined send keeps yielding before it sleeps. +#[cfg(windows)] +const PROGRESS_YIELD: Duration = Duration::from_millis(1); + +/// ERROR_MORE_DATA: a message larger than the read buffer. +#[cfg(windows)] +const ERROR_MORE_DATA: u32 = 234; + +/// Wire bytes `inflight` responses can occupy: the pending queue bound. +#[cfg(windows)] +fn pending_bound(max_response_payload: u32, packet_size: u32, inflight: usize) -> usize { + let payload = max_response_payload as usize; + let budget = (packet_size as usize).saturating_sub(HEADER_SIZE).max(1); + let packets = if payload == 0 { + 1 + } else { + 1 + (payload - 1) / budget + }; + packets + .saturating_mul(HEADER_SIZE + PENDING_RECORD_LEN) + .saturating_add(payload) + .saturating_mul(inflight) +} + +#[cfg(windows)] +fn peek_available(handle: ffi::HANDLE) -> Result { + let mut available: u32 = 0; + let ok = unsafe { + ffi::PeekNamedPipe( + handle, + ptr::null_mut(), + 0, + ptr::null_mut(), + &mut available, + ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(recv_error(last_error())); + } + Ok(available) +} + +/// Write one packet. On a client handle with `bound` set (more than one +/// request in flight), it reads what the server sent into `pending` until +/// the write completes, so both sides keep making progress. No timeout: it +/// waits as long as a blocking write would. +#[cfg(windows)] +#[allow(clippy::too_many_arguments)] +fn write_packet( + handle: ffi::HANDLE, + io: Option<&PipeIo>, + pending: &mut PendingQueue, + bound: usize, + read_len: usize, + data: &[u8], + wait: Option<&ReceiveWait<'_>>, + broken: &mut bool, +) -> Result<(), NpError> { + let Some(io) = io else { + return raw_write(handle, data); + }; + let mut ov = new_overlapped(io.write_event); + start_overlapped_write(handle, &mut ov, data)?; + if bound == 0 { + return match wait { + Some(wait) => finish_overlapped_write_wait(handle, io, &mut ov, data, wait, broken), + None => finish_overlapped_write(handle, &mut ov, data), + }; + } + let mut eof = false; + let mut yield_until = Instant::now() + PROGRESS_YIELD; + loop { + match unsafe { ffi::WaitForSingleObject(io.write_event, 0) } { + ffi::WAIT_OBJECT_0 => return finish_overlapped_write(handle, &mut ov, data), + ffi::WAIT_TIMEOUT => {} + _ => { + let err = last_error(); + cancel_overlapped_write(handle, &mut ov); + *broken = true; + return Err(NpError::Send(err)); + } + } + if let Some(wait) = wait { + if let Err(e) = wait.wait_slice_ms() { + cancel_overlapped_write(handle, &mut ov); + *broken = true; + return Err(e); + } + } + if !eof { + match pending.drain(handle, io.read_event, read_len, bound, &mut eof) { + Ok(true) => { + yield_until = Instant::now() + PROGRESS_YIELD; + continue; + } + Ok(false) => {} + Err(e) => { + cancel_overlapped_write(handle, &mut ov); + *broken = true; + return Err(e); + } + } + } + // The server is busy or its reply is still being written: yield for + // up to PROGRESS_YIELD after the last progress, then wait for the + // write in short slices. A slice lasts a system timer tick and only + // the write completing ends it early, so slicing at once stalls a + // server blocked on its reply to us. + if Instant::now() < yield_until { + unsafe { + ffi::SwitchToThread(); + } + continue; + } + unsafe { + ffi::WaitForSingleObject(io.write_event, 1); + } + } +} + +/// Wait for a posted write until it completes, the deadline passes or the +/// abort flag is set. A write that ends early is cancelled and the session +/// broken, since part of the message may be on the wire. +#[cfg(windows)] +fn finish_overlapped_write_wait( + handle: ffi::HANDLE, + io: &PipeIo, + ov: &mut ffi::OVERLAPPED, + data: &[u8], + wait: &ReceiveWait<'_>, + broken: &mut bool, +) -> Result<(), NpError> { + loop { + let mut written: u32 = 0; + if unsafe { ffi::GetOverlappedResult(handle, ov, &mut written, 0) } != 0 { + if written != data.len() as u32 { + return Err(NpError::Send(0)); + } + return Ok(()); + } + let err = last_error(); + if err != ffi::ERROR_IO_INCOMPLETE { + return Err(send_error(err)); + } + let slice = match wait.wait_slice_ms() { + Ok(ms) => ms, + Err(e) => { + cancel_overlapped_write(handle, ov); + *broken = true; + return Err(e); + } + }; + if unsafe { ffi::WaitForSingleObject(io.write_event, slice) } == ffi::WAIT_FAILED { + let err = last_error(); + cancel_overlapped_write(handle, ov); + *broken = true; + return Err(NpError::Send(err)); + } + } +} + +/// Read one packet: a queued one first, then the pipe. +#[cfg(windows)] +fn read_packet( + handle: ffi::HANDLE, + io: Option<&PipeIo>, + pending: &mut PendingQueue, + buf: &mut [u8], +) -> Result { + if pending.queued() { + return pending.pop(buf); + } + match io { + Some(io) => overlapped_read(handle, io.read_event, buf), + None => raw_recv(handle, buf), + } +} + #[cfg(windows)] struct ReceiveWait<'a> { deadline: Option, @@ -533,7 +1012,7 @@ impl<'a> ReceiveWait<'a> { if now >= deadline { return Err(NpError::Timeout); } - Some(deadline.duration_since(now).as_millis()) + Some(deadline.duration_since(now).as_millis().max(1)) } else { None }; @@ -624,38 +1103,41 @@ fn wait_pipe_readable(handle: ffi::HANDLE, timeout_ms: u32) -> Result, + pending: &mut PendingQueue, buf: &mut [u8], wait: &mut ReceiveWait<'_>, ) -> Result { + if pending.queued() { + return pending.pop(buf); + } loop { let wait_ms = wait.wait_slice_ms()?; if !wait_pipe_readable(handle, wait_ms)? { - if wait.aborted() { - return Err(NpError::Aborted); - } - if wait.deadline.is_some() { - return Err(NpError::Timeout); - } + // An empty slice only means the abort flag is due for a check; + // wait_slice_ms() reports the real deadline. continue; } if wait.aborted() { return Err(NpError::Aborted); } - return raw_recv(handle, buf); + return read_packet(handle, io, pending, buf); } } #[cfg(windows)] fn raw_recv_control( handle: ffi::HANDLE, + io: Option<&PipeIo>, + pending: &mut PendingQueue, buf: &mut [u8], controlled: bool, wait: &mut ReceiveWait<'_>, ) -> Result { if controlled { - raw_recv_with_wait(handle, buf, wait) + raw_recv_with_wait(handle, io, pending, buf, wait) } else { - raw_recv(handle, buf) + read_packet(handle, io, pending, buf) } } @@ -694,21 +1176,50 @@ pub struct NpSession { // In-flight message_id set (client-side only) inflight_ids: HashSet, + + // Overlapped state of a client handle; None on server sessions, whose + // handles are synchronous. + io: Option, + // Messages read while a pipelined send waited for its write. + pending: PendingQueue, } #[cfg(windows)] impl NpSession { - fn fail_all_inflight(&mut self) { - if self.role == Role::Client { - self.inflight_ids.clear(); + /// The framing of the messages this side sends (`sending`) or receives. + /// Clients send requests and receive responses; servers the opposite. + fn framing(&self, sending: bool) -> Framing { + let client = self.role == Role::Client; + let (max_payload, max_items) = if client == sending { + (self.max_request_payload_bytes, self.max_request_batch_items) + } else { + ( + self.max_response_payload_bytes, + self.max_response_batch_items, + ) + }; + Framing { + client, + max_payload, + max_items, + packet_size: self.packet_size, } } - /// Get the raw HANDLE for WaitForSingleObject integration. + /// Get the raw pipe HANDLE. It is not a readability signal: a pipe + /// handle is signalled by I/O completion, not by arriving data. Use + /// `wait_readable`, and check `has_pending` first: a pipelined send can + /// read responses into the session, where the pipe no longer shows them. pub fn handle(&self) -> ffi::HANDLE { self.handle } + /// Whether the next receive can return a message read earlier by a + /// pipelined send, without the pipe becoming readable. + pub fn has_pending(&self) -> bool { + self.pending.queued() + } + /// Get the session role. pub fn role(&self) -> Role { self.role @@ -716,6 +1227,9 @@ impl NpSession { /// Wait until the pipe becomes readable or the timeout expires. pub fn wait_readable(&self, timeout_ms: u32) -> Result { + if self.pending.queued() && self.handle != ffi::INVALID_HANDLE_VALUE { + return Ok(true); + } wait_pipe_readable(self.handle, timeout_ms) } @@ -731,22 +1245,29 @@ impl NpSession { service_name: &str, config: &ClientConfig, ) -> Result { - let pipe_name = build_pipe_name(run_dir, service_name)?; + Self::connect_with_timeout(run_dir, service_name, config, 0, None) + } - let handle = unsafe { - ffi::CreateFileW( - pipe_name.as_ptr(), - ffi::GENERIC_READ | ffi::GENERIC_WRITE, - 0, - ptr::null(), - ffi::OPEN_EXISTING, - 0, - 0, - ) - }; + /// `connect()` bounded by `timeout_ms`: the busy-pipe wait and the wait + /// for HELLO_ACK together end with `NpError::Timeout` after `timeout_ms` + /// (0 = no deadline), and with `NpError::Aborted` once `abort` is set. + pub fn connect_with_timeout( + run_dir: &str, + service_name: &str, + config: &ClientConfig, + timeout_ms: u32, + abort: Option<&AtomicBool>, + ) -> Result { + let wait = ReceiveWait::new(timeout_ms, abort); + let pipe_name = build_pipe_name(run_dir, service_name)?; + let handle = open_client_pipe(&pipe_name, &wait)?; - if handle == ffi::INVALID_HANDLE_VALUE { - return Err(NpError::Connect(last_error())); + // Whoever created the pipe name first owns it. Send HELLO and the auth + // token only to a server running as this account, SYSTEM or + // Administrators; other accounts cannot take those as owner. + if !owner_trusted(handle) { + close_handle(handle); + return Err(NpError::UntrustedServer); } // Set read mode to message mode @@ -758,7 +1279,14 @@ impl NpSession { return Err(NpError::Connect(err)); } - match client_handshake(handle, config) { + let io = match PipeIo::new() { + Ok(io) => io, + Err(e) => { + close_handle(handle); + return Err(e); + } + }; + match client_handshake(handle, io, config, &wait) { Ok(session) => Ok(session), Err(e) => { close_handle(handle); @@ -770,126 +1298,81 @@ impl NpSession { /// Send one logical message. Fills magic/version/header_len/payload_len. /// Chunked transparently if message exceeds packet_size. pub fn send(&mut self, hdr: &mut Header, payload: &[u8]) -> Result<(), NpError> { + self.send_with_control(hdr, payload, 0, None) + } + + /// `send` with an optional deadline and abort flag for a client session. + /// `timeout_ms == 0` waits without a deadline. If the deadline passes or + /// `abort` is set before a write completes, the write is cancelled, the + /// session is closed (later calls fail with `BadParam`) and the call + /// returns `NpError::Timeout` or `NpError::Aborted`, since part of the + /// message may be on the wire. + pub fn send_with_control( + &mut self, + hdr: &mut Header, + payload: &[u8], + timeout_ms: u32, + abort: Option<&AtomicBool>, + ) -> Result<(), NpError> { if self.handle == ffi::INVALID_HANDLE_VALUE { return Err(NpError::BadParam("session closed".into())); } - // Validate payload against negotiated directional limits before transmitting. - let (max_payload, max_items) = if self.role == Role::Client { - (self.max_request_payload_bytes, self.max_request_batch_items) + // One request in flight (every typed call) writes without + // draining; only a pipelining client can deadlock with a server + // blocked writing to it. + let mut inflight = self.inflight_ids.len(); + if self.role == Role::Client && hdr.kind == crate::protocol::KIND_REQUEST { + inflight += 1; + } + let bound = if self.io.is_some() && inflight > 1 { + pending_bound(self.max_response_payload_bytes, self.packet_size, inflight) } else { - ( - self.max_response_payload_bytes, - self.max_response_batch_items, - ) + 0 }; - if payload.len() > max_payload as usize - || payload.len() > (u32::MAX as usize).saturating_sub(HEADER_SIZE) - { - return Err(NpError::LimitExceeded); - } - if hdr.item_count > max_items { - return Err(NpError::LimitExceeded); - } - - // Client-side: track in-flight message_ids for requests - if self.role == Role::Client && hdr.kind == KIND_REQUEST { - if !self.inflight_ids.insert(hdr.message_id) { - return Err(NpError::DuplicateMsgId(hdr.message_id)); - } - } - - // Fill envelope fields - hdr.magic = MAGIC_MSG; - hdr.version = VERSION; - hdr.header_len = protocol::HEADER_LEN; - hdr.payload_len = payload.len() as u32; - - let tracked = self.role == Role::Client && hdr.kind == KIND_REQUEST; - let msg_id = hdr.message_id; - let result = self.send_inner(hdr, payload); - - if let Err(err) = &result { - if tracked { - match err { - NpError::Send(_) | NpError::Disconnected => self.fail_all_inflight(), - _ => { - self.inflight_ids.remove(&msg_id); - } + let framing = self.framing(true); + let handle = self.handle; + let io = self.io.as_ref(); + let pending = &mut self.pending; + let read_len = self.packet_size as usize; + let send_buf = &mut self.send_buf; + let wait = + (timeout_ms != 0 || abort.is_some()).then(|| ReceiveWait::new(timeout_ms, abort)); + let mut broken = false; + let result = + framing::send_message(framing, &mut self.inflight_ids, hdr, payload, |h, p| { + let total = h.len() + p.len(); + if send_buf.len() < total { + send_buf.resize(total, 0); } - } + send_buf[..h.len()].copy_from_slice(h); + send_buf[h.len()..total].copy_from_slice(p); + write_packet( + handle, + io, + pending, + bound, + read_len, + &send_buf[..total], + wait.as_ref(), + &mut broken, + ) + }); + if broken { + self.break_session(); } - result } - fn send_inner(&mut self, hdr: &mut Header, payload: &[u8]) -> Result<(), NpError> { - let total_msg = HEADER_SIZE + payload.len(); - - // Single packet? - if total_msg <= self.packet_size as usize { - let mut hdr_buf = [0u8; HEADER_SIZE]; - hdr.encode(&mut hdr_buf); - return raw_send_msg(self.handle, &mut self.send_buf, &hdr_buf, payload); - } - - // Chunked send - let chunk_payload_budget = self.packet_size as usize - HEADER_SIZE; - if chunk_payload_budget == 0 { - return Err(NpError::BadParam("packet_size too small".into())); - } - - let first_chunk_payload = payload.len().min(chunk_payload_budget); - let remaining_after_first = payload.len() - first_chunk_payload; - - let continuation_chunks = if remaining_after_first > 0 { - 1 + ((remaining_after_first - 1) / chunk_payload_budget) - } else { - 0 - }; - let chunk_count = 1 + continuation_chunks as u32; - - // First chunk - let mut hdr_buf = [0u8; HEADER_SIZE]; - hdr.encode(&mut hdr_buf); - raw_send_msg( - self.handle, - &mut self.send_buf, - &hdr_buf, - &payload[..first_chunk_payload], - )?; - - // Continuation chunks - let mut offset = first_chunk_payload; - for ci in 1..chunk_count { - let remaining = payload.len() - offset; - let this_chunk = remaining.min(chunk_payload_budget); - - let chk = ChunkHeader { - magic: MAGIC_CHUNK, - version: VERSION, - flags: 0, - message_id: hdr.message_id, - total_message_len: total_msg as u32, - chunk_index: ci, - chunk_count, - chunk_payload_len: this_chunk as u32, - }; - - let mut chk_buf = [0u8; HEADER_SIZE]; - chk.encode(&mut chk_buf); - raw_send_msg( - self.handle, - &mut self.send_buf, - &chk_buf, - &payload[offset..offset + this_chunk], - )?; - - offset += this_chunk; - } - - Ok(()) + /// Close the pipe: a cancelled write leaves the message stream in an + /// unknown state, and a drain failure drops inbound messages. Later + /// calls fail with `BadParam` and `close` still releases the rest. + fn break_session(&mut self) { + close_handle(self.handle); + self.handle = ffi::INVALID_HANDLE_VALUE; + self.pending = PendingQueue::default(); + self.inflight_ids.clear(); } /// Receive one logical message. buf is a scratch buffer for the first read. @@ -912,185 +1395,38 @@ impl NpSession { let controlled = timeout_ms != 0 || abort.is_some(); let mut wait = ReceiveWait::new(timeout_ms, abort); - let n = match raw_recv_control(self.handle, buf, controlled, &mut wait) { - Ok(n) => n, - Err(err) => { - self.fail_all_inflight(); - return Err(err); - } - }; - - if n < HEADER_SIZE { - return Err(NpError::Protocol("packet too short for header".into())); - } - - let hdr = Header::decode(&buf[..n]) - .map_err(|e| NpError::Protocol(format!("header decode: {e}")))?; - - // Validate payload_len against negotiated limit - let max_payload = if self.role == Role::Server { - self.max_request_payload_bytes - } else { - self.max_response_payload_bytes - }; - if hdr.payload_len > max_payload { - return Err(NpError::LimitExceeded); - } - - // Validate item_count - let max_batch = if self.role == Role::Server { - self.max_request_batch_items - } else { - self.max_response_batch_items - }; - if hdr.item_count > max_batch { - return Err(NpError::LimitExceeded); - } - - // Client-side: validate response message_id - if self.role == Role::Client && hdr.kind == KIND_RESPONSE { - if !self.inflight_ids.remove(&hdr.message_id) { - return Err(NpError::UnknownMsgId(hdr.message_id)); - } - } - - let total_msg = HEADER_SIZE + hdr.payload_len as usize; - - if n > total_msg { - return Err(NpError::Protocol( - "packet exceeds declared payload_len".into(), - )); - } - - // Non-chunked - if n == total_msg { - let payload = &buf[HEADER_SIZE..HEADER_SIZE + hdr.payload_len as usize]; - - // Validate batch directory - if hdr.flags & FLAG_BATCH != 0 && hdr.item_count > 1 { - let dir_bytes = hdr.item_count as usize * 8; - let dir_aligned = align8(dir_bytes); - if payload.len() < dir_aligned { - return Err(NpError::Protocol("batch directory exceeds payload".into())); - } - let packed_area_len = (payload.len() - dir_aligned) as u32; - protocol::batch_dir_validate( - &payload[..dir_bytes], - hdr.item_count, - packed_area_len, - ) - .map_err(|e| NpError::Protocol(format!("batch directory: {e:?}")))?; - } - - return Ok((hdr, payload)); - } - - // Chunked - let first_payload_bytes = n - HEADER_SIZE; - let needed = hdr.payload_len as usize; - if self.recv_buf.len() < needed { - self.recv_buf.resize(needed, 0); - } - - self.recv_buf[..first_payload_bytes] - .copy_from_slice(&buf[HEADER_SIZE..HEADER_SIZE + first_payload_bytes]); - - let mut assembled = first_payload_bytes; - let chunk_payload_budget = self.packet_size as usize - HEADER_SIZE; - - let remaining_after_first = hdr.payload_len as usize - first_payload_bytes; - let expected_continuations = if remaining_after_first > 0 && chunk_payload_budget > 0 { - 1 + ((remaining_after_first - 1) / chunk_payload_budget) - } else { - 0 - }; - let expected_chunk_count = 1 + expected_continuations as u32; - - if self.pkt_buf.len() < self.packet_size as usize { - self.pkt_buf.resize(self.packet_size as usize, 0); - } - - let mut ci: u32 = 1; - while assembled < hdr.payload_len as usize { - let cn = match raw_recv_control(self.handle, &mut self.pkt_buf, controlled, &mut wait) { - Ok(n) => n, - Err(err) => { - self.fail_all_inflight(); - return Err(err); - } - }; - - if cn < HEADER_SIZE { - return Err(NpError::Chunk("continuation too short".into())); - } - - let chk = ChunkHeader::decode(&self.pkt_buf[..cn]) - .map_err(|e| NpError::Chunk(format!("chunk header: {e}")))?; - - if chk.message_id != hdr.message_id { - return Err(NpError::Chunk("message_id mismatch".into())); - } - if chk.chunk_index != ci { - return Err(NpError::Chunk(format!( - "chunk_index mismatch: expected {ci}, got {}", - chk.chunk_index - ))); - } - if chk.chunk_count != expected_chunk_count { - return Err(NpError::Chunk("chunk_count mismatch".into())); - } - if chk.total_message_len != total_msg as u32 { - return Err(NpError::Chunk("total_message_len mismatch".into())); - } - - let chunk_data = cn - HEADER_SIZE; - if chunk_data != chk.chunk_payload_len as usize { - return Err(NpError::Chunk("chunk_payload_len mismatch".into())); - } - if assembled + chunk_data > hdr.payload_len as usize { - return Err(NpError::Chunk("chunk exceeds payload_len".into())); - } - - self.recv_buf[assembled..assembled + chunk_data] - .copy_from_slice(&self.pkt_buf[HEADER_SIZE..HEADER_SIZE + chunk_data]); - assembled += chunk_data; - ci += 1; - } - - let payload = &self.recv_buf[..hdr.payload_len as usize]; - - // Validate batch directory - if hdr.flags & FLAG_BATCH != 0 && hdr.item_count > 1 { - let dir_bytes = hdr.item_count as usize * 8; - let dir_aligned = align8(dir_bytes); - if payload.len() < dir_aligned { - return Err(NpError::Protocol("batch directory exceeds payload".into())); - } - let packed_area_len = (payload.len() - dir_aligned) as u32; - protocol::batch_dir_validate(&payload[..dir_bytes], hdr.item_count, packed_area_len) - .map_err(|e| NpError::Protocol(format!("batch directory: {e:?}")))?; - } - - Ok((hdr, payload)) + let framing = self.framing(false); + let handle = self.handle; + let io = self.io.as_ref(); + let pending = &mut self.pending; + framing::receive_message( + framing, + &mut self.inflight_ids, + buf, + &mut self.recv_buf, + &mut self.pkt_buf, + |b| raw_recv_control(handle, io, pending, b, controlled, &mut wait), + ) } /// Close the session. pub fn close(&mut self) { if self.handle != ffi::INVALID_HANDLE_VALUE && self.handle != 0 { - // Flush pending writes so the peer reads all data - unsafe { - ffi::FlushFileBuffers(self.handle); - } - if self.role == Role::Server { - unsafe { - ffi::DisconnectNamedPipe(self.handle); - } - } + // Close only. The peer still reads every message written before + // the close, then sees ERROR_BROKEN_PIPE. FlushFileBuffers would + // block on a peer that stopped reading, and neither + // CancelSynchronousIo nor DisconnectNamedPipe from another thread + // releases it. close_handle(self.handle); self.handle = ffi::INVALID_HANDLE_VALUE; } - self.fail_all_inflight(); + if self.role == Role::Client { + self.inflight_ids.clear(); + } self.recv_buf.clear(); + self.pending = PendingQueue::default(); + // No I/O stays posted between calls, so the events can go. + self.io = None; } } @@ -1112,6 +1448,10 @@ pub struct NpListener { config: ServerConfig, pipe_name: Vec, next_session_id: AtomicU64, + /// Fails this many creations of a replacement pipe instance in accept, + /// as `CreateNamedPipeW` can under resource exhaustion. + #[cfg(test)] + fail_next_instances: u32, } #[cfg(windows)] @@ -1129,6 +1469,8 @@ impl NpListener { config, pipe_name, next_session_id: AtomicU64::new(1), + #[cfg(test)] + fail_next_instances: 0, }) } @@ -1154,12 +1496,25 @@ impl NpListener { } /// Accept one client using a caller-provided per-session server config - /// and session ID. + /// and session ID. The handshake waits at most + /// `SERVER_HANDSHAKE_TIMEOUT_MS` for HELLO. pub fn accept_with_config( &mut self, session_id: u64, config: ServerConfig, ) -> Result { + self.accept_pending()?.handshake( + session_id, + &config, + crate::protocol::SERVER_HANDSHAKE_TIMEOUT_MS, + None, + ) + } + + /// Accept one connection without handshaking, so the caller can run the + /// handshake on another thread. Blocks until a client connects, then + /// creates a new pipe instance for subsequent clients. + pub fn accept_pending(&mut self) -> Result { // Wait for client let connected = unsafe { ffi::ConnectNamedPipe(self.handle, ptr::null_mut()) }; if connected == 0 { @@ -1173,31 +1528,35 @@ impl NpListener { // Create new pipe instance for next client let buf_size = pipe_buffer_size(self.config.packet_size); + #[cfg(test)] + if self.fail_next_instances > 0 { + self.fail_next_instances -= 1; + unsafe { ffi::DisconnectNamedPipe(session_handle) }; + // ERROR_NO_SYSTEM_RESOURCES + return Err(NpError::CreatePipe(1450)); + } let next = match create_pipe_instance(&self.pipe_name, buf_size, false) { Ok(h) => h, Err(e) => { - // Failed to create replacement instance; disconnect and close - // the accepted client to avoid an orphaned connection. - unsafe { - ffi::DisconnectNamedPipe(session_handle); - } - close_handle(session_handle); - self.handle = ffi::INVALID_HANDLE_VALUE; + // Without a next instance, drop this client and keep its + // instance as the listener's, so a later accept listens on + // it again. + unsafe { ffi::DisconnectNamedPipe(session_handle) }; return Err(e); } }; self.handle = next; - // Perform handshake - match server_handshake(session_handle, &config, session_id) { - Ok(session) => Ok(session), - Err(e) => { - unsafe { - ffi::DisconnectNamedPipe(session_handle); - } - close_handle(session_handle); - Err(e) - } + Ok(PendingPipe { + handle: session_handle, + }) + } + + /// A waker that unblocks `accept_pending()` from another thread. It uses + /// only the pipe name, so it never touches a handle the accept loop owns. + pub fn waker(&self) -> NpListenerWaker { + NpListenerWaker { + pipe_name: self.pipe_name.clone(), } } @@ -1215,6 +1574,208 @@ impl Drop for NpListener { } } +/// SECURITY_IDENTIFICATION lets the server identify the client but never act +/// as it. Overlapped, so a pipelining send can read while its write waits. +#[cfg(windows)] +fn create_client_pipe(pipe_name: &[u16]) -> ffi::HANDLE { + unsafe { + ffi::CreateFileW( + pipe_name.as_ptr(), + ffi::GENERIC_READ | ffi::GENERIC_WRITE, + 0, + ptr::null(), + ffi::OPEN_EXISTING, + ffi::SECURITY_SQOS_PRESENT | ffi::SECURITY_IDENTIFICATION | ffi::FILE_FLAG_OVERLAPPED, + 0, + ) + } +} + +/// Test hook called with the pipe name. Tests run in parallel, so a hook +/// ignores every pipe but its own test's. +#[cfg(all(windows, test))] +pub(crate) type ConnectHook = std::sync::Mutex>>; + +/// Runs whenever a connect finds every pipe instance busy, before it waits. +#[cfg(all(windows, test))] +pub(crate) static CONNECT_BUSY_HOOK: ConnectHook = std::sync::Mutex::new(None); + +/// Runs whenever `WaitNamedPipeW` reports a free instance, before the connect +/// retries `CreateFileW`, so a test can take that instance first. +#[cfg(all(windows, test))] +pub(crate) static CONNECT_WAITED_HOOK: ConnectHook = std::sync::Mutex::new(None); + +/// Longest single busy-pipe wait, so an abort ends it promptly. +#[cfg(windows)] +const CONNECT_BUSY_SLICE_MS: u64 = 100; + +/// A server has no free pipe instance between accepting one client and +/// creating the next instance, and connects in that window fail with +/// ERROR_PIPE_BUSY. Wait, bounded by `CONNECT_BUSY_WAIT_MS` and the caller's +/// deadline, for an instance and retry: `WaitNamedPipeW` does not reserve it, +/// so another client may take it first. +#[cfg(windows)] +fn open_client_pipe(pipe_name: &[u16], wait: &ReceiveWait<'_>) -> Result { + if wait.aborted() { + return Err(NpError::Aborted); + } + if wait + .deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { + return Err(NpError::Timeout); + } + let handle = create_client_pipe(pipe_name); + if handle != ffi::INVALID_HANDLE_VALUE { + return Ok(handle); + } + let err = last_error(); + if err != ffi::ERROR_PIPE_BUSY { + return Err(NpError::Connect(err)); + } + #[cfg(test)] + if let Some(hook) = CONNECT_BUSY_HOOK.lock().unwrap().as_ref() { + hook(pipe_name); + } + + let mut busy_deadline = Instant::now() + Duration::from_millis(CONNECT_BUSY_WAIT_MS as u64); + if let Some(deadline) = wait.deadline { + busy_deadline = busy_deadline.min(deadline); + } + loop { + if wait.aborted() { + return Err(NpError::Aborted); + } + let now = Instant::now(); + if now >= busy_deadline { + if wait.deadline.is_some_and(|deadline| now >= deadline) { + return Err(NpError::Timeout); + } + return Err(NpError::Connect(ffi::ERROR_PIPE_BUSY)); + } + let left = busy_deadline.duration_since(now).as_millis().max(1) as u64; + let slice = left.min(CONNECT_BUSY_SLICE_MS) as u32; + if unsafe { ffi::WaitNamedPipeW(pipe_name.as_ptr(), slice) } != 0 { + #[cfg(test)] + if let Some(hook) = CONNECT_WAITED_HOOK.lock().unwrap().as_ref() { + hook(pipe_name); + } + let handle = create_client_pipe(pipe_name); + if handle != ffi::INVALID_HANDLE_VALUE { + return Ok(handle); + } + let err = last_error(); + if err != ffi::ERROR_PIPE_BUSY { + return Err(NpError::Connect(err)); + } + // Another client took the instance first. + continue; + } + let err = last_error(); + if err != ffi::ERROR_SEM_TIMEOUT { + return Err(NpError::Connect(err)); + } + } +} + +/// Wakes a listener blocked in `ConnectNamedPipe` by connecting to it. +#[cfg(windows)] +#[derive(Clone, Debug)] +pub struct NpListenerWaker { + pipe_name: Vec, +} + +#[cfg(windows)] +impl NpListenerWaker { + pub fn wake(&self) { + let h = unsafe { + ffi::CreateFileW( + self.pipe_name.as_ptr(), + ffi::GENERIC_READ | ffi::GENERIC_WRITE, + 0, + ptr::null(), + ffi::OPEN_EXISTING, + ffi::SECURITY_SQOS_PRESENT | ffi::SECURITY_IDENTIFICATION, + 0, + ) + }; + close_handle(h); + } +} + +/// An accepted pipe whose handshake has not run yet. Dropping it closes the +/// pipe; DisconnectNamedPipe would discard a rejection reply the client has +/// not read yet. +#[cfg(windows)] +#[derive(Debug)] +pub struct PendingPipe { + handle: ffi::HANDLE, +} + +#[cfg(windows)] +impl PendingPipe { + /// Complete the server side of the handshake. Waits at most `timeout_ms` + /// for HELLO (`NpError::Timeout`; 0 waits without a deadline) and returns + /// `NpError::Aborted` once `abort` is set. + pub fn handshake( + self, + session_id: u64, + config: &ServerConfig, + timeout_ms: u32, + abort: Option<&AtomicBool>, + ) -> Result { + self.handshake_inner(session_id, config, timeout_ms, abort, None) + } + + /// [`handshake`](Self::handshake) with a prepare step. Once a profile is + /// selected and the payload limits are agreed, and before HELLO_ACK is + /// sent, `prepare(profile, max_request_payload_bytes, + /// max_response_payload_bytes)` sets up that profile's resources (SHM + /// objects). Returning false drops the profile and the handshake selects + /// again; with no profile left the client is rejected as unsupported. + pub fn handshake_prepared( + self, + session_id: u64, + config: &ServerConfig, + timeout_ms: u32, + abort: Option<&AtomicBool>, + prepare: &mut dyn FnMut(u32, u32, u32) -> bool, + ) -> Result { + self.handshake_inner(session_id, config, timeout_ms, abort, Some(prepare)) + } + + fn handshake_inner( + self, + session_id: u64, + config: &ServerConfig, + timeout_ms: u32, + abort: Option<&AtomicBool>, + prepare: Option<&mut dyn FnMut(u32, u32, u32) -> bool>, + ) -> Result { + let wait = ReceiveWait::new(timeout_ms, abort); + loop { + let wait_ms = wait.wait_slice_ms()?; + if wait_pipe_readable(self.handle, wait_ms)? { + break; + } + } + if wait.aborted() { + return Err(NpError::Aborted); + } + let session = server_handshake(self.handle, config, session_id, prepare)?; + // The session owns the handle now. + std::mem::forget(self); + Ok(session) + } +} + +#[cfg(windows)] +impl Drop for PendingPipe { + fn drop(&mut self) { + close_handle(self.handle); + } +} + // --------------------------------------------------------------------------- // Pipe instance creation // --------------------------------------------------------------------------- @@ -1230,16 +1791,20 @@ fn create_pipe_instance( open_mode |= ffi::FILE_FLAG_FIRST_PIPE_INSTANCE; } + let attrs = OwnerOnlyAttributes::new().map_err(NpError::CreatePipe)?; let handle = unsafe { ffi::CreateNamedPipeW( pipe_name.as_ptr(), open_mode, - ffi::PIPE_TYPE_MESSAGE | ffi::PIPE_READMODE_MESSAGE | ffi::PIPE_WAIT, + ffi::PIPE_TYPE_MESSAGE + | ffi::PIPE_READMODE_MESSAGE + | ffi::PIPE_WAIT + | ffi::PIPE_REJECT_REMOTE_CLIENTS, ffi::PIPE_UNLIMITED_INSTANCES, buf_size, buf_size, 0, - ptr::null(), + attrs.as_ptr(), ) }; @@ -1259,14 +1824,15 @@ fn create_pipe_instance( // --------------------------------------------------------------------------- #[cfg(windows)] -fn client_handshake(handle: ffi::HANDLE, config: &ClientConfig) -> Result { +fn client_handshake( + handle: ffi::HANDLE, + io: PipeIo, + config: &ClientConfig, + wait: &ReceiveWait<'_>, +) -> Result { let pkt_size = apply_default(config.packet_size, DEFAULT_PACKET_SIZE); - let supported = if config.supported_profiles != 0 { - config.supported_profiles - } else { - PROFILE_BASELINE - }; + let supported = apply_default(config.supported_profiles, PROFILE_BASELINE); let hello = Hello { layout_version: 1, @@ -1290,87 +1856,50 @@ fn client_handshake(handle: ffi::HANDLE, config: &ClientConfig) -> Result hdr, - Err(crate::protocol::NipcError::BadVersion) => { - return Err(NpError::Incompatible("ack header version mismatch".into())) - } - Err(e) => return Err(NpError::Protocol(format!("ack header: {e}"))), - }; - - if ack_hdr.kind != protocol::KIND_CONTROL || ack_hdr.code != protocol::CODE_HELLO_ACK { - return Err(NpError::Protocol("expected HELLO_ACK".into())); - } - - if ack_hdr.transport_status == protocol::STATUS_AUTH_FAILED { - return Err(NpError::AuthFailed); - } - if ack_hdr.transport_status == protocol::STATUS_UNSUPPORTED { - return Err(NpError::NoProfile); - } - if ack_hdr.transport_status == protocol::STATUS_INCOMPATIBLE { - return Err(NpError::Incompatible( - "ack transport_status incompatible".into(), - )); - } - if ack_hdr.transport_status == protocol::STATUS_LIMIT_EXCEEDED { - return Err(NpError::LimitExceeded); - } - if ack_hdr.transport_status != protocol::STATUS_OK { - return Err(NpError::Handshake(format!( - "transport_status={}", - ack_hdr.transport_status - ))); + let mut no_pending = PendingQueue::default(); + write_packet( + handle, + Some(&io), + &mut no_pending, + 0, + 0, + &framing::hello_packet(&hello), + None, + &mut false, + )?; + + // Receive HELLO_ACK; a server that never answers must not hold the + // client past its deadline. A server that rejects the client writes its + // reply and closes, so a disconnected pipe may still hold that reply. + loop { + let wait_ms = wait.wait_slice_ms()?; + match wait_pipe_readable(handle, wait_ms) { + Ok(true) | Err(NpError::Disconnected) => break, + Ok(false) => {} + Err(e) => return Err(e), + } } - - if n < HEADER_SIZE + HELLO_ACK_PAYLOAD_SIZE { - return Err(NpError::Protocol("ack payload truncated".into())); + if wait.aborted() { + return Err(NpError::Aborted); } + let mut ack_buf = [0u8; 128]; + let n = overlapped_read(handle, io.read_event, &mut ack_buf)?; - let ack = match HelloAck::decode(&ack_buf[HEADER_SIZE..n]) { - Ok(ack) => ack, - Err(crate::protocol::NipcError::BadLayout) - if hello_ack_layout_incompatible(&ack_buf[HEADER_SIZE..n]) => - { - return Err(NpError::Incompatible( - "ack payload layout version mismatch".into(), - )) - } - Err(e) => return Err(NpError::Protocol(format!("ack payload: {e}"))), - }; + let ack: HelloAck = framing::parse_hello_ack(&ack_buf[..n], supported)?; // Reject packet sizes too small for a header if ack.agreed_packet_size <= HEADER_SIZE as u32 { - return Err(NpError::Handshake( + return Err(NpError::Protocol( "agreed packet_size <= HEADER_SIZE".into(), )); } + // A server may only lower our proposal: buffers and chunking on this side + // are sized from it. + if ack.agreed_packet_size > pkt_size { + return Err(NpError::Protocol( + "agreed packet_size exceeds proposal".into(), + )); + } Ok(NpSession { handle, @@ -1386,6 +1915,8 @@ fn client_handshake(handle: ffi::HANDLE, config: &ClientConfig) -> Result bool>, ) -> Result { - let server_pkt_size = apply_default(config.packet_size, DEFAULT_PACKET_SIZE); - let s_req_pay = apply_default(config.max_request_payload_bytes, MAX_PAYLOAD_DEFAULT); - let s_resp_pay = apply_default(config.max_response_payload_bytes, MAX_PAYLOAD_DEFAULT); - let s_profiles = if config.supported_profiles != 0 { - config.supported_profiles - } else { - PROFILE_BASELINE - }; - let s_preferred = config.preferred_profiles; - - let send_rejection = |status: u16| { - let ack = HelloAck { - layout_version: 1, - ..HelloAck::default() - }; - let mut ack_buf = [0u8; HELLO_ACK_PAYLOAD_SIZE]; - ack.encode(&mut ack_buf); - - let ack_hdr = Header { - magic: MAGIC_MSG, - version: VERSION, - header_len: protocol::HEADER_LEN, - kind: protocol::KIND_CONTROL, - flags: 0, - code: protocol::CODE_HELLO_ACK, - transport_status: status, - payload_len: HELLO_ACK_PAYLOAD_SIZE as u32, - item_count: 1, - message_id: 0, - }; - - let mut pkt = [0u8; HEADER_SIZE + HELLO_ACK_PAYLOAD_SIZE]; - ack_hdr.encode(&mut pkt[..HEADER_SIZE]); - pkt[HEADER_SIZE..].copy_from_slice(&ack_buf); - let _ = raw_write(handle, &pkt); + let offer = ServerOffer { + packet_size: apply_default(config.packet_size, DEFAULT_PACKET_SIZE), + supported_profiles: apply_default(config.supported_profiles, PROFILE_BASELINE), + preferred_profiles: config.preferred_profiles, + max_request_payload_bytes: apply_default( + config.max_request_payload_bytes, + MAX_PAYLOAD_DEFAULT, + ), + max_response_payload_bytes: apply_default( + config.max_response_payload_bytes, + MAX_PAYLOAD_DEFAULT, + ), + auth_token: config.auth_token, }; // Receive HELLO let mut buf = [0u8; 128]; let n = raw_recv(handle, &mut buf)?; - - let hdr = match Header::decode(&buf[..n]) { - Ok(hdr) => hdr, - Err(crate::protocol::NipcError::BadVersion) - if header_version_incompatible(&buf[..n], protocol::CODE_HELLO) => - { - send_rejection(protocol::STATUS_INCOMPATIBLE); - return Err(NpError::Incompatible( - "hello header version mismatch".into(), - )); - } - Err(e) => return Err(NpError::Protocol(format!("hello header: {e}"))), - }; - - if hdr.kind != protocol::KIND_CONTROL || hdr.code != protocol::CODE_HELLO { - return Err(NpError::Protocol("expected HELLO".into())); - } - - let hello = match Hello::decode(&buf[HEADER_SIZE..n]) { - Ok(hello) => hello, - Err(crate::protocol::NipcError::BadLayout) - if hello_layout_incompatible(&buf[HEADER_SIZE..n]) => - { - send_rejection(protocol::STATUS_INCOMPATIBLE); - return Err(NpError::Incompatible( - "hello payload layout version mismatch".into(), - )); - } - Err(e) => return Err(NpError::Protocol(format!("hello payload: {e}"))), - }; - - let intersection = hello.supported_profiles & s_profiles; - - if intersection == 0 { - send_rejection(protocol::STATUS_UNSUPPORTED); - return Err(NpError::NoProfile); - } - - if hello.auth_token != config.auth_token { - send_rejection(protocol::STATUS_AUTH_FAILED); - return Err(NpError::AuthFailed); - } - - // Select profile - let preferred_intersection = intersection & hello.preferred_profiles & s_preferred; - let selected = if preferred_intersection != 0 { - highest_bit(preferred_intersection) - } else { - highest_bit(intersection) - }; - - if hello.max_request_payload_bytes > s_req_pay { - send_rejection(protocol::STATUS_LIMIT_EXCEEDED); - return Err(NpError::LimitExceeded); - } - - // Negotiate limits: - // - request payload and batch size are client-proposed and echoed - // - response payload is server-authoritative - // - response batch size is symmetric with request batch size - let agreed_req_pay = hello.max_request_payload_bytes; - let agreed_req_bat = hello.max_request_batch_items; - let agreed_resp_pay = s_resp_pay; - let agreed_resp_bat = agreed_req_bat; - let agreed_pkt = min_u32(hello.packet_size, server_pkt_size); - - // Reject packet sizes too small for a usable message packet - if agreed_pkt <= HEADER_SIZE as u32 { - send_rejection(protocol::STATUS_INCOMPATIBLE); - return Err(NpError::Incompatible( - "packet size too small for negotiated session".into(), - )); - } - - // Send HELLO_ACK - let ack = HelloAck { - layout_version: 1, - flags: 0, - server_supported_profiles: s_profiles, - intersection_profiles: intersection, - selected_profile: selected, - agreed_max_request_payload_bytes: agreed_req_pay, - agreed_max_request_batch_items: agreed_req_bat, - agreed_max_response_payload_bytes: agreed_resp_pay, - agreed_max_response_batch_items: agreed_resp_bat, - agreed_packet_size: agreed_pkt, - session_id, - }; - - let mut ack_buf = [0u8; HELLO_ACK_PAYLOAD_SIZE]; - ack.encode(&mut ack_buf); - - let ack_hdr = Header { - magic: MAGIC_MSG, - version: VERSION, - header_len: protocol::HEADER_LEN, - kind: protocol::KIND_CONTROL, - flags: 0, - code: protocol::CODE_HELLO_ACK, - transport_status: protocol::STATUS_OK, - payload_len: HELLO_ACK_PAYLOAD_SIZE as u32, - item_count: 1, - message_id: 0, - }; - - let mut pkt = [0u8; HEADER_SIZE + HELLO_ACK_PAYLOAD_SIZE]; - ack_hdr.encode(&mut pkt[..HEADER_SIZE]); - pkt[HEADER_SIZE..].copy_from_slice(&ack_buf); - - raw_write(handle, &pkt)?; + let ack = framing::server_negotiate(&buf[..n], &offer, session_id, prepare, |pkt| { + raw_write(handle, pkt) + })?; Ok(NpSession { handle, role: Role::Server, - max_request_payload_bytes: agreed_req_pay, - max_request_batch_items: agreed_req_bat, - max_response_payload_bytes: agreed_resp_pay, - max_response_batch_items: agreed_resp_bat, - packet_size: agreed_pkt, - selected_profile: selected, + max_request_payload_bytes: ack.agreed_max_request_payload_bytes, + max_request_batch_items: ack.agreed_max_request_batch_items, + max_response_payload_bytes: ack.agreed_max_response_payload_bytes, + max_response_batch_items: ack.agreed_max_response_batch_items, + packet_size: ack.agreed_packet_size, + selected_profile: ack.selected_profile, session_id, recv_buf: Vec::new(), pkt_buf: Vec::new(), send_buf: Vec::new(), inflight_ids: HashSet::new(), + io: None, + pending: PendingQueue::default(), }) } @@ -1574,6 +1979,11 @@ fn server_handshake( #[cfg(test)] mod tests { use super::*; + use crate::protocol; + #[cfg(windows)] + use crate::protocol::{KIND_REQUEST, KIND_RESPONSE, MAGIC_MSG, VERSION}; + #[cfg(windows)] + use crate::transport::framing::HELLO_ACK_PAYLOAD_SIZE; #[cfg(windows)] use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(windows)] @@ -1755,58 +2165,110 @@ mod tests { } #[test] - fn test_incompatible_classifiers() { - let hdr = Header { - magic: MAGIC_MSG, - version: VERSION + 1, - header_len: protocol::HEADER_LEN, - kind: protocol::KIND_CONTROL, - flags: 0, - code: protocol::CODE_HELLO, - transport_status: protocol::STATUS_OK, - payload_len: HELLO_PAYLOAD_SIZE as u32, - item_count: 1, - message_id: 0, - }; - let mut hdr_buf = [0u8; HEADER_SIZE]; - hdr.encode(&mut hdr_buf); - assert!(header_version_incompatible(&hdr_buf, protocol::CODE_HELLO)); - assert!(!header_version_incompatible( - &hdr_buf, - protocol::CODE_HELLO_ACK - )); - - let hello = Hello { - layout_version: 2, - ..Hello::default() - }; - let mut hello_buf = [0u8; HELLO_PAYLOAD_SIZE]; - hello.encode(&mut hello_buf); - assert!(hello_layout_incompatible(&hello_buf)); + fn test_build_pipe_name_too_long() { + let long_name = "a".repeat(300); + let result = build_pipe_name("/var/run", &long_name); + assert!(matches!(result, Err(NpError::PipeName(_)))); + } - let ack = HelloAck { - layout_version: 2, - ..HelloAck::default() - }; - let mut ack_buf = [0u8; HELLO_ACK_PAYLOAD_SIZE]; - ack.encode(&mut ack_buf); - assert!(hello_ack_layout_incompatible(&ack_buf)); + /// Serve one HELLO_ACK with `selected` and the agreed packet size + /// `agreed(proposal)`, then close; returns the server thread. + #[cfg(windows)] + fn fake_hello_ack_server( + svc: &str, + selected: u32, + agreed: fn(u32) -> u32, + ) -> thread::JoinHandle<()> { + let pipe_name = build_pipe_name(TEST_RUN_DIR, svc).expect("pipe name"); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let server = thread::spawn(move || { + let handle = + create_pipe_instance(&pipe_name, pipe_buffer_size(DEFAULT_PACKET_SIZE), true) + .expect("create pipe"); + ready_tx.send(()).expect("signal ready"); + unsafe { ffi::ConnectNamedPipe(handle, std::ptr::null_mut()) }; + + let mut buf = [0u8; 128]; + let n = raw_recv(handle, &mut buf).expect("recv hello"); + let hello = Hello::decode(&buf[HEADER_SIZE..n]).expect("decode hello"); + let ack = HelloAck { + layout_version: 1, + server_supported_profiles: PROFILE_BASELINE | protocol::PROFILE_SHM_HYBRID, + intersection_profiles: PROFILE_BASELINE, + selected_profile: selected, + agreed_max_request_payload_bytes: hello.max_request_payload_bytes, + agreed_max_request_batch_items: hello.max_request_batch_items, + agreed_max_response_payload_bytes: hello.max_response_payload_bytes, + agreed_max_response_batch_items: hello.max_response_batch_items, + agreed_packet_size: agreed(hello.packet_size), + session_id: 1, + ..HelloAck::default() + }; + let mut pkt = [0u8; HEADER_SIZE + HELLO_ACK_PAYLOAD_SIZE]; + Header { + magic: MAGIC_MSG, + version: VERSION, + header_len: protocol::HEADER_LEN, + kind: protocol::KIND_CONTROL, + flags: 0, + code: protocol::CODE_HELLO_ACK, + transport_status: protocol::STATUS_OK, + payload_len: HELLO_ACK_PAYLOAD_SIZE as u32, + item_count: 1, + message_id: 0, + } + .encode(&mut pkt[..HEADER_SIZE]); + ack.encode(&mut pkt[HEADER_SIZE..]); + raw_write(handle, &pkt).expect("send ack"); + close_handle(handle); + }); + ready_rx.recv().expect("server ready"); + server } + #[cfg(windows)] #[test] - fn test_highest_bit() { - assert_eq!(highest_bit(0), 0); - assert_eq!(highest_bit(1), 1); - assert_eq!(highest_bit(0b0101), 4); - assert_eq!(highest_bit(0b1000), 8); - assert_eq!(highest_bit(0xFF), 128); + fn test_connect_rejects_selected_profile_not_offered() { + ensure_run_dir(); + // The client offers baseline only. + for selected in [ + protocol::PROFILE_SHM_HYBRID, + 0, + PROFILE_BASELINE | protocol::PROFILE_SHM_HYBRID, + ] { + let svc = unique_service("rs_ack_selected"); + let server = fake_hello_ack_server(&svc, selected, |proposal| proposal); + let err = match NpSession::connect(TEST_RUN_DIR, &svc, &default_client_config()) { + Ok(_) => panic!("HELLO_ACK selecting 0x{selected:x} should fail"), + Err(err) => err, + }; + assert!( + matches!(&err, NpError::Protocol(msg) if msg.contains("not offered")), + "selected 0x{selected:x}: {err:?}" + ); + server.join().expect("server join"); + } } + #[cfg(windows)] #[test] - fn test_build_pipe_name_too_long() { - let long_name = "a".repeat(300); - let result = build_pipe_name("/var/run", &long_name); - assert!(matches!(result, Err(NpError::PipeName(_)))); + fn test_connect_rejects_agreed_packet_size_out_of_range() { + ensure_run_dir(); + // A server may only lower the proposal, and must leave payload room. + let cases: [fn(u32) -> u32; 2] = [|proposal| proposal + 1, |_| HEADER_SIZE as u32]; + for agreed in cases { + let svc = unique_service("rs_ack_pkt"); + let server = fake_hello_ack_server(&svc, PROFILE_BASELINE, agreed); + let err = match NpSession::connect(TEST_RUN_DIR, &svc, &default_client_config()) { + Ok(_) => panic!("HELLO_ACK with an out-of-range packet size should fail"), + Err(err) => err, + }; + assert!( + matches!(&err, NpError::Protocol(msg) if msg.contains("packet_size")), + "{err:?}" + ); + server.join().expect("server join"); + } } #[cfg(windows)] @@ -1835,6 +2297,8 @@ mod tests { pkt_buf: Vec::new(), send_buf: Vec::new(), inflight_ids: HashSet::new(), + io: None, + pending: PendingQueue::default(), }; let mut hdr = Header { @@ -2083,6 +2547,107 @@ mod tests { server.join().expect("server join"); } + /// A client pipelining more than the pipe buffers before reading must + /// not deadlock with a server blocked writing responses to it. + #[cfg(windows)] + #[test] + fn test_pipeline_beyond_pipe_buffers() { + const PAYLOAD: usize = 16 * 1024; + const REQUESTS: u64 = 32; // 512 KiB each way, 8x the 64 KiB pipe buffers. + fn payload_for(id: u64) -> Vec { + (0..PAYLOAD).map(|i| (id as usize + i) as u8).collect() + } + + ensure_run_dir(); + let svc = unique_service("rs_pipe_progress"); + let scfg = ServerConfig { + max_request_payload_bytes: PAYLOAD as u32, + max_response_payload_bytes: PAYLOAD as u32, + ..default_server_config() + }; + let mut listener = NpListener::bind(TEST_RUN_DIR, &svc, scfg).expect("bind"); + + // Answers each request with blocking writes, one at a time. + let server = thread::spawn(move || { + let mut session = listener.accept().expect("accept"); + let mut buf = vec![0u8; 64 * 1024]; + for _ in 0..REQUESTS { + let (hdr, payload) = session.receive(&mut buf).expect("recv"); + let payload = payload.to_vec(); + let mut resp = hdr; + resp.kind = KIND_RESPONSE; + resp.transport_status = protocol::STATUS_OK; + session.send(&mut resp, &payload).expect("send"); + } + session.close(); + }); + + let ccfg = ClientConfig { + max_request_payload_bytes: PAYLOAD as u32, + max_response_payload_bytes: PAYLOAD as u32, + ..default_client_config() + }; + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let client = thread::spawn(move || { + let mut session = NpSession::connect(TEST_RUN_DIR, &svc, &ccfg).expect("connect"); + for id in 1..=REQUESTS { + let mut hdr = Header { + kind: KIND_REQUEST, + code: protocol::METHOD_INCREMENT, + item_count: 1, + message_id: id, + ..Header::default() + }; + session.send(&mut hdr, &payload_for(id)).expect("send"); + } + // The server could read every request only because the sends + // drained responses it had written. + assert!( + session.has_pending(), + "no response was queued while sending" + ); + assert!(session.wait_readable(0).expect("wait_readable")); + let mut rbuf = vec![0u8; 64 * 1024]; + for id in 1..=REQUESTS { + let (rhdr, rpayload) = session.receive(&mut rbuf).expect("recv"); + assert_eq!(rhdr.message_id, id); + assert_eq!(rpayload, payload_for(id).as_slice()); + } + assert!(!session.has_pending()); + session.close(); + let _ = done_tx.send(()); + }); + + // Watchdog only: a deadlock never completes. A client panic drops + // done_tx; join then reports the panic itself. + match done_rx.recv_timeout(Duration::from_secs(30)) { + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + panic!("pipelining beyond the pipe buffers deadlocked") + } + _ => client.join().expect("client"), + } + server.join().expect("server join"); + } + + /// A queued message larger than the receive buffer fails like a pipe + /// read with ERROR_MORE_DATA, and the queue moves past it. + #[cfg(windows)] + #[test] + fn test_pending_queue_pop_too_small() { + let mut q = PendingQueue::default(); + for msg in [&b"0123456789"[..], &b"ab"[..]] { + q.push(msg); + } + let mut dst = [0u8; 4]; + assert!(matches!( + q.pop(&mut dst), + Err(NpError::Recv(ERROR_MORE_DATA)) + )); + assert_eq!(q.pop(&mut dst).expect("second pop"), 2); + assert_eq!(&dst[..2], b"ab"); + assert!(!q.queued()); + } + #[cfg(windows)] #[test] fn test_duplicate_message_id() { @@ -2283,6 +2848,319 @@ mod tests { server.join().expect("server join"); } + /// The connect hooks are process-wide: tests that set them run one at + /// a time. + #[cfg(windows)] + static CONNECT_HOOK_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + #[cfg(windows)] + #[test] + fn test_connect_waits_for_busy_pipe() { + let _hooks = CONNECT_HOOK_TESTS.lock().unwrap_or_else(|e| e.into_inner()); + ensure_run_dir(); + let svc = unique_service("rs_busy"); + let mut listener = + NpListener::bind(TEST_RUN_DIR, &svc, default_server_config()).expect("bind"); + + // The first client takes the only instance; the server has not + // accepted it, so no next instance exists yet. + let pipe_name = build_pipe_name(TEST_RUN_DIR, &svc).expect("pipe name"); + let holder = create_client_pipe(&pipe_name); + assert_ne!( + holder, + ffi::INVALID_HANDLE_VALUE, + "first client holds the only instance" + ); + + let (busy_tx, busy_rx) = std::sync::mpsc::channel::<()>(); + let busy_name = pipe_name.clone(); + *CONNECT_BUSY_HOOK.lock().unwrap() = Some(Box::new(move |name| { + if name == busy_name.as_slice() { + let _ = busy_tx.send(()); + } + })); + + // Accept the holder once the second client found the pipe busy, then + // serve the second client's handshake. + let server = thread::spawn(move || { + busy_rx.recv().ok()?; + let first = listener.accept_pending().ok()?; + let second = listener.accept_pending().ok()?; + let session = second + .handshake(2, &default_server_config(), 10_000, None) + .ok()?; + drop(first); + Some(session) + }); + + let result = NpSession::connect(TEST_RUN_DIR, &svc, &default_client_config()); + // Dropping the hook releases the server thread if connect never waited. + *CONNECT_BUSY_HOOK.lock().unwrap() = None; + if result.is_err() { + close_handle(holder); + } + let mut session = result.expect("busy pipe connect waits and succeeds"); + let accepted = server.join().expect("server join"); + assert!(accepted.is_some(), "server accepted the waiting client"); + + session.close(); + close_handle(holder); + } + + #[cfg(windows)] + #[test] + fn test_connect_retries_stolen_instance() { + let _hooks = CONNECT_HOOK_TESTS.lock().unwrap_or_else(|e| e.into_inner()); + ensure_run_dir(); + let svc = unique_service("rs_stolen"); + let mut listener = + NpListener::bind(TEST_RUN_DIR, &svc, default_server_config()).expect("bind"); + + let pipe_name = build_pipe_name(TEST_RUN_DIR, &svc).expect("pipe name"); + let holder = create_client_pipe(&pipe_name); + assert_ne!( + holder, + ffi::INVALID_HANDLE_VALUE, + "holder takes the only instance" + ); + + let (busy_tx, busy_rx) = std::sync::mpsc::channel::<()>(); + let busy_name = pipe_name.clone(); + *CONNECT_BUSY_HOOK.lock().unwrap() = Some(Box::new(move |name| { + if name == busy_name.as_slice() { + let _ = busy_tx.send(()); + } + })); + // Once WaitNamedPipeW reports the instance the server created on + // accept, a third client takes it first, once. + let (thief_tx, thief_rx) = std::sync::mpsc::channel::(); + let thief_name = pipe_name.clone(); + let stolen = std::sync::atomic::AtomicBool::new(false); + *CONNECT_WAITED_HOOK.lock().unwrap() = Some(Box::new(move |name| { + if name == thief_name.as_slice() + && !stolen.swap(true, std::sync::atomic::Ordering::SeqCst) + { + let _ = thief_tx.send(create_client_pipe(&thief_name)); + } + })); + + // Accept the holder, then the thief, then the retrying client. + let server = thread::spawn(move || { + busy_rx.recv().ok()?; + let first = listener.accept_pending().ok()?; + let thief = thief_rx.recv().ok()?; + let second = listener.accept_pending().ok()?; + let third = listener.accept_pending().ok()?; + let session = third + .handshake(3, &default_server_config(), 10_000, None) + .ok()?; + drop(first); + drop(second); + Some((session, thief)) + }); + + let result = NpSession::connect(TEST_RUN_DIR, &svc, &default_client_config()); + // Dropping the hooks releases the server thread if connect never got there. + *CONNECT_BUSY_HOOK.lock().unwrap() = None; + *CONNECT_WAITED_HOOK.lock().unwrap() = None; + if result.is_err() { + close_handle(holder); + } + let mut session = result.expect("connect retries after the instance was taken"); + let (_accepted, thief) = server + .join() + .expect("server join") + .expect("server accepted the retrying client"); + assert_ne!( + thief, + ffi::INVALID_HANDLE_VALUE, + "thief took the freed instance" + ); + + session.close(); + close_handle(thief); + close_handle(holder); + } + + #[cfg(windows)] + #[test] + fn test_accept_survives_failed_instance() { + let _hooks = CONNECT_HOOK_TESTS.lock().unwrap_or_else(|e| e.into_inner()); + ensure_run_dir(); + let svc = unique_service("rs_failinst"); + let mut listener = + NpListener::bind(TEST_RUN_DIR, &svc, default_server_config()).expect("bind"); + + let pipe_name = build_pipe_name(TEST_RUN_DIR, &svc).expect("pipe name"); + let dropped = create_client_pipe(&pipe_name); + assert_ne!(dropped, ffi::INVALID_HANDLE_VALUE, "first client connects"); + + listener.fail_next_instances = 1; + let result = listener.accept_pending(); + assert!(matches!(result, Err(NpError::CreatePipe(_)))); + assert_ne!( + listener.handle, + ffi::INVALID_HANDLE_VALUE, + "listener keeps its instance" + ); + let mut avail: ffi::DWORD = 0; + let peek = unsafe { + ffi::PeekNamedPipe( + dropped, + ptr::null_mut(), + 0, + ptr::null_mut(), + &mut avail, + ptr::null_mut(), + ) + }; + assert_eq!(peek, 0, "first client was disconnected"); + + // The disconnected instance takes no client until accept listens on + // it again, so the next client finds the pipe busy first. + let (busy_tx, busy_rx) = std::sync::mpsc::channel::<()>(); + let busy_name = pipe_name.clone(); + *CONNECT_BUSY_HOOK.lock().unwrap() = Some(Box::new(move |name| { + if name == busy_name.as_slice() { + let _ = busy_tx.send(()); + } + })); + let server = thread::spawn(move || { + busy_rx.recv().ok()?; + let pending = listener.accept_pending().ok()?; + pending + .handshake(1, &default_server_config(), 10_000, None) + .ok() + }); + + let result = NpSession::connect_with_timeout( + TEST_RUN_DIR, + &svc, + &default_client_config(), + 10_000, + None, + ); + *CONNECT_BUSY_HOOK.lock().unwrap() = None; + close_handle(dropped); + let mut session = result.expect("next client connects"); + let accepted = server.join().expect("server join"); + assert!(accepted.is_some(), "server accepted the next client"); + session.close(); + } + + #[cfg(windows)] + #[test] + fn test_pipe_dacl_owner_only() { + use crate::transport::win_security::{object_dacl_sddl, owner_only_dacl_sddl}; + ensure_run_dir(); + let svc = unique_service("rs_dacl"); + let mut listener = + NpListener::bind(TEST_RUN_DIR, &svc, default_server_config()).expect("bind"); + let expected = owner_only_dacl_sddl("FA").expect("expected DACL"); + assert_eq!( + object_dacl_sddl(listener.handle).as_deref(), + Some(expected.as_str()) + ); + + // Instances created after an accept carry the same DACL. + let pipe_name = build_pipe_name(TEST_RUN_DIR, &svc).expect("pipe name"); + let client = create_client_pipe(&pipe_name); + let pending = listener.accept_pending().expect("accept pending"); + assert_eq!( + object_dacl_sddl(listener.handle).as_deref(), + Some(expected.as_str()) + ); + drop(pending); + close_handle(client); + } + + #[cfg(windows)] + #[test] + fn test_remote_client_rejected() { + ensure_run_dir(); + let svc = unique_service("rs_remote"); + let _listener = + NpListener::bind(TEST_RUN_DIR, &svc, default_server_config()).expect("bind"); + + // The same pipe through the SMB loopback is a remote client. + let pipe_name = build_pipe_name(TEST_RUN_DIR, &svc).expect("pipe name"); + let mut remote: Vec = r"\\127.0.0.1\".encode_utf16().collect(); + remote.extend_from_slice(&pipe_name[4..]); + let h = unsafe { + ffi::CreateFileW( + remote.as_ptr(), + ffi::GENERIC_READ | ffi::GENERIC_WRITE, + 0, + ptr::null(), + ffi::OPEN_EXISTING, + 0, + 0, + ) + }; + if h != ffi::INVALID_HANDLE_VALUE { + close_handle(h); + } + assert_eq!(h, ffi::INVALID_HANDLE_VALUE, "remote open is refused"); + } + + #[cfg(windows)] + #[test] + fn test_untrusted_server_rejected() { + use crate::transport::win_security::{ + SECURITY_IDENTIFICATION_LEVEL, TRUSTED_OWNER_FOR_TESTS, client_impersonation_level, + object_owner_sid, + }; + ensure_run_dir(); + let svc = unique_service("rs_untrusted"); + let listener = NpListener::bind(TEST_RUN_DIR, &svc, default_server_config()).expect("bind"); + + // BUILTIN\Users never owns the test pipe. Nobody accepts, so a client + // that skipped the check fails on the deadline instead of hanging. + TRUSTED_OWNER_FOR_TESTS.with(|t| *t.borrow_mut() = Some("S-1-5-32-545".into())); + let result = NpSession::connect_with_timeout( + TEST_RUN_DIR, + &svc, + &default_client_config(), + 2000, + None, + ); + TRUSTED_OWNER_FOR_TESTS.with(|t| *t.borrow_mut() = None); + assert!( + matches!(result, Err(NpError::UntrustedServer)), + "got {:?}", + result.err() + ); + drop(listener); + + // Trusting the real owner lets the client through, and the server sees + // only an identification-level token for it. + let svc = unique_service("rs_trusted"); + let mut listener = + NpListener::bind(TEST_RUN_DIR, &svc, default_server_config()).expect("bind"); + let owner = object_owner_sid(listener.handle).expect("pipe owner"); + let server = thread::spawn(move || { + let session = listener.accept().ok()?; + client_impersonation_level(session.handle()) + }); + TRUSTED_OWNER_FOR_TESTS.with(|t| *t.borrow_mut() = Some(owner)); + let result = NpSession::connect_with_timeout( + TEST_RUN_DIR, + &svc, + &default_client_config(), + 10_000, + None, + ); + TRUSTED_OWNER_FOR_TESTS.with(|t| *t.borrow_mut() = None); + let mut session = result.expect("connect accepts the trusted owner"); + let level = server.join().expect("server join"); + assert_eq!( + level, + Some(SECURITY_IDENTIFICATION_LEVEL), + "server can only identify the client" + ); + session.close(); + } + #[cfg(windows)] #[test] fn test_preferred_profile_selected() { diff --git a/src/go/go.mod b/src/go/go.mod index b7184147..90a4583a 100644 --- a/src/go/go.mod +++ b/src/go/go.mod @@ -1,3 +1,5 @@ module github.com/netdata/plugin-ipc/go -go 1.26.0 +go 1.27.0 + +require golang.org/x/sys v0.49.0 diff --git a/src/go/go.sum b/src/go/go.sum new file mode 100644 index 00000000..d61accff --- /dev/null +++ b/src/go/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/src/go/osv-scanner.toml b/src/go/osv-scanner.toml index 7f2a1225..9e02749b 100644 --- a/src/go/osv-scanner.toml +++ b/src/go/osv-scanner.toml @@ -1,6 +1,5 @@ -# CI/dev toolchains build with the latest Go 1.26.x patch release. The go -# directive in go.mod must stay 1.26.0 until netdata (which vendors this -# source) raises its own Go toolchain; bumping it would break netdata builds. -# This override scans the stdlib at the toolchain version actually used. -# Remove when netdata moves past Go 1.26.0 and go.mod is bumped. -GoVersionOverride = "1.26.4" +# Scan the Go standard library Netdata ships. go.mod keeps Netdata's +# `go 1.27.0` directive, while Netdata builds with the patch release pinned +# in netdata/netdata packaging/check-for-go-toolchain.sh. Keep this value +# equal to that pin and bump it whenever Netdata bumps it. +GoVersionOverride = "1.27.3" diff --git a/src/go/pkg/netipc/internal/monotime/monotime.go b/src/go/pkg/netipc/internal/monotime/monotime.go new file mode 100644 index 00000000..783ad61b --- /dev/null +++ b/src/go/pkg/netipc/internal/monotime/monotime.go @@ -0,0 +1,22 @@ +// Package monotime reads Go's monotonic clock without the wall clock. +// +// time.Now reads both clocks. Where the platform has no user-space clock +// (FreeBSD on a kvmclock timecounter) each read is a syscall, so per-call +// deadlines use this package instead: time.Since on a time that carries a +// monotonic reading reads only the monotonic clock. +package monotime + +import "time" + +var base = time.Now() + +// Now returns the monotonic time since process start, in nanoseconds. +func Now() int64 { + return int64(time.Since(base)) +} + +// After returns the monotonic time d from now, in nanoseconds. For d > 0 +// the result is never zero, so callers may use zero as "no deadline". +func After(d time.Duration) int64 { + return Now() + int64(d) +} diff --git a/src/go/pkg/netipc/internal/monotime/monotime_test.go b/src/go/pkg/netipc/internal/monotime/monotime_test.go new file mode 100644 index 00000000..25b0da6a --- /dev/null +++ b/src/go/pkg/netipc/internal/monotime/monotime_test.go @@ -0,0 +1,34 @@ +package monotime + +import ( + "testing" + "time" +) + +func TestNowDoesNotGoBackwards(t *testing.T) { + prev := Now() + for range 1000 { + now := Now() + if now < prev { + t.Fatalf("Now went backwards: %d after %d", now, prev) + } + prev = now + } +} + +func TestAfterIsAheadOfNow(t *testing.T) { + d := time.Millisecond + // Bracket the call with clock reads, so a descheduled test cannot fail. + before := Now() + deadline := After(d) + after := Now() + if deadline == 0 { + t.Fatal("After returned the no-deadline value") + } + if deadline < before+int64(d) || deadline > after+int64(d) { + t.Fatalf("After(%v) = %d, want within [%d, %d]", d, deadline, before+int64(d), after+int64(d)) + } + if expired := After(-d); expired >= Now() { + t.Fatalf("After(-d) = %d is not in the past", expired) + } +} diff --git a/src/go/pkg/netipc/internal/syspoll/poll_linux.go b/src/go/pkg/netipc/internal/syspoll/poll_linux.go new file mode 100644 index 00000000..92b77d66 --- /dev/null +++ b/src/go/pkg/netipc/internal/syspoll/poll_linux.go @@ -0,0 +1,34 @@ +//go:build linux + +package syspoll + +import ( + "syscall" + "unsafe" +) + +// RdHup reports a peer that closed its end (Linux-only POLLRDHUP). +const RdHup int16 = 0x2000 + +// Poll waits up to timeoutMs (negative waits indefinitely) for pfd.Events. +// It returns the poll result count and the raw errno. +// +// Linux uses ppoll because poll does not exist on newer architectures such +// as arm64, riscv64 and loong64. +func Poll(pfd *PollFd, timeoutMs int) (int, syscall.Errno) { + var ts *syscall.Timespec + if timeoutMs >= 0 { + t := syscall.NsecToTimespec(int64(timeoutMs) * 1_000_000) + ts = &t + } + r, _, errno := syscall.Syscall6( + syscall.SYS_PPOLL, + uintptr(unsafe.Pointer(pfd)), // #nosec G103 -- raw ppoll syscall requires a pollfd pointer. + 1, + uintptr(unsafe.Pointer(ts)), // #nosec G103 -- raw ppoll syscall requires a timespec pointer. + 0, + 0, + 0, + ) + return int(r), errno +} diff --git a/src/go/pkg/netipc/internal/syspoll/poll_other.go b/src/go/pkg/netipc/internal/syspoll/poll_other.go new file mode 100644 index 00000000..dad4dff5 --- /dev/null +++ b/src/go/pkg/netipc/internal/syspoll/poll_other.go @@ -0,0 +1,23 @@ +//go:build unix && !linux + +package syspoll + +import ( + "syscall" + "unsafe" +) + +// RdHup is Linux-only; elsewhere a closed peer shows as Hup or EOF. +const RdHup int16 = 0 + +// Poll waits up to timeoutMs (negative waits indefinitely) for pfd.Events. +// It returns the poll result count and the raw errno. +func Poll(pfd *PollFd, timeoutMs int) (int, syscall.Errno) { + r, _, errno := syscall.Syscall( + syscall.SYS_POLL, + uintptr(unsafe.Pointer(pfd)), // #nosec G103 -- raw poll syscall requires a pollfd pointer. + 1, + uintptr(timeoutMs), + ) + return int(r), errno +} diff --git a/src/go/pkg/netipc/internal/syspoll/syspoll.go b/src/go/pkg/netipc/internal/syspoll/syspoll.go new file mode 100644 index 00000000..c0a95d1c --- /dev/null +++ b/src/go/pkg/netipc/internal/syspoll/syspoll.go @@ -0,0 +1,22 @@ +//go:build unix + +// Package syspoll waits for readiness on one file descriptor using the raw +// poll family of system calls, so NetIPC stays pure Go (no cgo, no x/sys). +package syspoll + +// Poll event bits from ; the values are identical on Linux, FreeBSD +// and macOS. +const ( + In int16 = 0x0001 + Out int16 = 0x0004 + Err int16 = 0x0008 + Hup int16 = 0x0010 + Nval int16 = 0x0020 +) + +// PollFd matches struct pollfd from . +type PollFd struct { + Fd int32 + Events int16 + Revents int16 +} diff --git a/src/go/pkg/netipc/protocol/apps_lookup.go b/src/go/pkg/netipc/protocol/apps_lookup.go index 9e54230c..7f37cea2 100644 --- a/src/go/pkg/netipc/protocol/apps_lookup.go +++ b/src/go/pkg/netipc/protocol/apps_lookup.go @@ -170,40 +170,51 @@ func appsLookupRequestLayoutForCount(count int) (packedStart, total int, ok bool } func DecodeAppsLookupRequest(buf []byte) (*AppsLookupRequestView, error) { + view := &AppsLookupRequestView{} + if err := decodeAppsLookupRequestInto(buf, view); err != nil { + return nil, err + } + return view, nil +} + +// decodeAppsLookupRequestInto decodes into a caller-owned view, so the +// dispatch path can reuse one. +func decodeAppsLookupRequestInto(buf []byte, view *AppsLookupRequestView) error { if len(buf) < AppsLookupReqHdr { - return nil, ErrTruncated + return ErrTruncated } if ne.Uint16(buf[0:2]) != 1 || ne.Uint16(buf[2:4]) != 0 || ne.Uint32(buf[8:12]) != 0 || ne.Uint32(buf[12:16]) != 0 { - return nil, ErrBadLayout + return ErrBadLayout } itemCount := ne.Uint32(buf[4:8]) dirSize64 := uint64(itemCount) * uint64(LookupDirEntrySize) dirEnd, ok := checkedInt(uint64(AppsLookupReqHdr) + dirSize64) if !ok { - return nil, ErrBadItemCount + return ErrBadItemCount } if dirEnd > len(buf) { - return nil, ErrTruncated + return ErrTruncated } if err := validateLookupDir(buf, AppsLookupReqHdr, itemCount, len(buf)-dirEnd, 0, AppsLookupKeySize); err != nil { - return nil, err + return err } for i := range itemCount { base := AppsLookupReqHdr + int(i)*LookupDirEntrySize off, _, err := lookupDirEntry(buf, base) if err != nil { - return nil, err + return err } key, err := lookupPayloadSlice(buf, dirEnd, off, AppsLookupKeySize) if err != nil { - return nil, err + return err } if ne.Uint32(key[4:8]) != 0 { - return nil, ErrBadLayout + return ErrBadLayout } } - return &AppsLookupRequestView{ItemCount: itemCount, packedStart: dirEnd, payload: buf}, nil + *view = AppsLookupRequestView{ItemCount: itemCount, packedStart: dirEnd, payload: buf} + return nil } func (v *AppsLookupRequestView) Item(index uint32) (uint32, error) { @@ -571,6 +582,12 @@ type AppsLookupBuilder struct { } func NewAppsLookupBuilder(buf []byte, maxItems uint32, generation uint64) *AppsLookupBuilder { + b := &AppsLookupBuilder{} + b.reset(buf, maxItems, generation) + return b +} + +func (b *AppsLookupBuilder) reset(buf []byte, maxItems uint32, generation uint64) { minRequired, ok := lookupBuilderDataOffset(AppsLookupRespHdr, maxItems) if !ok { panic("AppsLookupBuilder buffer too small") @@ -578,7 +595,7 @@ func NewAppsLookupBuilder(buf []byte, maxItems uint32, generation uint64) *AppsL if len(buf) < minRequired { panic("AppsLookupBuilder buffer too small") } - return &AppsLookupBuilder{buf: buf, generation: generation, maxItems: maxItems, dataOffset: minRequired} + *b = AppsLookupBuilder{buf: buf, generation: generation, maxItems: maxItems, dataOffset: minRequired} } func (b *AppsLookupBuilder) SetGeneration(generation uint64) { @@ -837,15 +854,18 @@ func (b *AppsLookupBuilder) ItemCount() uint32 { } func DispatchAppsLookup(req []byte, resp []byte, handler func(*AppsLookupRequestView, *AppsLookupBuilder) bool) (int, error) { - request, err := DecodeAppsLookupRequest(req) - if err != nil { + scratch := appsLookupScratchPool.Get().(*appsLookupScratch) + defer scratch.release() + request := &scratch.view + if err := decodeAppsLookupRequestInto(req, request); err != nil { return 0, err } minRequired, ok := lookupBuilderDataOffset(AppsLookupRespHdr, request.ItemCount) if !ok || len(resp) < minRequired { return 0, ErrOverflow } - builder := NewAppsLookupBuilder(resp, request.ItemCount, 0) + builder := &scratch.builder + builder.reset(resp, request.ItemCount, 0) if request.ItemCount > 0 { builder.payloadExceededFixed = appsLookupUnknownItemSize } diff --git a/src/go/pkg/netipc/protocol/cgroups_lookup.go b/src/go/pkg/netipc/protocol/cgroups_lookup.go index 9473bfb4..11ef1a25 100644 --- a/src/go/pkg/netipc/protocol/cgroups_lookup.go +++ b/src/go/pkg/netipc/protocol/cgroups_lookup.go @@ -166,39 +166,49 @@ func cgroupsLookupRequestPackedStartForCount(count int) (int, bool) { } func DecodeCgroupsLookupRequest(buf []byte) (*CgroupsLookupRequestView, error) { + view := &CgroupsLookupRequestView{} + if err := decodeCgroupsLookupRequestInto(buf, view); err != nil { + return nil, err + } + return view, nil +} + +// decodeCgroupsLookupRequestInto decodes into a caller-owned view, so the +// dispatch path can reuse one. +func decodeCgroupsLookupRequestInto(buf []byte, view *CgroupsLookupRequestView) error { if len(buf) < CgroupsLookupReqHdr { - return nil, ErrTruncated + return ErrTruncated } if ne.Uint16(buf[0:2]) != 1 || ne.Uint16(buf[2:4]) != 0 || ne.Uint32(buf[8:12]) != 0 || ne.Uint32(buf[12:16]) != 0 { - return nil, ErrBadLayout + return ErrBadLayout } itemCount := ne.Uint32(buf[4:8]) dirSize64 := uint64(itemCount) * uint64(LookupDirEntrySize) dirEnd, ok := checkedInt(uint64(CgroupsLookupReqHdr) + dirSize64) if !ok { - return nil, ErrBadItemCount + return ErrBadItemCount } if dirEnd > len(buf) { - return nil, ErrTruncated + return ErrTruncated } if err := validateLookupDir(buf, CgroupsLookupReqHdr, itemCount, len(buf)-dirEnd, 2, -1); err != nil { - return nil, err + return err } - view := &CgroupsLookupRequestView{ItemCount: itemCount, packedStart: dirEnd, payload: buf} + *view = CgroupsLookupRequestView{ItemCount: itemCount, packedStart: dirEnd, payload: buf} for i := range itemCount { keyWithNul, key, err := view.itemBytes(i) if err != nil { - return nil, err + return err } if keyWithNul[len(keyWithNul)-1] != 0 { - return nil, ErrMissingNul + return ErrMissingNul } if bytes.IndexByte(key, 0) >= 0 { - return nil, ErrBadLayout + return ErrBadLayout } } - return view, nil + return nil } func (v *CgroupsLookupRequestView) Item(index uint32) (CStringView, error) { @@ -507,6 +517,12 @@ type CgroupsLookupBuilder struct { } func NewCgroupsLookupBuilder(buf []byte, maxItems uint32, generation uint64) *CgroupsLookupBuilder { + b := &CgroupsLookupBuilder{} + b.reset(buf, maxItems, generation) + return b +} + +func (b *CgroupsLookupBuilder) reset(buf []byte, maxItems uint32, generation uint64) { minRequired, ok := lookupBuilderDataOffset(CgroupsLookupRespHdr, maxItems) if !ok { panic("CgroupsLookupBuilder buffer too small") @@ -514,7 +530,7 @@ func NewCgroupsLookupBuilder(buf []byte, maxItems uint32, generation uint64) *Cg if len(buf) < minRequired { panic("CgroupsLookupBuilder buffer too small") } - return &CgroupsLookupBuilder{buf: buf, generation: generation, maxItems: maxItems, dataOffset: minRequired} + *b = CgroupsLookupBuilder{buf: buf, generation: generation, maxItems: maxItems, dataOffset: minRequired} } func (b *CgroupsLookupBuilder) SetGeneration(generation uint64) { @@ -777,17 +793,21 @@ func (b *CgroupsLookupBuilder) ItemCount() uint32 { } func DispatchCgroupsLookup(req []byte, resp []byte, handler func(*CgroupsLookupRequestView, *CgroupsLookupBuilder) bool) (int, error) { - request, err := DecodeCgroupsLookupRequest(req) - if err != nil { + scratch := cgroupsLookupScratchPool.Get().(*cgroupsLookupScratch) + defer scratch.release() + request := &scratch.view + if err := decodeCgroupsLookupRequestInto(req, request); err != nil { return 0, err } minRequired, ok := lookupBuilderDataOffset(CgroupsLookupRespHdr, request.ItemCount) if !ok || len(resp) < minRequired { return 0, ErrOverflow } - builder := NewCgroupsLookupBuilder(resp, request.ItemCount, 0) + builder := &scratch.builder + builder.reset(resp, request.ItemCount, 0) if request.ItemCount > 0 { - suffixBytes, ok := makePayloadExceededSuffixBytes(request.ItemCount) + suffixBytes, ok := reusePayloadExceededSuffixBytes(scratch.suffix, request.ItemCount) + scratch.suffix = suffixBytes if !ok { return 0, ErrOverflow } diff --git a/src/go/pkg/netipc/protocol/cgroups_snapshot.go b/src/go/pkg/netipc/protocol/cgroups_snapshot.go index cc17b824..31a8491b 100644 --- a/src/go/pkg/netipc/protocol/cgroups_snapshot.go +++ b/src/go/pkg/netipc/protocol/cgroups_snapshot.go @@ -355,18 +355,26 @@ type CgroupsBuilder struct { // NewCgroupsBuilder initializes a cgroups response builder. buf must be // caller-owned and large enough for the expected snapshot. func NewCgroupsBuilder(buf []byte, maxItems uint32, systemdEnabled uint32, generation uint64) *CgroupsBuilder { + b := &CgroupsBuilder{} + b.Reset(buf, maxItems, systemdEnabled, generation) + return b +} + +// Reset reinitializes a builder against a caller-owned buffer, as +// NewCgroupsBuilder does, so hot paths can reuse one builder instead of +// allocating one per response. It panics on the same too-small buffer. +func (b *CgroupsBuilder) Reset(buf []byte, maxItems uint32, systemdEnabled uint32, generation uint64) { minRequired, ok := CgroupsBuilderMinBytes(maxItems) if !ok || len(buf) < minRequired { panic(fmt.Sprintf("CgroupsBuilder buffer too small: need at least %d bytes, got %d", minRequired, len(buf))) } - dataOffset := minRequired - return &CgroupsBuilder{ + *b = CgroupsBuilder{ buf: buf, systemdEnabled: systemdEnabled, generation: generation, maxItems: maxItems, - dataOffset: dataOffset, + dataOffset: minRequired, } } diff --git a/src/go/pkg/netipc/protocol/codec_edge_test.go b/src/go/pkg/netipc/protocol/codec_edge_test.go index 539a549e..02bf90f2 100644 --- a/src/go/pkg/netipc/protocol/codec_edge_test.go +++ b/src/go/pkg/netipc/protocol/codec_edge_test.go @@ -544,7 +544,7 @@ func TestStringReverseRoundtrip(t *testing.T) { if err != nil { t.Fatalf("decode: %v", err) } - if view.Str != s { + if string(view.Str) != s { t.Fatalf("expected %q, got %q", s, view.Str) } if view.StrLen != uint32(len(s)) { @@ -563,7 +563,7 @@ func TestStringReverseEncodeEmpty(t *testing.T) { if err != nil { t.Fatalf("decode: %v", err) } - if view.Str != "" { + if len(view.Str) != 0 { t.Fatalf("expected empty, got %q", view.Str) } } @@ -630,9 +630,9 @@ func TestDispatchStringReverseSuccess(t *testing.T) { StringReverseEncode(input, reqBuf) respBuf := make([]byte, 128) - n, ok := DispatchStringReverse(reqBuf, respBuf, func(s string) (string, bool) { - // Reverse the string - b := []byte(s) + n, ok := DispatchStringReverse(reqBuf, respBuf, func(s []byte) (string, bool) { + // Reverse a copy: s borrows the request buffer. + b := append([]byte(nil), s...) for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 { b[i], b[j] = b[j], b[i] } @@ -649,14 +649,14 @@ func TestDispatchStringReverseSuccess(t *testing.T) { if err != nil { t.Fatalf("decode: %v", err) } - if view.Str != "olleh" { + if string(view.Str) != "olleh" { t.Fatalf("expected 'olleh', got %q", view.Str) } } func TestDispatchStringReverseBadRequest(t *testing.T) { - _, ok := DispatchStringReverse([]byte{0}, make([]byte, 128), func(s string) (string, bool) { - return s, true + _, ok := DispatchStringReverse([]byte{0}, make([]byte, 128), func(s []byte) (string, bool) { + return string(s), true }) if ok { t.Fatal("expected failure for bad request") @@ -668,7 +668,7 @@ func TestDispatchStringReverseHandlerFails(t *testing.T) { reqBuf := make([]byte, StringReverseHdrSize+len(input)+1) StringReverseEncode(input, reqBuf) - _, ok := DispatchStringReverse(reqBuf, make([]byte, 128), func(s string) (string, bool) { + _, ok := DispatchStringReverse(reqBuf, make([]byte, 128), func([]byte) (string, bool) { return "", false }) if ok { @@ -682,7 +682,7 @@ func TestDispatchStringReverseRespTooSmall(t *testing.T) { StringReverseEncode(input, reqBuf) // Response buffer too small for result - n, ok := DispatchStringReverse(reqBuf, make([]byte, 2), func(s string) (string, bool) { + n, ok := DispatchStringReverse(reqBuf, make([]byte, 2), func([]byte) (string, bool) { return "very long response string", true }) if ok { diff --git a/src/go/pkg/netipc/protocol/dispatch_scratch.go b/src/go/pkg/netipc/protocol/dispatch_scratch.go new file mode 100644 index 00000000..4f222601 --- /dev/null +++ b/src/go/pkg/netipc/protocol/dispatch_scratch.go @@ -0,0 +1,36 @@ +package protocol + +import "sync" + +// The request view and builder a lookup dispatch hands to its handler +// escape through that call. Pooled scratch keeps them, and the cgroups +// payload-exceeded table, off the per-request heap; each dispatch owns its +// entry until it returns. + +type cgroupsLookupScratch struct { + view CgroupsLookupRequestView + builder CgroupsLookupBuilder + suffix []uint32 +} + +var cgroupsLookupScratchPool = sync.Pool{New: func() any { return new(cgroupsLookupScratch) }} + +// release drops the references to the caller's buffers, keeps the table. +func (s *cgroupsLookupScratch) release() { + s.view = CgroupsLookupRequestView{} + s.builder = CgroupsLookupBuilder{} + cgroupsLookupScratchPool.Put(s) +} + +type appsLookupScratch struct { + view AppsLookupRequestView + builder AppsLookupBuilder +} + +var appsLookupScratchPool = sync.Pool{New: func() any { return new(appsLookupScratch) }} + +func (s *appsLookupScratch) release() { + s.view = AppsLookupRequestView{} + s.builder = AppsLookupBuilder{} + appsLookupScratchPool.Put(s) +} diff --git a/src/go/pkg/netipc/protocol/frame.go b/src/go/pkg/netipc/protocol/frame.go index 922691c2..16d5a255 100644 --- a/src/go/pkg/netipc/protocol/frame.go +++ b/src/go/pkg/netipc/protocol/frame.go @@ -66,6 +66,11 @@ const ( // initialization config. MaxPayloadCap uint32 = 1024 * 1024 + // ServerHandshakeTimeoutMs bounds how long an accepted connection may + // take to send HELLO before the server drops it, so a stalled client + // cannot hold server resources. + ServerHandshakeTimeoutMs uint32 = 5000 + // Alignment for batch items and typed codec items. Alignment = 8 @@ -95,6 +100,9 @@ var ( ErrHandlerFailed = errors.New("dispatch handler failed") ErrTimeout = errors.New("synchronous call deadline expired") ErrAborted = errors.New("synchronous call aborted") + ErrDisconnected = errors.New("peer disconnected") + ErrNotReady = errors.New("client not ready") + ErrTransport = errors.New("transport I/O failed") ) // --------------------------------------------------------------------------- diff --git a/src/go/pkg/netipc/protocol/fuzz_test.go b/src/go/pkg/netipc/protocol/fuzz_test.go index 50eafea0..d1324e71 100644 --- a/src/go/pkg/netipc/protocol/fuzz_test.go +++ b/src/go/pkg/netipc/protocol/fuzz_test.go @@ -1,3 +1,7 @@ +//go:build netipc_extended + +// Fuzz targets run only from tests/run-extended-fuzz.sh. + package protocol import ( diff --git a/src/go/pkg/netipc/protocol/lookup_common.go b/src/go/pkg/netipc/protocol/lookup_common.go index b8cfb408..17bde70e 100644 --- a/src/go/pkg/netipc/protocol/lookup_common.go +++ b/src/go/pkg/netipc/protocol/lookup_common.go @@ -180,12 +180,17 @@ func buildPayloadExceededSuffixBytes(itemLens []int) ([]uint32, bool) { return suffixBytes, true } -func makePayloadExceededSuffixBytes(itemCount uint32) ([]uint32, bool) { +// reusePayloadExceededSuffixBytes returns a table of itemCount+1 entries, +// reusing dst when it is large enough. Callers write every entry. +func reusePayloadExceededSuffixBytes(dst []uint32, itemCount uint32) ([]uint32, bool) { count, ok := checkedInt(uint64(itemCount)) if !ok || count == maxIntValue() { - return nil, false + return dst, false + } + if cap(dst) < count+1 { + return make([]uint32, count+1), true } - return make([]uint32, count+1), true + return dst[:count+1], true } func finishPayloadExceededSuffixBytes(suffixBytes []uint32) bool { diff --git a/src/go/pkg/netipc/protocol/lookup_guard_test.go b/src/go/pkg/netipc/protocol/lookup_guard_test.go index fe28768b..6b1d46b7 100644 --- a/src/go/pkg/netipc/protocol/lookup_guard_test.go +++ b/src/go/pkg/netipc/protocol/lookup_guard_test.go @@ -2041,8 +2041,8 @@ func TestLookupThirtyTwoBitGuardCoverage(t *testing.T) { if payloadExceededSuffixFits(0, 0, make([]uint32, 2), overMaxInt, 1) { t.Fatal("payloadExceededSuffixFits should reject unrepresentable first index") } - if _, ok := makePayloadExceededSuffixBytes(overMaxInt); ok { - t.Fatal("makePayloadExceededSuffixBytes should reject unrepresentable item count") + if _, ok := reusePayloadExceededSuffixBytes(nil, overMaxInt); ok { + t.Fatal("reusePayloadExceededSuffixBytes should reject unrepresentable item count") } if _, ok := stringReverseEncodedLen(maxIntValue()); ok { t.Fatal("string reverse encoded length should reject int overflow") diff --git a/src/go/pkg/netipc/protocol/string_reverse.go b/src/go/pkg/netipc/protocol/string_reverse.go index eeae74f3..52d83761 100644 --- a/src/go/pkg/netipc/protocol/string_reverse.go +++ b/src/go/pkg/netipc/protocol/string_reverse.go @@ -9,8 +9,10 @@ package protocol const StringReverseHdrSize = 8 // StringReverseView is the decoded result of a STRING_REVERSE payload. +// Str borrows the decoded buffer (excluding the NUL), so it is valid only +// while that buffer is not reused. type StringReverseView struct { - Str string + Str []byte StrLen uint32 } @@ -48,7 +50,8 @@ func stringReverseEncodedLen(strLen int) (int, bool) { return total, ok } -// StringReverseDecode decodes a STRING_REVERSE payload from buf. +// StringReverseDecode decodes a STRING_REVERSE payload from buf without +// copying: Str borrows buf. func StringReverseDecode(buf []byte) (StringReverseView, error) { if len(buf) < StringReverseHdrSize { return StringReverseView{}, ErrTruncated @@ -77,13 +80,14 @@ func StringReverseDecode(buf []byte) (StringReverseView, error) { return StringReverseView{}, ErrMissingNul } return StringReverseView{ - Str: string(buf[strOffset:strEnd]), + Str: buf[strOffset:strEnd:strEnd], StrLen: strLength32, }, nil } // DispatchStringReverse decodes request, calls handler, encodes response. -func DispatchStringReverse(req []byte, resp []byte, handler func(string) (string, bool)) (int, bool) { +// The handler's argument borrows req and is valid only during the call. +func DispatchStringReverse(req []byte, resp []byte, handler func([]byte) (string, bool)) (int, bool) { view, err := StringReverseDecode(req) if err != nil { return 0, false diff --git a/src/go/pkg/netipc/service/apps_lookup/client_common.go b/src/go/pkg/netipc/service/apps_lookup/client_common.go index 485a2429..78d2708a 100644 --- a/src/go/pkg/netipc/service/apps_lookup/client_common.go +++ b/src/go/pkg/netipc/service/apps_lookup/client_common.go @@ -52,3 +52,7 @@ func NewServerWithWorkers(runDir, serviceName string, config ServerConfig, handl func (s *Server) Run() error { return s.inner.Run() } func (s *Server) Stop() { s.inner.Stop() } + +// Listening returns a channel closed once Run has bound the listener and +// clients can connect. +func (s *Server) Listening() <-chan struct{} { return s.inner.Listening() } diff --git a/src/go/pkg/netipc/service/apps_lookup/client_common_test.go b/src/go/pkg/netipc/service/apps_lookup/client_common_test.go index 10d88ffc..8de6ef4d 100644 --- a/src/go/pkg/netipc/service/apps_lookup/client_common_test.go +++ b/src/go/pkg/netipc/service/apps_lookup/client_common_test.go @@ -2,6 +2,8 @@ package apps_lookup import ( "fmt" + "os" + "runtime" "testing" "time" @@ -21,21 +23,22 @@ func appsLookupTestConfig() ClientConfig { } } -func waitAppsLookupReady(t *testing.T, client *Client) { +// waitAppsLookupReady waits until server listens, then connects once. +func waitAppsLookupReady(t *testing.T, server *Server, client *Client) { t.Helper() - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - client.Refresh() - if client.Ready() { - return - } - time.Sleep(10 * time.Millisecond) + select { + case <-server.Listening(): + case <-time.After(10 * time.Second): + t.Fatal("server not listening after 10s") + } + client.Refresh() + if !client.Ready() { + t.Fatalf("apps lookup client not ready after one refresh; status=%+v", client.Status()) } - t.Fatalf("apps lookup client did not become ready; status=%+v", client.Status()) } func TestAppsLookupPublicWrapper(t *testing.T) { - runDir := t.TempDir() + runDir := shortTempDir(t) service := fmt.Sprintf("go_apps_wrapper_%d", time.Now().UnixNano()) handler := Handler{Handle: func(req *protocol.AppsLookupRequestView, builder *protocol.AppsLookupBuilder) bool { for i := uint32(0); i < req.ItemCount; i++ { @@ -86,7 +89,7 @@ func TestAppsLookupPublicWrapper(t *testing.T) { client.SetCallTimeout(500) client.Abort() client.ClearAbort() - waitAppsLookupReady(t, client) + waitAppsLookupReady(t, server, client) if client.Status().State != StateReady { t.Fatalf("status after ready = %+v", client.Status()) } @@ -112,3 +115,19 @@ func TestAppsLookupPublicWrapper(t *testing.T) { t.Fatalf("apps timeout item = %+v err=%v", item, err) } } + +// shortTempDir is a per-test run directory. t.TempDir() follows $TMPDIR, +// which on macOS is long enough to push a socket path past the 104-byte +// sun_path limit. +func shortTempDir(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + return t.TempDir() + } + dir, err := os.MkdirTemp("/tmp", "nipc_go_") + if err != nil { + t.Fatalf("create temp dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir +} diff --git a/src/go/pkg/netipc/service/apps_lookup/types.go b/src/go/pkg/netipc/service/apps_lookup/types.go index 7f81c555..ab874770 100644 --- a/src/go/pkg/netipc/service/apps_lookup/types.go +++ b/src/go/pkg/netipc/service/apps_lookup/types.go @@ -26,6 +26,10 @@ type ClientConfig transportconfig.TypedConfig type ServerConfig transportconfig.TypedConfig +// HandlerFunc is the typed callback used by the apps-lookup service. +// The request borrows the receive buffer and the builder writes into the +// response buffer; both are valid only during the call, so copy what you +// keep. type HandlerFunc = func(*protocol.AppsLookupRequestView, *protocol.AppsLookupBuilder) bool type Handler struct { diff --git a/src/go/pkg/netipc/service/cgroups_lookup/client_common.go b/src/go/pkg/netipc/service/cgroups_lookup/client_common.go index efcef7e6..f71a26f2 100644 --- a/src/go/pkg/netipc/service/cgroups_lookup/client_common.go +++ b/src/go/pkg/netipc/service/cgroups_lookup/client_common.go @@ -52,3 +52,7 @@ func NewServerWithWorkers(runDir, serviceName string, config ServerConfig, handl func (s *Server) Run() error { return s.inner.Run() } func (s *Server) Stop() { s.inner.Stop() } + +// Listening returns a channel closed once Run has bound the listener and +// clients can connect. +func (s *Server) Listening() <-chan struct{} { return s.inner.Listening() } diff --git a/src/go/pkg/netipc/service/cgroups_lookup/client_common_test.go b/src/go/pkg/netipc/service/cgroups_lookup/client_common_test.go index a3cdc236..fbed9684 100644 --- a/src/go/pkg/netipc/service/cgroups_lookup/client_common_test.go +++ b/src/go/pkg/netipc/service/cgroups_lookup/client_common_test.go @@ -2,6 +2,8 @@ package cgroups_lookup import ( "fmt" + "os" + "runtime" "testing" "time" @@ -21,21 +23,22 @@ func cgroupsLookupTestConfig() ClientConfig { } } -func waitCgroupsLookupReady(t *testing.T, client *Client) { +// waitCgroupsLookupReady waits until server listens, then connects once. +func waitCgroupsLookupReady(t *testing.T, server *Server, client *Client) { t.Helper() - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - client.Refresh() - if client.Ready() { - return - } - time.Sleep(10 * time.Millisecond) + select { + case <-server.Listening(): + case <-time.After(10 * time.Second): + t.Fatal("server not listening after 10s") + } + client.Refresh() + if !client.Ready() { + t.Fatalf("cgroups lookup client not ready after one refresh; status=%+v", client.Status()) } - t.Fatalf("cgroups lookup client did not become ready; status=%+v", client.Status()) } func TestCgroupsLookupPublicWrapper(t *testing.T) { - runDir := t.TempDir() + runDir := shortTempDir(t) service := fmt.Sprintf("go_cgroups_wrapper_%d", time.Now().UnixNano()) handler := Handler{Handle: func(req *protocol.CgroupsLookupRequestView, builder *protocol.CgroupsLookupBuilder) bool { for i := uint32(0); i < req.ItemCount; i++ { @@ -80,7 +83,7 @@ func TestCgroupsLookupPublicWrapper(t *testing.T) { client.SetCallTimeout(500) client.Abort() client.ClearAbort() - waitCgroupsLookupReady(t, client) + waitCgroupsLookupReady(t, server, client) if client.Status().State != StateReady { t.Fatalf("status after ready = %+v", client.Status()) } @@ -106,3 +109,19 @@ func TestCgroupsLookupPublicWrapper(t *testing.T) { t.Fatalf("cgroups timeout item = %+v err=%v", item, err) } } + +// shortTempDir is a per-test run directory. t.TempDir() follows $TMPDIR, +// which on macOS is long enough to push a socket path past the 104-byte +// sun_path limit. +func shortTempDir(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + return t.TempDir() + } + dir, err := os.MkdirTemp("/tmp", "nipc_go_") + if err != nil { + t.Fatalf("create temp dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir +} diff --git a/src/go/pkg/netipc/service/cgroups_lookup/types.go b/src/go/pkg/netipc/service/cgroups_lookup/types.go index 427c688f..4d6c73cf 100644 --- a/src/go/pkg/netipc/service/cgroups_lookup/types.go +++ b/src/go/pkg/netipc/service/cgroups_lookup/types.go @@ -26,6 +26,10 @@ type ClientConfig transportconfig.TypedConfig type ServerConfig transportconfig.TypedConfig +// HandlerFunc is the typed callback used by the cgroups-lookup service. +// The request borrows the receive buffer and the builder writes into the +// response buffer; both are valid only during the call, so copy what you +// keep. type HandlerFunc = func(*protocol.CgroupsLookupRequestView, *protocol.CgroupsLookupBuilder) bool type Handler struct { diff --git a/src/go/pkg/netipc/service/cgroups_snapshot/cgroups_unix_test.go b/src/go/pkg/netipc/service/cgroups_snapshot/cgroups_unix_test.go index ff6f9548..63f4885a 100644 --- a/src/go/pkg/netipc/service/cgroups_snapshot/cgroups_unix_test.go +++ b/src/go/pkg/netipc/service/cgroups_snapshot/cgroups_unix_test.go @@ -21,7 +21,9 @@ const ( var unixServiceCounter atomic.Uint64 func uniqueUnixService(prefix string) string { - return fmt.Sprintf("%s_%d_%d_%d", prefix, os.Getpid(), unixServiceCounter.Add(1), time.Now().UnixNano()) + service := fmt.Sprintf("%s_%d_%d_%d", prefix, os.Getpid(), unixServiceCounter.Add(1), time.Now().UnixNano()) + trackTestLock(testRunDirUnix, service) + return service } func ensureUnixRunDir(t *testing.T) { @@ -84,6 +86,7 @@ func testUnixHandler() Handler { type unixTestServer struct { server *Server done chan struct{} + t *testing.T } func startUnixTestServer(t *testing.T, service string) *unixTestServer { @@ -98,28 +101,32 @@ func startUnixTestServer(t *testing.T, service string) *unixTestServer { _ = s.Run() }() - time.Sleep(50 * time.Millisecond) - return &unixTestServer{server: s, done: done} + select { + case <-s.Listening(): + case <-done: + t.Fatal("server Run returned before listening") + case <-time.After(10 * time.Second): + t.Fatal("server not listening after 10s") + } + return &unixTestServer{server: s, done: done, t: t} } func (ts *unixTestServer) stop() { ts.server.Stop() select { case <-ts.done: - case <-time.After(2 * time.Second): + case <-time.After(10 * time.Second): + ts.t.Error("server Run did not return within 10s of Stop") } } +// connectReadyUnix connects once: the server is already listening. func connectReadyUnix(t *testing.T, client *Client) { t.Helper() - for range 200 { - client.Refresh() - if client.Ready() { - return - } - time.Sleep(10 * time.Millisecond) - } - t.Fatal("client did not reach READY state") + client.Refresh() + if !client.Ready() { + t.Fatalf("client not READY after one refresh: %+v", client.Status()) + } } func TestSnapshotRoundTripUnix(t *testing.T) { @@ -167,16 +174,8 @@ func TestCacheRoundTripUnix(t *testing.T) { cache.Abort() cache.ClearAbort() - var updated bool - for range 200 { - if cache.Refresh() { - updated = true - break - } - time.Sleep(10 * time.Millisecond) - } - if !updated { - t.Fatal("Refresh never succeeded") + if !cache.Refresh() { + t.Fatal("first Refresh against a listening server must succeed") } if !cache.Ready() { t.Fatal("cache not ready after refresh") @@ -207,8 +206,8 @@ func TestClientNotReadyReturnsErrorUnix(t *testing.T) { client := NewClient(testRunDirUnix, service, testUnixClientConfig()) defer client.Close() - if _, err := client.CallSnapshot(); err != protocol.ErrBadLayout { - t.Fatalf("CallSnapshot err = %v, want %v", err, protocol.ErrBadLayout) + if _, err := client.CallSnapshot(); err != protocol.ErrNotReady { + t.Fatalf("CallSnapshot err = %v, want %v", err, protocol.ErrNotReady) } } diff --git a/src/go/pkg/netipc/service/cgroups_snapshot/cgroups_windows_test.go b/src/go/pkg/netipc/service/cgroups_snapshot/cgroups_windows_test.go index 28c35038..c9d503cf 100644 --- a/src/go/pkg/netipc/service/cgroups_snapshot/cgroups_windows_test.go +++ b/src/go/pkg/netipc/service/cgroups_snapshot/cgroups_windows_test.go @@ -81,6 +81,7 @@ func testWinHandler() Handler { type winTestServer struct { server *Server done chan struct{} + t *testing.T } func startWinTestServer(t *testing.T, service string) *winTestServer { @@ -92,28 +93,32 @@ func startWinTestServer(t *testing.T, service string) *winTestServer { defer close(done) _ = s.Run() }() - time.Sleep(200 * time.Millisecond) - return &winTestServer{server: s, done: done} + select { + case <-s.Listening(): + case <-done: + t.Fatal("server Run returned before listening") + case <-time.After(10 * time.Second): + t.Fatal("server not listening after 10s") + } + return &winTestServer{server: s, done: done, t: t} } func (ts *winTestServer) stop() { ts.server.Stop() select { case <-ts.done: - case <-time.After(2 * time.Second): + case <-time.After(10 * time.Second): + ts.t.Error("server Run did not return within 10s of Stop") } } +// connectReadyWin connects once: the server is already listening. func connectReadyWin(t *testing.T, client *Client) { t.Helper() - for i := 0; i < 200; i++ { - client.Refresh() - if client.Ready() { - return - } - time.Sleep(10 * time.Millisecond) - } - t.Fatal("client did not reach READY state") + client.Refresh() + if !client.Ready() { + t.Fatalf("client not READY after one refresh: %+v", client.Status()) + } } func TestSnapshotRoundTripWindows(t *testing.T) { @@ -150,16 +155,8 @@ func TestCacheRoundTripWindows(t *testing.T) { t.Fatalf("unexpected initial cache status: %+v", status0) } - var updated bool - for i := 0; i < 200; i++ { - if cache.Refresh() { - updated = true - break - } - time.Sleep(10 * time.Millisecond) - } - if !updated { - t.Fatal("Refresh never succeeded") + if !cache.Refresh() { + t.Fatal("first Refresh against a listening server must succeed") } if !cache.Ready() { t.Fatal("cache not ready after refresh") @@ -191,8 +188,8 @@ func TestClientNotReadyReturnsErrorWindows(t *testing.T) { t.Fatalf("unexpected initial client status: %+v", status) } - if _, err := client.CallSnapshot(); err != protocol.ErrBadLayout { - t.Fatalf("CallSnapshot err = %v, want %v", err, protocol.ErrBadLayout) + if _, err := client.CallSnapshot(); err != protocol.ErrNotReady { + t.Fatalf("CallSnapshot err = %v, want %v", err, protocol.ErrNotReady) } status = client.Status() diff --git a/src/go/pkg/netipc/service/cgroups_snapshot/client_common.go b/src/go/pkg/netipc/service/cgroups_snapshot/client_common.go index e1eb3e47..6be888e8 100644 --- a/src/go/pkg/netipc/service/cgroups_snapshot/client_common.go +++ b/src/go/pkg/netipc/service/cgroups_snapshot/client_common.go @@ -53,12 +53,14 @@ func (c *Client) ClearAbort() { } // CallSnapshot performs a blocking typed cgroups snapshot call. +// The returned view is valid until the next typed call on this client. func (c *Client) CallSnapshot() (*protocol.CgroupsResponseView, error) { return c.CallSnapshotWithTimeout(0) } // CallSnapshotWithTimeout performs a blocking typed call with an explicit -// timeout. A zero timeout uses the client's context-level default. +// timeout. A zero timeout uses the client's context-level default. The +// returned view is valid until the next typed call on this client. func (c *Client) CallSnapshotWithTimeout(timeoutMs uint32) (*protocol.CgroupsResponseView, error) { return c.inner.CallSnapshotWithTimeout(timeoutMs) } @@ -110,3 +112,9 @@ func (s *Server) Run() error { func (s *Server) Stop() { s.inner.Stop() } + +// Listening returns a channel closed once Run has bound the listener and +// clients can connect. +func (s *Server) Listening() <-chan struct{} { + return s.inner.Listening() +} diff --git a/src/go/pkg/netipc/service/cgroups_snapshot/control_windows_test.go b/src/go/pkg/netipc/service/cgroups_snapshot/control_windows_test.go index 9d3ed71f..8383ee40 100644 --- a/src/go/pkg/netipc/service/cgroups_snapshot/control_windows_test.go +++ b/src/go/pkg/netipc/service/cgroups_snapshot/control_windows_test.go @@ -25,8 +25,8 @@ func TestClientAndCacheControlMethodsWindows(t *testing.T) { if status.State != StateNotFound { t.Fatalf("client state = %v, want StateNotFound", status.State) } - if _, err := client.CallSnapshotWithTimeout(1); err != protocol.ErrBadLayout { - t.Fatalf("CallSnapshotWithTimeout without READY = %v, want ErrBadLayout", err) + if _, err := client.CallSnapshotWithTimeout(1); err != protocol.ErrNotReady { + t.Fatalf("CallSnapshotWithTimeout without READY = %v, want ErrNotReady", err) } cache := NewCache(testWinRunDir, service, testWinClientConfig()) diff --git a/src/go/pkg/netipc/service/cgroups_snapshot/testlock_unix_test.go b/src/go/pkg/netipc/service/cgroups_snapshot/testlock_unix_test.go new file mode 100644 index 00000000..5d47c11d --- /dev/null +++ b/src/go/pkg/netipc/service/cgroups_snapshot/testlock_unix_test.go @@ -0,0 +1,35 @@ +//go:build unix + +package cgroups_snapshot + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +// Endpoint lock files stay after a server closes, by design +// (docs/level1-posix-uds.md, "Endpoint lock"). Test service names are +// unique per run, so without this every run would leave new locks behind: +// the package removes the locks it named once all its tests have finished. +var testLocks struct { + sync.Mutex + paths []string +} + +func trackTestLock(dir, service string) { + testLocks.Lock() + testLocks.paths = append(testLocks.paths, filepath.Join(dir, service+".lock")) + testLocks.Unlock() +} + +func TestMain(m *testing.M) { + code := m.Run() + testLocks.Lock() + for _, p := range testLocks.paths { + _ = os.Remove(p) + } + testLocks.Unlock() + os.Exit(code) +} diff --git a/src/go/pkg/netipc/service/cgroups_snapshot/types.go b/src/go/pkg/netipc/service/cgroups_snapshot/types.go index 16f89432..3dc5398e 100644 --- a/src/go/pkg/netipc/service/cgroups_snapshot/types.go +++ b/src/go/pkg/netipc/service/cgroups_snapshot/types.go @@ -41,6 +41,8 @@ type ClientConfig transportconfig.TypedConfig type ServerConfig transportconfig.TypedConfig // SnapshotHandler is the typed callback used by the cgroups-snapshot service. +// The request is decoded into scalar fields, and the builder writes into the +// response buffer, which is valid only during the call. type SnapshotHandler = func(*protocol.CgroupsRequest, *protocol.CgroupsBuilder) bool // Handler defines the public typed callback surface for the cgroups-snapshot diff --git a/src/go/pkg/netipc/service/raw/alloc_calls_test.go b/src/go/pkg/netipc/service/raw/alloc_calls_test.go new file mode 100644 index 00000000..d0cbc3ed --- /dev/null +++ b/src/go/pkg/netipc/service/raw/alloc_calls_test.go @@ -0,0 +1,134 @@ +package raw + +import ( + "errors" + "fmt" + "runtime" + "testing" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" +) + +// allocCallCase is one typed call checked for per-call allocations. Its +// handler must not allocate either, so the server side stays clean. +type allocCallCase struct { + name string + method uint16 + dispatch DispatchHandler + call func(*Client) error +} + +// Package-level, unlike testCgroupsHandler's items, so the handler does not +// allocate per request. +var allocCgroupsItems = [...]struct { + hash, options, enabled uint32 + name, path []byte +}{ + {1001, 0, 1, []byte("docker-abc123"), []byte("/sys/fs/cgroup/docker/abc123")}, + {2002, 0, 1, []byte("k8s-pod-xyz"), []byte("/sys/fs/cgroup/kubepods/xyz")}, + {3003, 0, 0, []byte("systemd-user"), []byte("/sys/fs/cgroup/user.slice/user-1000")}, +} + +func allocCgroupsHandler(_ *protocol.CgroupsRequest, builder *protocol.CgroupsBuilder) bool { + builder.SetHeader(1, 42) + for i := range allocCgroupsItems { + item := &allocCgroupsItems[i] + if builder.Add(item.hash, item.options, item.enabled, item.name, item.path) != nil { + return false + } + } + return true +} + +var errAllocCallResult = errors.New("unexpected call result") + +func allocCallCases() []allocCallCase { + var val uint64 + return []allocCallCase{ + { + name: "increment", + method: protocol.MethodIncrement, + dispatch: IncrementDispatch(func(v uint64) (uint64, bool) { + return v + 1, true + }), + call: func(c *Client) error { + got, err := c.CallIncrement(val) + if err != nil { + return err + } + if got != val+1 { + return errAllocCallResult + } + val = got + return nil + }, + }, + { + name: "snapshot", + method: protocol.MethodCgroupsSnapshot, + dispatch: SnapshotDispatch(allocCgroupsHandler, uint32(len(allocCgroupsItems))), + call: func(c *Client) error { + view, err := c.CallSnapshot() + if err != nil { + return err + } + if view.ItemCount != uint32(len(allocCgroupsItems)) || view.Generation != 42 { + return errAllocCallResult + } + return nil + }, + }, + { + name: "string-reverse", + method: protocol.MethodStringReverse, + // Comparing a converted []byte with a constant does not allocate. + dispatch: StringReverseDispatch(func(s []byte) (string, bool) { + if string(s) != "hello" { + return "", false + } + return "olleh", true + }), + call: func(c *Client) error { + view, err := c.CallStringReverse("hello") + if err != nil { + return err + } + if string(view.Str) != "olleh" { + return errAllocCallResult + } + return nil + }, + }, + } +} + +func allocCallError(name string, err error) error { + return fmt.Errorf("%s call: %w", name, err) +} + +// allocCallRuns calls per case; the runtime's own occasional allocations +// (starting an OS thread) stay below allocCallRuns/100 in total. +const allocCallRuns = 2000 + +// callAllocs returns the heap allocations, client and in-process server +// together, of allocCallRuns calls after one warm-up call. Unlike +// testing.AllocsPerRun it does not round the per-call average down, so an +// allocation made on only some calls still counts. +func callAllocs(f func()) uint64 { + defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(1)) + f() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + for range allocCallRuns { + f() + } + runtime.ReadMemStats(&after) + return after.Mallocs - before.Mallocs +} + +func skipAllocTestsUnderRace(t *testing.T) { + t.Helper() + if raceEnabled { + t.Skip("sync.Pool drops items under the race detector") + } +} diff --git a/src/go/pkg/netipc/service/raw/alloc_shm_posix_test.go b/src/go/pkg/netipc/service/raw/alloc_shm_posix_test.go new file mode 100644 index 00000000..c086729e --- /dev/null +++ b/src/go/pkg/netipc/service/raw/alloc_shm_posix_test.go @@ -0,0 +1,9 @@ +//go:build linux || (freebsd && (amd64 || 386)) + +package raw + +import "testing" + +func TestCallNoAllocsShm(t *testing.T) { + assertCallsDoNotAllocate(t, "go_alloc_shm", testUnixShmServerConfig(), testUnixShmClientConfig()) +} diff --git a/src/go/pkg/netipc/service/raw/alloc_test.go b/src/go/pkg/netipc/service/raw/alloc_test.go new file mode 100644 index 00000000..4555e2c6 --- /dev/null +++ b/src/go/pkg/netipc/service/raw/alloc_test.go @@ -0,0 +1,153 @@ +package raw + +import ( + "testing" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" +) + +// Steady-state requests on an established session must not allocate: per +// request garbage drives GC cycles that stall the server and, through the +// peer's futex sleeps, cost far more than the allocations themselves. + +const allocTestBufSize = 64 * 1024 + +// sessionAllocs runs one server session over requests copies of payload and +// returns the allocations of the whole session. +func sessionAllocs(t *testing.T, s *Server, code uint16, payload []byte, requests int) float64 { + t.Helper() + sent := 0 + served := 0 + ops := serverSessionOps{ + maxRequestPayloadBytes: allocTestBufSize, + maxResponsePayloadBytes: allocTestBufSize, + receive: func([]byte) (protocol.Header, []byte, serverReceiveAction) { + if sent == requests { + return protocol.Header{}, nil, serverReceiveStop + } + sent++ + return protocol.Header{ + Kind: protocol.KindRequest, + Code: code, + ItemCount: 1, + MessageID: uint64(sent), // #nosec G115 -- sent is a small positive test count. + PayloadLen: uint32(len(payload)), // #nosec G115 -- test payloads are small. + }, payload, serverReceiveOK + }, + send: func(hdr *protocol.Header, _ []byte, _ *[]byte) error { + if hdr.TransportStatus != protocol.StatusOK { + t.Errorf("response status %d", hdr.TransportStatus) + } + served++ + return nil + }, + close: func() {}, + } + s.running.Store(true) + allocs := testing.AllocsPerRun(5, func() { + sent = 0 + s.handleServerSession(ops) + }) + if served == 0 { + t.Fatal("no request served") + } + return allocs +} + +// assertNoPerRequestAllocs compares a 1-request session with a 2000-request +// one. Per-session buffers are the same in both, so a per-request +// allocation shows up as at least 1999 more; the margin absorbs the Go +// runtime's own occasional allocations (starting an OS thread), which are +// not per request. +func assertNoPerRequestAllocs(t *testing.T, s *Server, code uint16, payload []byte) { + t.Helper() + if raceEnabled { + t.Skip("sync.Pool drops items under the race detector") + } + const requests = 2000 + one := sessionAllocs(t, s, code, payload, 1) + many := sessionAllocs(t, s, code, payload, requests) + if many-one >= requests/100 { + t.Fatalf("allocations: %.0f for 1 request, %.0f for %d requests", one, many, requests) + } +} + +func TestServerNoPerRequestAllocsIncrement(t *testing.T) { + s := &Server{ + expectedMethodCode: protocol.MethodIncrement, + handler: IncrementDispatch(func(v uint64) (uint64, bool) { return v + 1, true }), + } + var req [protocol.IncrementPayloadSize]byte + protocol.IncrementEncode(41, req[:]) + assertNoPerRequestAllocs(t, s, protocol.MethodIncrement, req[:]) +} + +func TestServerNoPerRequestAllocsSnapshot(t *testing.T) { + name := []byte("cgroup-1") + path := []byte("/sys/fs/cgroup/bench/cg-1") + s := &Server{ + expectedMethodCode: protocol.MethodCgroupsSnapshot, + handler: SnapshotDispatch(func(_ *protocol.CgroupsRequest, b *protocol.CgroupsBuilder) bool { + b.SetHeader(1, 7) + for i := range uint32(16) { + if b.Add(1000+i, 0, i%2, name, path) != nil { + return false + } + } + return true + }, 16), + } + req := protocol.CgroupsRequest{LayoutVersion: 1} + var buf [4]byte + req.Encode(buf[:]) + assertNoPerRequestAllocs(t, s, protocol.MethodCgroupsSnapshot, buf[:]) +} + +func TestServerNoPerRequestAllocsCgroupsLookup(t *testing.T) { + name := []byte("cg") + s := &Server{ + expectedMethodCode: protocol.MethodCgroupsLookup, + handler: CgroupsLookupDispatch(func(req *protocol.CgroupsLookupRequestView, b *protocol.CgroupsLookupBuilder) bool { + for i := range req.ItemCount { + if b.AddRequestItem(req, i, protocol.CgroupLookupKnown, 0, name, nil) != nil { + return false + } + } + return true + }), + } + paths := [][]byte{[]byte("/a"), []byte("/b/c"), []byte("/d")} + buf := make([]byte, 256) + n, err := protocol.EncodeCgroupsLookupRequest(paths, buf) + if err != nil { + t.Fatalf("encode: %v", err) + } + assertNoPerRequestAllocs(t, s, protocol.MethodCgroupsLookup, buf[:n]) +} + +func TestServerNoPerRequestAllocsAppsLookup(t *testing.T) { + comm := []byte("proc") + cgPath := []byte("/sys/fs/cgroup/app") + cgName := []byte("app") + s := &Server{ + expectedMethodCode: protocol.MethodAppsLookup, + handler: AppsLookupDispatch(func(req *protocol.AppsLookupRequestView, b *protocol.AppsLookupBuilder) bool { + for i := range req.ItemCount { + pid, err := req.Item(i) + if err != nil { + return false + } + if b.Add(protocol.PidLookupKnown, protocol.AppsCgroupKnown, 0, pid, 0, 0, 1, comm, cgPath, cgName, nil) != nil { + return false + } + } + return true + }), + } + buf := make([]byte, 256) + n, err := protocol.EncodeAppsLookupRequest([]uint32{10, 20, 30}, buf) + if err != nil { + t.Fatalf("encode: %v", err) + } + assertNoPerRequestAllocs(t, s, protocol.MethodAppsLookup, buf[:n]) +} diff --git a/src/go/pkg/netipc/service/raw/alloc_unix_test.go b/src/go/pkg/netipc/service/raw/alloc_unix_test.go new file mode 100644 index 00000000..124eb206 --- /dev/null +++ b/src/go/pkg/netipc/service/raw/alloc_unix_test.go @@ -0,0 +1,51 @@ +//go:build unix + +package raw + +import ( + "strings" + "testing" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/posix" +) + +// Typed calls on an established session, client and server in this +// process, must not allocate on either side. +func assertCallsDoNotAllocate(t *testing.T, svc string, scfg posix.ServerConfig, ccfg posix.ClientConfig) { + t.Helper() + skipAllocTestsUnderRace(t) + for _, tc := range allocCallCases() { + t.Run(tc.name, func(t *testing.T) { + name := svc + "_" + strings.ReplaceAll(tc.name, "-", "_") + ensureRunDir() + cleanupAll(name) + trackTestLock(testRunDir, name) + ts := startTestServerUnixWithConfig(name, scfg, tc.method, tc.dispatch) + defer ts.stop() + + client := newClient(testRunDir, name, ccfg, tc.method) + defer client.Close() + client.Refresh() + if !client.Ready() { + t.Fatal("client not ready") + } + + var callErr error + allocs := callAllocs(func() { + if err := tc.call(client); err != nil && callErr == nil { + callErr = allocCallError(tc.name, err) + } + }) + if callErr != nil { + t.Fatal(callErr) + } + if allocs >= allocCallRuns/100 { + t.Fatalf("%d allocations in %d calls", allocs, allocCallRuns) + } + }) + } +} + +func TestCallNoAllocsBaseline(t *testing.T) { + assertCallsDoNotAllocate(t, "go_alloc_uds", testServerConfig(), testClientConfig()) +} diff --git a/src/go/pkg/netipc/service/raw/alloc_windows_test.go b/src/go/pkg/netipc/service/raw/alloc_windows_test.go new file mode 100644 index 00000000..3e056937 --- /dev/null +++ b/src/go/pkg/netipc/service/raw/alloc_windows_test.go @@ -0,0 +1,49 @@ +//go:build windows + +package raw + +import ( + "strings" + "testing" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/windows" +) + +// Typed calls on an established session, client and server in this +// process, must not allocate on either side. +func assertWinCallsDoNotAllocate(t *testing.T, svc string, scfg windows.ServerConfig, ccfg windows.ClientConfig) { + t.Helper() + skipAllocTestsUnderRace(t) + for _, tc := range allocCallCases() { + t.Run(tc.name, func(t *testing.T) { + name := svc + "_" + strings.ReplaceAll(tc.name, "-", "_") + ts := startTestServerWinWithConfig(name, scfg, tc.method, tc.dispatch) + defer ts.stop() + + client := newClient(winTestRunDir, name, ccfg, tc.method) + defer client.Close() + waitWinClientReady(t, client) + + var callErr error + allocs := callAllocs(func() { + if err := tc.call(client); err != nil && callErr == nil { + callErr = allocCallError(tc.name, err) + } + }) + if callErr != nil { + t.Fatal(callErr) + } + if allocs >= allocCallRuns/100 { + t.Fatalf("%d allocations in %d calls", allocs, allocCallRuns) + } + }) + } +} + +func TestWinCallNoAllocsBaseline(t *testing.T) { + assertWinCallsDoNotAllocate(t, "go_win_alloc_np", testWinServerConfig(), testWinClientConfig()) +} + +func TestWinCallNoAllocsShm(t *testing.T) { + assertWinCallsDoNotAllocate(t, "go_win_alloc_shm", testWinShmServerConfig(), testWinShmClientConfig()) +} diff --git a/src/go/pkg/netipc/service/raw/apps_lookup.go b/src/go/pkg/netipc/service/raw/apps_lookup.go index 144098c1..26f76dd7 100644 --- a/src/go/pkg/netipc/service/raw/apps_lookup.go +++ b/src/go/pkg/netipc/service/raw/apps_lookup.go @@ -6,7 +6,9 @@ import ( "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" ) -// AppsLookupHandler serves a single APPS_LOOKUP service kind. +// AppsLookupHandler serves a single APPS_LOOKUP service kind. The request +// borrows the receive buffer and the builder writes into the response +// buffer; both are valid only during the call, so copy what you keep. type AppsLookupHandler func(*protocol.AppsLookupRequestView, *protocol.AppsLookupBuilder) bool func appsLookupRequestSize(pids []uint32) (int, error) { @@ -84,10 +86,13 @@ func (c *Client) CallAppsLookupWithTimeout(pids []uint32, timeoutMs uint32) (*pr reqCount, reqSize, err := appsLookupNextRequest(pids[start:], c.sessionMaxRequestPayloadBytes()) if err != nil { oneItemSize, serr := appsLookupRequestSize([]uint32{0}) - if errors.Is(err, protocol.ErrOverflow) && start < len(pids) && serr == nil { - if c.ensureLookupRequestCapacity(oneItemSize) == nil { - continue + if errors.Is(err, protocol.ErrOverflow) && start < len(pids) && serr == nil && + c.lookupRequestCapacityCanReconnect(oneItemSize) { + // The reconnect's own error ends the lookup, as in C and Rust. + if cerr := c.ensureLookupRequestCapacity(oneItemSize, deadline); cerr != nil { + return nil, cerr } + continue } return nil, err } @@ -107,7 +112,7 @@ func (c *Client) CallAppsLookupWithTimeout(pids []uint32, timeoutMs uint32) (*pr return nil, protocol.ErrOverflow } var payload []byte - if err := c.callWithRetry(func() error { + if err := c.callWithRetry(remainingTimeout, func() error { _, callPayload, rerr := c.doRawCallWithTimeout(protocol.MethodAppsLookup, reqBuf[:reqLen], remainingTimeout) if rerr != nil { return rerr diff --git a/src/go/pkg/netipc/service/raw/cache_test.go b/src/go/pkg/netipc/service/raw/cache_test.go index 540db95e..a8d8c2fd 100644 --- a/src/go/pkg/netipc/service/raw/cache_test.go +++ b/src/go/pkg/netipc/service/raw/cache_test.go @@ -123,7 +123,6 @@ func TestCacheRefreshFailurePreserves(t *testing.T) { // Kill server ts.stop() cleanupAll(svc) - time.Sleep(50 * time.Millisecond) // Refresh fails, old cache preserved updated := cache.Refresh() @@ -275,7 +274,6 @@ func TestCacheReconnectRebuilds(t *testing.T) { // Kill and restart server ts1.stop() cleanupAll(svc) - time.Sleep(50 * time.Millisecond) ts2 := startTestServer(svc, testSnapshotDispatch()) defer ts2.stop() @@ -373,11 +371,13 @@ func TestCacheOverflowGuards(t *testing.T) { t.Skip("oversized slice-header guard requires 64-bit int") } - if _, err := cacheBucketMaskForLen(int(uint64(^uint32(0)) + 2)); !errors.Is(err, protocol.ErrOverflow) { + // Non-constant so 32-bit builds compile; the skip above keeps it from running there. + maxU32 := uint64(^uint32(0)) + if _, err := cacheBucketMaskForLen(int(maxU32 + 2)); !errors.Is(err, protocol.ErrOverflow) { t.Fatalf("unrepresentable bucket count error = %v, want ErrOverflow", err) } - if got := cacheStatusItemCountForLen(int(uint64(^uint32(0)) + 1)); got != ^uint32(0) { + if got := cacheStatusItemCountForLen(int(maxU32 + 1)); got != ^uint32(0) { t.Fatalf("overflow status item count = %d, want uint32 max", got) } } @@ -418,12 +418,7 @@ func TestCacheLargeDataset(t *testing.T) { cfg.MaxResponsePayloadBytes = 256 * N s := NewServer(testRunDir, svc, cfg, protocol.MethodCgroupsSnapshot, largeHandler) - doneCh := make(chan struct{}) - go func() { - defer close(doneCh) - s.Run() - }() - time.Sleep(100 * time.Millisecond) + doneCh := runServerForTest(s) defer func() { s.Stop() diff --git a/src/go/pkg/netipc/service/raw/cgroups_lookup.go b/src/go/pkg/netipc/service/raw/cgroups_lookup.go index c0e5da9a..77f09a90 100644 --- a/src/go/pkg/netipc/service/raw/cgroups_lookup.go +++ b/src/go/pkg/netipc/service/raw/cgroups_lookup.go @@ -7,7 +7,9 @@ import ( "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" ) -// CgroupsLookupHandler serves a single CGROUPS_LOOKUP service kind. +// CgroupsLookupHandler serves a single CGROUPS_LOOKUP service kind. The request +// borrows the receive buffer and the builder writes into the response +// buffer; both are valid only during the call, so copy what you keep. type CgroupsLookupHandler func(*protocol.CgroupsLookupRequestView, *protocol.CgroupsLookupBuilder) bool func cgroupsLookupRequestSize(paths [][]byte) (int, error) { @@ -155,10 +157,14 @@ func (c *Client) CallCgroupsLookupWithTimeout(paths [][]byte, timeoutMs uint32) if err != nil { if err == protocol.ErrOverflow && start < len(paths) { oneItemSize, serr := cgroupsLookupRequestSize(paths[start : start+1]) - if serr == nil { - if c.ensureLookupRequestCapacity(oneItemSize) == nil { - continue + if serr == nil && c.lookupRequestCapacityCanReconnect(oneItemSize) { + // The reconnect's own error ends the lookup, as in C and + // Rust; only an item over the client's limit is sent + // as oversized. + if cerr := c.ensureLookupRequestCapacity(oneItemSize, deadline); cerr != nil { + return nil, cerr } + continue } rawItem, serr := cgroupsLookupOversizedRequestItem(paths[start]) if serr != nil { @@ -186,7 +192,7 @@ func (c *Client) CallCgroupsLookupWithTimeout(paths [][]byte, timeoutMs uint32) return nil, protocol.ErrOverflow } var payload []byte - if err := c.callWithRetry(func() error { + if err := c.callWithRetry(remainingTimeout, func() error { _, callPayload, rerr := c.doRawCallWithTimeout(protocol.MethodCgroupsLookup, reqBuf[:reqLen], remainingTimeout) if rerr != nil { return rerr diff --git a/src/go/pkg/netipc/service/raw/cgroups_snapshot.go b/src/go/pkg/netipc/service/raw/cgroups_snapshot.go index 7a37e319..0b2f4a57 100644 --- a/src/go/pkg/netipc/service/raw/cgroups_snapshot.go +++ b/src/go/pkg/netipc/service/raw/cgroups_snapshot.go @@ -1,8 +1,14 @@ package raw -import "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" +import ( + "sync" -// SnapshotHandler serves a single CGROUPS_SNAPSHOT service kind. + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" +) + +// SnapshotHandler serves a single CGROUPS_SNAPSHOT service kind. The request +// is decoded into scalar fields, and the builder writes into the response +// buffer, which is valid only during the call. type SnapshotHandler func(*protocol.CgroupsRequest, *protocol.CgroupsBuilder) bool // SnapshotMaxItems returns the item budget for a single snapshot service kind. @@ -24,16 +30,16 @@ func (c *Client) CallSnapshotWithTimeout(timeoutMs uint32) (*protocol.CgroupsRes return nil, err } - var result *protocol.CgroupsResponseView - - err := c.callWithRetry(func() error { + err := c.callWithRetry(timeoutMs, func() error { req := protocol.CgroupsRequest{LayoutVersion: 1, Flags: 0} - var reqBuf [4]byte - if req.Encode(reqBuf[:]) == 0 { + // Client scratch: the payload reaches the transport, so a local + // array would be a heap allocation per call. + reqBuf := ensureClientScratch(&c.requestBuf, 4) + if req.Encode(reqBuf) == 0 { return protocol.ErrTruncated } - _, payload, rerr := c.doRawCallWithTimeout(protocol.MethodCgroupsSnapshot, reqBuf[:], timeoutMs) + _, payload, rerr := c.doRawCallWithTimeout(protocol.MethodCgroupsSnapshot, reqBuf, timeoutMs) if rerr != nil { return rerr } @@ -42,13 +48,30 @@ func (c *Client) CallSnapshotWithTimeout(timeoutMs uint32) (*protocol.CgroupsRes if derr != nil { return derr } - result = &view + c.snapshotView = view return nil }) if err != nil { return nil, err } - return result, nil + // The view lives in the client, so returning it does not allocate. + return &c.snapshotView, nil +} + +// The request and builder handed to a snapshot handler escape through that +// call; pooled scratch keeps them off the per-request heap. Each dispatch +// owns its entry until it returns. +type snapshotScratch struct { + req protocol.CgroupsRequest + builder protocol.CgroupsBuilder +} + +var snapshotScratchPool = sync.Pool{New: func() any { return new(snapshotScratch) }} + +// release drops the reference to the caller's response buffer. +func (s *snapshotScratch) release() { + *s = snapshotScratch{} + snapshotScratchPool.Put(s) } // SnapshotDispatch adapts a typed snapshot handler to the raw dispatch shape. @@ -57,7 +80,10 @@ func SnapshotDispatch(handle SnapshotHandler, maxItems uint32) DispatchHandler { return nil } return func(request []byte, responseBuf []byte) (int, error) { - req, err := protocol.DecodeCgroupsRequest(request) + scratch := snapshotScratchPool.Get().(*snapshotScratch) + defer scratch.release() + var err error + scratch.req, err = protocol.DecodeCgroupsRequest(request) if err != nil { return 0, err } @@ -69,8 +95,9 @@ func SnapshotDispatch(handle SnapshotHandler, maxItems uint32) DispatchHandler { if !ok || len(responseBuf) < minRequired { return 0, protocol.ErrOverflow } - builder := protocol.NewCgroupsBuilder(responseBuf, itemBudget, 0, 0) - if !handle(&req, builder) { + builder := &scratch.builder + builder.Reset(responseBuf, itemBudget, 0, 0) + if !handle(&scratch.req, builder) { return 0, errHandlerFailed } n := builder.Finish() diff --git a/src/go/pkg/netipc/service/raw/client.go b/src/go/pkg/netipc/service/raw/client.go index 0349e5f1..95fcd0da 100644 --- a/src/go/pkg/netipc/service/raw/client.go +++ b/src/go/pkg/netipc/service/raw/client.go @@ -4,10 +4,13 @@ import ( "errors" "time" + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/monotime" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" ) const clientShmAttachRetryInterval = 5 * time.Millisecond + +// clientShmAttachRetryTimeout is the default Client.shmAttachTimeout. const clientShmAttachRetryTimeout = 5 * time.Second func nextPowerOf2U32(n uint32) uint32 { @@ -40,6 +43,45 @@ func (c *Client) resolvedCallTimeout(timeoutMs uint32) uint32 { return ClientCallTimeoutDefaultMs } +// beginOp starts a call or refresh: one deadline, timeoutMs resolved as for +// calls, bounds every connect, handshake, attach and retry inside it. +func (c *Client) beginOp(timeoutMs uint32) { + c.opDeadline = monotime.After(time.Duration(c.resolvedCallTimeout(timeoutMs)) * time.Millisecond) +} + +func (c *Client) endOp() { + c.opDeadline = 0 +} + +// opRemainingMs returns the time left before the current deadline, 0 once it +// passed. It rounds up, so a wait given this time never ends before the +// deadline itself. Outside a call or refresh it is the resolved call timeout. +func (c *Client) opRemainingMs() uint32 { + if c.opDeadline == 0 { + return c.resolvedCallTimeout(0) + } + left := time.Duration(c.opDeadline - monotime.Now()) + if left <= 0 { + return 0 + } + ms := (left.Microseconds() + 999) / 1000 + if ms > int64(^uint32(0)) { + return ^uint32(0) + } + return uint32(ms) // #nosec G115 -- ms is positive and bounded by uint32 max above. +} + +// callReconnectFailure reports why a call ended when its reconnect failed. +func (c *Client) callReconnectFailure(err error) error { + if c.abortRequested.Load() { + return protocol.ErrAborted + } + if c.opRemainingMs() == 0 { + return protocol.ErrTimeout + } + return err +} + func (c *Client) abortSignal() <-chan struct{} { c.abortMu.Lock() ch := c.abortCh @@ -84,6 +126,13 @@ func (c *Client) ClearAbort() { func (c *Client) Refresh() bool { oldState := c.state + // A pending abort keeps the client offline until it is cleared. + if c.abortRequested.Load() { + return false + } + c.beginOp(0) + defer c.endOp() + switch c.state { case StateDisconnected, StateNotFound: c.state = StateConnecting @@ -154,17 +203,20 @@ func (c *Client) noteResponseCapacity(payloadLen uint32) { // callWithRetry manages reconnect-driven recovery for a typed call. // Ordinary failures retry once. Overflow-driven resize recovery may -// reconnect more than once while negotiated capacities grow. -func (c *Client) callWithRetry(attempt func() error) error { +// reconnect more than once while negotiated capacities grow. One deadline, +// from timeoutMs, bounds the attempts and every reconnect. +func (c *Client) callWithRetry(timeoutMs uint32, attempt func() error) error { // Fail fast if not READY if c.state != StateReady { c.errorCount++ - return protocol.ErrBadLayout + return protocol.ErrNotReady } if c.abortRequested.Load() { c.errorCount++ return protocol.ErrAborted } + c.beginOp(timeoutMs) + defer c.endOp() // Cap overflow-driven retries: payloads grow by powers of 2, so 8 // retries allows ~256x growth from the initial negotiated size. @@ -195,7 +247,7 @@ func (c *Client) callWithRetry(attempt func() error) error { c.state = c.tryConnect() if c.state != StateReady { c.errorCount++ - return firstErr + return c.callReconnectFailure(firstErr) } c.reconnectCount++ @@ -217,7 +269,7 @@ func (c *Client) callWithRetry(attempt func() error) error { c.state = c.tryConnect() if c.state != StateReady { c.errorCount++ - return firstErr + return c.callReconnectFailure(firstErr) } c.reconnectCount++ @@ -247,8 +299,18 @@ func (c *Client) doRawCall(methodCode uint16, reqPayload []byte) (protocol.Heade return c.doRawCallWithTimeout(methodCode, reqPayload, 0) } +// doRawCallWithTimeout makes one attempt. Inside a call it waits only for +// the time left before the call deadline; timeoutMs applies outside one. func (c *Client) doRawCallWithTimeout(methodCode uint16, reqPayload []byte, timeoutMs uint32) (protocol.Header, []byte, error) { - hdr := protocol.Header{ + waitMs := c.resolvedCallTimeout(timeoutMs) + if c.opDeadline != 0 { + waitMs = c.opRemainingMs() + if waitMs == 0 { + return protocol.Header{}, nil, protocol.ErrTimeout + } + } + + c.requestHdr = protocol.Header{ Kind: protocol.KindRequest, Code: methodCode, Flags: 0, @@ -256,12 +318,22 @@ func (c *Client) doRawCallWithTimeout(methodCode uint16, reqPayload []byte, time MessageID: uint64(c.callCount) + 1, TransportStatus: protocol.StatusOK, } + hdr := &c.requestHdr - if err := c.transportSend(&hdr, reqPayload); err != nil { + attemptDeadline := monotime.After(time.Duration(waitMs) * time.Millisecond) + if err := c.transportSend(hdr, reqPayload, waitMs); err != nil { return protocol.Header{}, nil, err } - respHdr, payload, err := c.transportReceiveWithControl(c.resolvedCallTimeout(timeoutMs), c.abortSignal()) + // The send may have waited for buffer space: the receive gets what is + // left of this attempt's budget. + left := time.Duration(attemptDeadline - monotime.Now()) + if left <= 0 { + return protocol.Header{}, nil, protocol.ErrTimeout + } + waitMs = uint32((left + time.Millisecond - 1) / time.Millisecond) // #nosec G115 -- left is below the uint32 budget it started from. + + respHdr, payload, err := c.transportReceiveWithControl(waitMs, c.abortSignal()) if err != nil { return protocol.Header{}, nil, err } diff --git a/src/go/pkg/netipc/service/raw/client_test.go b/src/go/pkg/netipc/service/raw/client_test.go index 6e8a79df..7dccc6a2 100644 --- a/src/go/pkg/netipc/service/raw/client_test.go +++ b/src/go/pkg/netipc/service/raw/client_test.go @@ -5,7 +5,6 @@ package raw import ( "os" "testing" - "time" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/posix" @@ -26,46 +25,6 @@ func cleanupAll(service string) { posix.ShmCleanupStale(testRunDir, service) } -func waitUnixSocketReady(service string) { - path := testRunDir + "/" + service + ".sock" - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - if _, err := os.Stat(path); err == nil { - return - } - time.Sleep(10 * time.Millisecond) - } -} - -func waitUnixServerReady(service string) { - waitUnixSocketReady(service) - - cfg := testClientConfig() - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - session, err := posix.Connect(testRunDir, service, &cfg) - if err == nil { - session.Close() - return - } - time.Sleep(10 * time.Millisecond) - } -} - -func waitUnixRawSessionConnect(service string, cfg posix.ClientConfig) (*posix.Session, error) { - deadline := time.Now().Add(2 * time.Second) - var lastErr error - for time.Now().Before(deadline) { - session, err := posix.Connect(testRunDir, service, &cfg) - if err == nil { - return session, nil - } - lastErr = err - time.Sleep(10 * time.Millisecond) - } - return nil, lastErr -} - func testServerConfig() posix.ServerConfig { return posix.ServerConfig{ SupportedProfiles: protocol.ProfileBaseline, @@ -74,7 +33,9 @@ func testServerConfig() posix.ServerConfig { MaxResponsePayloadBytes: responseBufSize, MaxResponseBatchItems: 1, AuthToken: authToken, - Backlog: 4, + // Above every test's connect burst: macOS and FreeBSD refuse a + // connect to a full backlog at once, where Linux waits. + Backlog: 64, } } @@ -138,22 +99,12 @@ func startTestServer(service string, handler DispatchHandler) *testServer { cleanupAll(service) s := NewServer(testRunDir, service, testServerConfig(), protocol.MethodCgroupsSnapshot, handler) - doneCh := make(chan struct{}) - - go func() { - defer close(doneCh) - s.Run() - }() - - // Wait until the server is actually accepting sessions. - waitUnixServerReady(service) - - return &testServer{server: s, doneCh: doneCh} + return &testServer{server: s, doneCh: runServerForTest(s)} } func (ts *testServer) stop() { ts.server.Stop() - <-ts.doneCh + waitClosed(ts.doneCh, "server "+ts.server.serviceName+" Run") } func TestClientLifecycle(t *testing.T) { @@ -316,7 +267,6 @@ func TestRetryOnFailure(t *testing.T) { // Kill server ts1.stop() cleanupAll(svc) - time.Sleep(50 * time.Millisecond) // Restart server ts2 := startTestServer(svc, testSnapshotDispatch()) @@ -408,14 +358,7 @@ func TestConcurrentClients(t *testing.T) { client := NewSnapshotClient(testRunDir, svc, testClientConfig()) defer client.Close() - for range 100 { - client.Refresh() - if client.Ready() { - break - } - time.Sleep(10 * time.Millisecond) - } - + client.Refresh() if !client.Ready() { r.failures = requestsPerClient results <- r @@ -553,7 +496,7 @@ func TestNonRequestTerminatesSession(t *testing.T) { defer ts.stop() // Connect via raw UDS session (transport level) - session, err := waitUnixRawSessionConnect(svc, posix.ClientConfig{ + session, err := posix.Connect(testRunDir, svc, &posix.ClientConfig{ SupportedProfiles: protocol.ProfileBaseline, MaxRequestPayloadBytes: 4096, MaxRequestBatchItems: 1, @@ -579,10 +522,8 @@ func TestNonRequestTerminatesSession(t *testing.T) { // Send might fail immediately; that's acceptable t.Logf("send non-request failed immediately: %v", sendErr) } else { - // Wait for server to process and terminate the session - time.Sleep(200 * time.Millisecond) - - // Try to send a valid request and receive - should fail + // The server terminates the session on the bad message, so the + // receive below ends with an error instead of a response. reqHdr := &protocol.Header{ Kind: protocol.KindRequest, Code: protocol.MethodCgroupsSnapshot, diff --git a/src/go/pkg/netipc/service/raw/client_unix.go b/src/go/pkg/netipc/service/raw/client_unix.go index b6832883..5dfac785 100644 --- a/src/go/pkg/netipc/service/raw/client_unix.go +++ b/src/go/pkg/netipc/service/raw/client_unix.go @@ -8,6 +8,7 @@ import ( "sync/atomic" "time" + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/monotime" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/posix" ) @@ -30,6 +31,13 @@ type Client struct { requestBuf []byte sendBuf []byte transportBuf []byte + // Request header of the call in progress: its address goes to the + // transport, so a per-call variable would be a heap allocation per call. + requestHdr protocol.Header + // Views returned by the typed calls: valid until the next typed call, and + // kept here so that returning their address does not allocate. + snapshotView protocol.CgroupsResponseView + stringReverseView protocol.StringReverseView // Stats connectCount uint32 @@ -44,6 +52,12 @@ type Client struct { abortRequested atomic.Bool abortMu sync.Mutex abortCh chan struct{} + // shmAttachTimeout bounds the SHM attach retry after negotiation; + // tests shorten it per instance. + shmAttachTimeout time.Duration + // opDeadline bounds the call or refresh in progress, in monotime + // nanoseconds; zero outside one. + opDeadline int64 } func newClient(runDir, serviceName string, config posix.ClientConfig, expectedMethodCode uint16) *Client { @@ -55,6 +69,7 @@ func newClient(runDir, serviceName string, config posix.ClientConfig, expectedMe config: config, callTimeoutMs: ClientCallTimeoutDefaultMs, abortCh: make(chan struct{}), + shmAttachTimeout: clientShmAttachRetryTimeout, } client.SetLookupLogicalConfig(LookupLogicalConfig{}) return client @@ -72,7 +87,11 @@ func (c *Client) disconnect() { } func (c *Client) tryConnect() ClientState { - session, err := posix.Connect(c.runDir, c.serviceName, &c.config) + remaining := c.opRemainingMs() + if remaining == 0 || c.abortRequested.Load() { + return StateDisconnected + } + session, err := posix.ConnectTimeout(c.runDir, c.serviceName, &c.config, remaining, c.abortSignal()) if err != nil { switch { case isConnectError(err): @@ -91,7 +110,13 @@ func (c *Client) tryConnect() ClientState { session.SelectedProfile == protocol.ProfileSHMFutex { // Retry attach: the server prepared SHM before handshake, but the // client may still race slightly with the peer exposing the region. - deadline := time.Now().Add(clientShmAttachRetryTimeout) + // When the call deadline ends the wait first, SHM is not at fault + // and stays enabled for the next connect. + callLeft := time.Duration(c.opRemainingMs()) * time.Millisecond + // The remaining time is rounded up, so a tie means the call deadline + // comes first. + callBound := callLeft <= c.shmAttachTimeout + deadline := time.Now().Add(min(callLeft, c.shmAttachTimeout)) for { shm, serr := posix.ShmClientAttach(c.runDir, c.serviceName, session.SessionID) if serr == nil { @@ -99,8 +124,23 @@ func (c *Client) tryConnect() ClientState { break } if !time.Now().Before(deadline) { + if callBound { + session.Close() + return StateDisconnected + } break } + if c.abortRequested.Load() { + session.Close() + return StateDisconnected + } + // A server that refused or died closes the session socket; its + // SHM region will never appear. Not an SHM problem: keep SHM + // enabled for the next connect. + if peerDisconnected(session.Fd()) { + session.Close() + return StateDisconnected + } time.Sleep(clientShmAttachRetryInterval) } if c.shm == nil { @@ -120,7 +160,10 @@ func (c *Client) tryConnect() ClientState { return StateReady } -func (c *Client) transportSend(hdr *protocol.Header, payload []byte) error { +// transportSend sends via the active transport. The baseline send ends at +// timeoutMs (0 = no deadline) or on abort; the SHM send never waits for the +// peer. +func (c *Client) transportSend(hdr *protocol.Header, payload []byte, timeoutMs uint32) error { payloadLen, err := checkedLookupU32(len(payload)) if err != nil { c.noteRequestCapacity(^uint32(0)) @@ -154,25 +197,54 @@ func (c *Client) transportSend(hdr *protocol.Header, payload []byte) error { c.noteRequestCapacity(payloadLen) return protocol.ErrOverflow } - return protocol.ErrTruncated + return c.shmSendFailure() } return nil } // UDS path if c.session == nil { - return protocol.ErrTruncated + return protocol.ErrTransport } - if err := c.session.Send(hdr, payload); err != nil { + if err := c.session.SendTimeout(hdr, payload, timeoutMs, c.abortSignal()); err != nil { + if errors.Is(err, posix.ErrTimeout) { + return protocol.ErrTimeout + } + if errors.Is(err, posix.ErrAborted) { + return protocol.ErrAborted + } if errors.Is(err, posix.ErrLimitExceeded) { c.noteRequestCapacity(payloadLen) return protocol.ErrOverflow } - return protocol.ErrTruncated + return c.socketFailure(err, protocol.ErrTransport) } return nil } +// socketFailure maps a socket send/recv failure. The transport reports EOF, +// EPIPE and ECONNRESET as plain send/recv failures, so the session probe tells +// a gone server apart from any other I/O failure; protocol errors keep their +// own meaning. +func (c *Client) socketFailure(err error, otherwise error) error { + if errors.Is(err, posix.ErrSend) || errors.Is(err, posix.ErrRecv) { + if c.session != nil && peerDisconnected(c.session.Fd()) { + return protocol.ErrDisconnected + } + return protocol.ErrTransport + } + return otherwise +} + +// shmSendFailure maps a failed SHM send: a disconnect when the session probe +// says the server is gone, a transport failure otherwise. +func (c *Client) shmSendFailure() error { + if c.session != nil && peerDisconnected(c.session.Fd()) { + return protocol.ErrDisconnected + } + return protocol.ErrTransport +} + func (c *Client) transportReceive() (protocol.Header, []byte, error) { return c.transportReceiveWithControl(c.resolvedCallTimeout(0), c.abortSignal()) } @@ -181,7 +253,8 @@ func (c *Client) transportReceiveWithControl(timeoutMs uint32, abortCh <-chan st scratch := ensureClientScratch(&c.transportBuf, c.maxReceiveMessageBytes()) if c.shm != nil { - deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) + // One monotonic read per slice, as in C. + deadline := monotime.After(time.Duration(timeoutMs) * time.Millisecond) for { select { case <-abortCh: @@ -189,13 +262,9 @@ func (c *Client) transportReceiveWithControl(timeoutMs uint32, abortCh <-chan st default: } - if timeoutMs != 0 && !time.Now().Before(deadline) { - return protocol.Header{}, nil, protocol.ErrTimeout - } - waitMs := clientAbortPollMs if timeoutMs != 0 { - remaining := time.Until(deadline) + remaining := time.Duration(deadline - monotime.Now()) if remaining <= 0 { return protocol.Header{}, nil, protocol.ErrTimeout } @@ -205,9 +274,20 @@ func (c *Client) transportReceiveWithControl(timeoutMs uint32, abortCh <-chan st mlen, err := c.shm.ShmReceive(scratch, waitMs) if err != nil { if errors.Is(err, posix.ErrShmTimeout) { + // Only an idle slice pays for the probe. + if c.session != nil && peerDisconnected(c.session.Fd()) { + return protocol.Header{}, nil, protocol.ErrDisconnected + } continue } - return protocol.Header{}, nil, protocol.ErrTruncated + if errors.Is(err, posix.ErrShmMsgTooLarge) { + return protocol.Header{}, nil, protocol.ErrOverflow + } + // A zero-length or corrupt slot is a truncated message. + if errors.Is(err, posix.ErrShmBadHeader) { + return protocol.Header{}, nil, protocol.ErrTruncated + } + return protocol.Header{}, nil, protocol.ErrTransport } if mlen < protocol.HeaderSize { return protocol.Header{}, nil, protocol.ErrTruncated @@ -223,7 +303,7 @@ func (c *Client) transportReceiveWithControl(timeoutMs uint32, abortCh <-chan st // UDS path: receive returns (Header, payload, error) if c.session == nil { - return protocol.Header{}, nil, protocol.ErrTruncated + return protocol.Header{}, nil, protocol.ErrTransport } hdr, payload, err := c.session.ReceiveTimeout(scratch, timeoutMs, abortCh) @@ -234,7 +314,10 @@ func (c *Client) transportReceiveWithControl(timeoutMs uint32, abortCh <-chan st if errors.Is(err, posix.ErrAborted) { return protocol.Header{}, nil, protocol.ErrAborted } - return protocol.Header{}, nil, protocol.ErrTruncated + if errors.Is(err, posix.ErrLimitExceeded) { + return protocol.Header{}, nil, protocol.ErrOverflow + } + return protocol.Header{}, nil, c.socketFailure(err, protocol.ErrTruncated) } return hdr, payload, nil } diff --git a/src/go/pkg/netipc/service/raw/client_windows.go b/src/go/pkg/netipc/service/raw/client_windows.go index d68225bf..362aa317 100644 --- a/src/go/pkg/netipc/service/raw/client_windows.go +++ b/src/go/pkg/netipc/service/raw/client_windows.go @@ -8,6 +8,7 @@ import ( "sync/atomic" "time" + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/monotime" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" windows "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/windows" ) @@ -26,6 +27,13 @@ type Client struct { requestBuf []byte sendBuf []byte transportBuf []byte + // Request header of the call in progress: its address goes to the + // transport, so a per-call variable would be a heap allocation per call. + requestHdr protocol.Header + // Views returned by the typed calls: valid until the next typed call, and + // kept here so that returning their address does not allocate. + snapshotView protocol.CgroupsResponseView + stringReverseView protocol.StringReverseView connectCount uint32 reconnectCount uint32 @@ -39,6 +47,12 @@ type Client struct { abortRequested atomic.Bool abortMu sync.Mutex abortCh chan struct{} + // shmAttachTimeout bounds the SHM attach retry after negotiation; + // tests shorten it per instance. + shmAttachTimeout time.Duration + // opDeadline bounds the call or refresh in progress, in monotime + // nanoseconds; zero outside one. + opDeadline int64 } func newClient(runDir, serviceName string, config windows.ClientConfig, expectedMethodCode uint16) *Client { @@ -50,6 +64,7 @@ func newClient(runDir, serviceName string, config windows.ClientConfig, expected config: config, callTimeoutMs: ClientCallTimeoutDefaultMs, abortCh: make(chan struct{}), + shmAttachTimeout: clientShmAttachRetryTimeout, } client.SetLookupLogicalConfig(LookupLogicalConfig{}) return client @@ -67,7 +82,11 @@ func (c *Client) disconnect() { } func (c *Client) tryConnect() ClientState { - session, err := windows.Connect(c.runDir, c.serviceName, &c.config) + remaining := c.opRemainingMs() + if remaining == 0 || c.abortRequested.Load() { + return StateDisconnected + } + session, err := windows.ConnectTimeout(c.runDir, c.serviceName, &c.config, remaining, c.abortSignal()) if err != nil { switch { case isConnectError(err): @@ -84,7 +103,13 @@ func (c *Client) tryConnect() ClientState { // Win SHM upgrade if negotiated if session.SelectedProfile == windows.WinShmProfileHybrid || session.SelectedProfile == windows.WinShmProfileBusywait { - deadline := time.Now().Add(clientShmAttachRetryTimeout) + // When the call deadline ends the wait first, WinSHM is not at + // fault and stays enabled for the next connect. + callLeft := time.Duration(c.opRemainingMs()) * time.Millisecond + // The remaining time is rounded up, so a tie means the call deadline + // comes first. + callBound := callLeft <= c.shmAttachTimeout + deadline := time.Now().Add(min(callLeft, c.shmAttachTimeout)) for { shm, serr := windows.WinShmClientAttach( c.runDir, c.serviceName, @@ -97,8 +122,23 @@ func (c *Client) tryConnect() ClientState { break } if !time.Now().Before(deadline) { + if callBound { + session.Close() + return StateDisconnected + } break } + if c.abortRequested.Load() { + session.Close() + return StateDisconnected + } + // A server that refused or died breaks the session pipe; its + // SHM objects will never appear. Not a WinSHM problem: keep it + // enabled for the next connect. + if pipePeerDisconnected(session) { + session.Close() + return StateDisconnected + } time.Sleep(clientShmAttachRetryInterval) } if c.shm == nil { @@ -118,7 +158,10 @@ func (c *Client) tryConnect() ClientState { return StateReady } -func (c *Client) transportSend(hdr *protocol.Header, payload []byte) error { +// transportSend sends via the active transport. The baseline send ends at +// timeoutMs (0 = no deadline) or on abort; the SHM send never waits for the +// peer. +func (c *Client) transportSend(hdr *protocol.Header, payload []byte, timeoutMs uint32) error { if c.shm != nil { if len(payload) > int(c.sessionMaxRequestPayloadBytes()) { c.noteRequestCapacity(uint32(len(payload))) @@ -143,24 +186,64 @@ func (c *Client) transportSend(hdr *protocol.Header, payload []byte) error { c.noteRequestCapacity(uint32(len(payload))) return protocol.ErrOverflow } - return protocol.ErrTruncated + return c.shmSendFailure() } return nil } if c.session == nil { - return protocol.ErrTruncated + return protocol.ErrTransport } - if err := c.session.Send(hdr, payload); err != nil { + if err := c.session.SendTimeout(hdr, payload, timeoutMs, c.abortSignal()); err != nil { + if errors.Is(err, windows.ErrTimeout) { + return protocol.ErrTimeout + } + if errors.Is(err, windows.ErrAborted) { + return protocol.ErrAborted + } if errors.Is(err, windows.ErrLimitExceeded) { c.noteRequestCapacity(uint32(len(payload))) return protocol.ErrOverflow } - return protocol.ErrTruncated + return c.pipeFailure(err, protocol.ErrTransport) } return nil } +// pipeFailure maps a pipe send/recv failure. A broken pipe is a disconnect; +// a plain send/recv failure is one when the session probe says the server is +// gone, and a transport failure otherwise; protocol errors keep their own +// meaning. +func (c *Client) pipeFailure(err error, otherwise error) error { + if errors.Is(err, windows.ErrDisconnected) { + return protocol.ErrDisconnected + } + if errors.Is(err, windows.ErrSend) || errors.Is(err, windows.ErrRecv) { + if c.session != nil && pipePeerDisconnected(c.session) { + return protocol.ErrDisconnected + } + return protocol.ErrTransport + } + return otherwise +} + +// shmSendFailure maps a failed SHM send: a disconnect when the session probe +// says the server is gone, a transport failure otherwise. +func (c *Client) shmSendFailure() error { + if c.session != nil && pipePeerDisconnected(c.session) { + return protocol.ErrDisconnected + } + return protocol.ErrTransport +} + +// pipePeerDisconnected is a zero-wait probe of a session pipe. WinSHM sessions +// keep the pipe open for their whole life and the kernel breaks it when the +// peer exits, so a broken pipe is the dead-peer signal for both directions. +func pipePeerDisconnected(session *windows.Session) bool { + _, err := session.WaitReadable(0) + return errors.Is(err, windows.ErrDisconnected) +} + func (c *Client) transportReceive() (protocol.Header, []byte, error) { return c.transportReceiveWithControl(c.resolvedCallTimeout(0), c.abortSignal()) } @@ -169,7 +252,8 @@ func (c *Client) transportReceiveWithControl(timeoutMs uint32, abortCh <-chan st scratch := ensureClientScratch(&c.transportBuf, c.maxReceiveMessageBytes()) if c.shm != nil { - deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) + // One monotonic read per slice, as in C. + deadline := monotime.After(time.Duration(timeoutMs) * time.Millisecond) for { select { case <-abortCh: @@ -177,13 +261,9 @@ func (c *Client) transportReceiveWithControl(timeoutMs uint32, abortCh <-chan st default: } - if timeoutMs != 0 && !time.Now().Before(deadline) { - return protocol.Header{}, nil, protocol.ErrTimeout - } - waitMs := clientAbortPollMs if timeoutMs != 0 { - remaining := time.Until(deadline) + remaining := time.Duration(deadline - monotime.Now()) if remaining <= 0 { return protocol.Header{}, nil, protocol.ErrTimeout } @@ -193,9 +273,23 @@ func (c *Client) transportReceiveWithControl(timeoutMs uint32, abortCh <-chan st mlen, err := c.shm.WinShmReceive(scratch, waitMs) if err != nil { if errors.Is(err, windows.ErrWinShmTimeout) { + // Only an idle slice pays for the probe. + if c.session != nil && pipePeerDisconnected(c.session) { + return protocol.Header{}, nil, protocol.ErrDisconnected + } continue } - return protocol.Header{}, nil, protocol.ErrTruncated + if errors.Is(err, windows.ErrWinShmDisconnected) { + return protocol.Header{}, nil, protocol.ErrDisconnected + } + if errors.Is(err, windows.ErrWinShmMsgTooLarge) { + return protocol.Header{}, nil, protocol.ErrOverflow + } + // A zero-length or corrupt slot is a truncated message. + if errors.Is(err, windows.ErrWinShmBadHeader) { + return protocol.Header{}, nil, protocol.ErrTruncated + } + return protocol.Header{}, nil, protocol.ErrTransport } if mlen < protocol.HeaderSize { return protocol.Header{}, nil, protocol.ErrTruncated @@ -210,7 +304,7 @@ func (c *Client) transportReceiveWithControl(timeoutMs uint32, abortCh <-chan st } if c.session == nil { - return protocol.Header{}, nil, protocol.ErrTruncated + return protocol.Header{}, nil, protocol.ErrTransport } hdr, payload, err := c.session.ReceiveTimeout(scratch, timeoutMs, abortCh) @@ -221,7 +315,10 @@ func (c *Client) transportReceiveWithControl(timeoutMs uint32, abortCh <-chan st if errors.Is(err, windows.ErrAborted) { return protocol.Header{}, nil, protocol.ErrAborted } - return protocol.Header{}, nil, protocol.ErrTruncated + if errors.Is(err, windows.ErrLimitExceeded) { + return protocol.Header{}, nil, protocol.ErrOverflow + } + return protocol.Header{}, nil, c.pipeFailure(err, protocol.ErrTruncated) } return hdr, payload, nil } @@ -243,7 +340,7 @@ func isConnectError(err error) bool { } func isAuthError(err error) bool { - return errors.Is(err, windows.ErrAuthFailed) + return errors.Is(err, windows.ErrAuthFailed) || errors.Is(err, windows.ErrUntrustedServer) } func isIncompatibleError(err error) bool { diff --git a/src/go/pkg/netipc/service/raw/edge_test.go b/src/go/pkg/netipc/service/raw/edge_test.go index 92022887..09d76ee0 100644 --- a/src/go/pkg/netipc/service/raw/edge_test.go +++ b/src/go/pkg/netipc/service/raw/edge_test.go @@ -3,8 +3,8 @@ package raw import ( + "errors" "testing" - "time" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/posix" @@ -54,12 +54,7 @@ func TestClientRefreshFromAuthFailed(t *testing.T) { protocol.MethodCgroupsSnapshot, testSnapshotDispatch(), ) - doneCh := make(chan struct{}) - go func() { - defer close(doneCh) - s.Run() - }() - time.Sleep(100 * time.Millisecond) + doneCh := runServerForTest(s) defer func() { s.Stop() <-doneCh @@ -100,12 +95,7 @@ func TestClientRefreshFromIncompatible(t *testing.T) { protocol.MethodCgroupsSnapshot, testSnapshotDispatch(), ) - doneCh := make(chan struct{}) - go func() { - defer close(doneCh) - s.Run() - }() - time.Sleep(100 * time.Millisecond) + doneCh := runServerForTest(s) defer func() { s.Stop() <-doneCh @@ -171,7 +161,15 @@ func TestClientRefreshFromBroken(t *testing.T) { ensureRunDir() cleanupAll(svc) - ts := startTestServer(svc, testSnapshotDispatch()) + // A call that times out leaves the client BROKEN while the server stays up. + release := make(chan struct{}) + ts := startTestServerUnixWithConfig(svc, testServerConfig(), protocol.MethodCgroupsSnapshot, + SnapshotDispatch(func(req *protocol.CgroupsRequest, b *protocol.CgroupsBuilder) bool { + <-release + return testCgroupsHandler(req, b) + }, 3)) + defer ts.stop() + defer close(release) client := NewSnapshotClient(testRunDir, svc, testClientConfig()) defer client.Close() @@ -181,24 +179,13 @@ func TestClientRefreshFromBroken(t *testing.T) { t.Fatal("expected READY") } - // Kill server to make connection broken - ts.stop() - cleanupAll(svc) - time.Sleep(50 * time.Millisecond) - - // Force a call to break the connection - _, _ = client.CallSnapshot() - - // At this point, state should be BROKEN + if _, err := client.CallSnapshotWithTimeout(30); !errors.Is(err, protocol.ErrTimeout) { + t.Fatalf("CallSnapshot error = %v, want timeout", err) + } if client.state != StateBroken { - t.Logf("state is %d (may not be broken if retry succeeded; skipping)", client.state) - return + t.Fatalf("state = %d after a timed-out call, want BROKEN", client.state) } - // Start a new server - ts2 := startTestServer(svc, testSnapshotDispatch()) - defer ts2.stop() - // Refresh from BROKEN should reconnect changed := client.Refresh() if !changed { diff --git a/src/go/pkg/netipc/service/raw/helpers_windows_test.go b/src/go/pkg/netipc/service/raw/helpers_windows_test.go index 1db036a5..fd6ac4c2 100644 --- a/src/go/pkg/netipc/service/raw/helpers_windows_test.go +++ b/src/go/pkg/netipc/service/raw/helpers_windows_test.go @@ -50,18 +50,14 @@ func testWinShmClientConfig() windows.ClientConfig { return cfg } +// waitWinClientReady refreshes once: servers in these tests are already +// listening, so a single connect must succeed. func waitWinClientReady(t *testing.T, client *Client) { t.Helper() - - for i := 0; i < 100; i++ { - client.Refresh() - if client.Ready() { - return - } - time.Sleep(10 * time.Millisecond) + client.Refresh() + if !client.Ready() { + t.Fatalf("client not ready after one refresh, state=%d", client.state) } - - t.Fatalf("client not ready, final state=%d", client.state) } func startTestServerWinWithConfig( @@ -71,16 +67,7 @@ func startTestServerWinWithConfig( handler DispatchHandler, ) *winTestServer { s := NewServer(winTestRunDir, service, cfg, expectedMethodCode, handler) - doneCh := make(chan struct{}) - - go func() { - defer close(doneCh) - _ = s.Run() - }() - - time.Sleep(200 * time.Millisecond) - - return &winTestServer{server: s, doneCh: doneCh} + return &winTestServer{server: s, doneCh: runServerForTest(s)} } func startTestIncrementServerWinWithConfig(service string, cfg windows.ServerConfig) *winTestServer { @@ -280,7 +267,7 @@ func startRawWinHelloAckServer(t *testing.T, service string, packet []byte) *win srv.doneCh <- nil }() - time.Sleep(200 * time.Millisecond) + // The pipe instance exists before this returns, so clients can open it. return srv } @@ -320,8 +307,15 @@ func connectRawNamedPipe(handle syscall.Handle) error { func (s *winHelloAckServer) wait(t *testing.T) { t.Helper() - if err := <-s.doneCh; err != nil { - t.Fatalf("raw windows hello_ack server failed: %v", err) + // A client that never connects leaves the server in ConnectNamedPipe: + // fail instead of hanging. + select { + case err := <-s.doneCh: + if err != nil { + t.Fatalf("raw windows hello_ack server failed: %v", err) + } + case <-time.After(testWaitLimit): + t.Fatalf("raw windows hello_ack server did not finish within %v", testWaitLimit) } if got := s.accepted.Load(); got != 1 { t.Fatalf("expected exactly one raw handshake accept, got %d", got) @@ -378,14 +372,19 @@ func startRawWinSessionServerN(t *testing.T, service string, cfg windows.ServerC srv.doneCh <- nil }() - time.Sleep(200 * time.Millisecond) + // The pipe instance exists before this returns, so clients can open it. return srv } func (s *winRawSessionServer) wait(t *testing.T) { t.Helper() - if err := <-s.doneCh; err != nil { - t.Fatalf("raw windows session server failed: %v", err) + select { + case err := <-s.doneCh: + if err != nil { + t.Fatalf("raw windows session server failed: %v", err) + } + case <-time.After(testWaitLimit): + t.Fatalf("raw windows session server did not finish within %v", testWaitLimit) } } @@ -425,8 +424,8 @@ func winIncrementDispatchHandler() DispatchHandler { } func winStringReverseDispatchHandler() DispatchHandler { - return StringReverseDispatch(func(s string) (string, bool) { - return winReverseString(s), true + return StringReverseDispatch(func(s []byte) (string, bool) { + return winReverseString(string(s)), true }) } diff --git a/src/go/pkg/netipc/service/raw/increment.go b/src/go/pkg/netipc/service/raw/increment.go index ae046d72..d7166181 100644 --- a/src/go/pkg/netipc/service/raw/increment.go +++ b/src/go/pkg/netipc/service/raw/increment.go @@ -18,13 +18,15 @@ func (c *Client) CallIncrementWithTimeout(requestValue uint64, timeoutMs uint32) var result uint64 - err := c.callWithRetry(func() error { - var reqBuf [protocol.IncrementPayloadSize]byte - if protocol.IncrementEncode(requestValue, reqBuf[:]) == 0 { + err := c.callWithRetry(timeoutMs, func() error { + // Client scratch: the payload reaches the transport, so a local + // array would be a heap allocation per call. + reqBuf := ensureClientScratch(&c.requestBuf, protocol.IncrementPayloadSize) + if protocol.IncrementEncode(requestValue, reqBuf) == 0 { return protocol.ErrTruncated } - _, payload, rerr := c.doRawCallWithTimeout(protocol.MethodIncrement, reqBuf[:], timeoutMs) + _, payload, rerr := c.doRawCallWithTimeout(protocol.MethodIncrement, reqBuf, timeoutMs) if rerr != nil { return rerr } @@ -60,7 +62,7 @@ func (c *Client) CallIncrementBatchWithTimeout(values []uint64, timeoutMs uint32 return nil, err } - err = c.callWithRetry(func() error { + err = c.callWithRetry(timeoutMs, func() error { batchBufSize, err := incrementBatchRequestSize(len(values)) if err != nil { return err @@ -81,7 +83,7 @@ func (c *Client) CallIncrementBatchWithTimeout(values []uint64, timeoutMs uint32 totalPayloadLen, _ := bb.Finish() reqPayload := batchBuf[:totalPayloadLen] - hdr := protocol.Header{ + c.requestHdr = protocol.Header{ Kind: protocol.KindRequest, Code: protocol.MethodIncrement, Flags: protocol.FlagBatch, @@ -89,13 +91,21 @@ func (c *Client) CallIncrementBatchWithTimeout(values []uint64, timeoutMs uint32 MessageID: uint64(c.callCount) + 1, TransportStatus: protocol.StatusOK, } + hdr := &c.requestHdr - if err := c.transportSend(&hdr, reqPayload); err != nil { + waitMs := c.opRemainingMs() + if waitMs == 0 { + return protocol.ErrTimeout + } + if err := c.transportSend(hdr, reqPayload, waitMs); err != nil { return err } - respHdr, respPayload, err := c.transportReceiveWithControl( - c.resolvedCallTimeout(timeoutMs), c.abortSignal()) + waitMs = c.opRemainingMs() + if waitMs == 0 { + return protocol.ErrTimeout + } + respHdr, respPayload, err := c.transportReceiveWithControl(waitMs, c.abortSignal()) if err != nil { return err } diff --git a/src/go/pkg/netipc/service/raw/large_helpers_unix_test.go b/src/go/pkg/netipc/service/raw/large_helpers_unix_test.go new file mode 100644 index 00000000..85064628 --- /dev/null +++ b/src/go/pkg/netipc/service/raw/large_helpers_unix_test.go @@ -0,0 +1,60 @@ +//go:build unix && netipc_extended + +package raw + +import ( + "fmt" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" +) + +// simpleHash: djb2 matching the C implementation +func simpleHash(s string) uint32 { + var hash uint32 = 5381 + for _, c := range []byte(s) { + hash = ((hash << 5) + hash) + uint32(c) + } + return hash +} + +// largeHandler builds a snapshot with N items (realistic container names). +func largeHandler(n int) DispatchHandler { + return SnapshotDispatch(func(request *protocol.CgroupsRequest, builder *protocol.CgroupsBuilder) bool { + if request.LayoutVersion != 1 || request.Flags != 0 { + return false + } + builder.SetHeader(1, 42) + + for i := range n { + name := fmt.Sprintf("container-%04d", i) + path := fmt.Sprintf("/sys/fs/cgroup/docker/%04d", i) + hash := simpleHash(name) + enabled := uint32(1) + if i%5 == 0 { + enabled = 0 + } + if err := builder.Add(hash, 0x10, enabled, + []byte(name), []byte(path)); err != nil { + return false + } + } + return true + }, uint32(n)) +} + +// startServerWithWorkers creates a server with explicit worker count. +func startServerWithWorkers(service string, handler DispatchHandler, workers int) *testServer { + ensureRunDir() + cleanupAll(service) + + s := NewServerWithWorkers( + testRunDir, + service, + testServerConfig(), + protocol.MethodCgroupsSnapshot, + handler, + workers, + ) + doneCh := runServerForTest(s) + return &testServer{server: s, doneCh: doneCh} +} diff --git a/src/go/pkg/netipc/service/raw/lookup_common.go b/src/go/pkg/netipc/service/raw/lookup_common.go index 219bfa07..6baa5d8a 100644 --- a/src/go/pkg/netipc/service/raw/lookup_common.go +++ b/src/go/pkg/netipc/service/raw/lookup_common.go @@ -3,6 +3,7 @@ package raw import ( "time" + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/monotime" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" ) @@ -41,7 +42,7 @@ func (c *Client) SetLookupLogicalConfig(config LookupLogicalConfig) { func (c *Client) ensureReadyForLogicalLookup() error { if c.state != StateReady { c.errorCount++ - return protocol.ErrBadLayout + return protocol.ErrNotReady } if c.abortRequested.Load() { c.disconnect() @@ -52,7 +53,19 @@ func (c *Client) ensureReadyForLogicalLookup() error { return nil } -func (c *Client) ensureLookupRequestCapacity(required int) error { +// lookupRequestCapacityCanReconnect reports whether a reconnect can give the +// session room for required request bytes: they fit the client's limit. +func (c *Client) lookupRequestCapacityCanReconnect(required int) bool { + maxRequestPayloadBytes := c.config.MaxRequestPayloadBytes + if maxRequestPayloadBytes == 0 { + maxRequestPayloadBytes = protocol.MaxPayloadDefault + } + return required >= 0 && uint64(required) <= uint64(maxRequestPayloadBytes) +} + +// ensureLookupRequestCapacity reconnects for a larger request capacity when +// needed, bounded by the lookup deadline. +func (c *Client) ensureLookupRequestCapacity(required int, deadline int64) error { if c.abortRequested.Load() { c.disconnect() c.state = StateBroken @@ -73,12 +86,14 @@ func (c *Client) ensureLookupRequestCapacity(required int) error { return nil } prevReq := c.sessionMaxRequestPayloadBytes() + c.opDeadline = deadline + defer c.endOp() c.disconnect() c.state = StateBroken c.state = c.tryConnect() if c.state != StateReady { c.errorCount++ - return protocol.ErrOverflow + return c.callReconnectFailure(protocol.ErrOverflow) } c.reconnectCount++ if c.sessionMaxRequestPayloadBytes() <= prevReq || @@ -91,15 +106,16 @@ func (c *Client) ensureLookupRequestCapacity(required int) error { return nil } -func (c *Client) newLookupDeadline(timeoutMs uint32) time.Time { - return time.Now().Add(time.Duration(c.resolvedCallTimeout(timeoutMs)) * time.Millisecond) +// newLookupDeadline returns the lookup deadline in monotime nanoseconds. +func (c *Client) newLookupDeadline(timeoutMs uint32) int64 { + return monotime.After(time.Duration(c.resolvedCallTimeout(timeoutMs)) * time.Millisecond) } -func (c *Client) lookupRemainingTimeout(deadline time.Time) (uint32, error) { +func (c *Client) lookupRemainingTimeout(deadline int64) (uint32, error) { if c.abortRequested.Load() { return 0, protocol.ErrAborted } - remaining := time.Until(deadline) + remaining := time.Duration(deadline - monotime.Now()) if remaining <= 0 { return 0, protocol.ErrTimeout } diff --git a/src/go/pkg/netipc/service/raw/lookup_common_test.go b/src/go/pkg/netipc/service/raw/lookup_common_test.go index 1b33ca0b..521f04ab 100644 --- a/src/go/pkg/netipc/service/raw/lookup_common_test.go +++ b/src/go/pkg/netipc/service/raw/lookup_common_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/monotime" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" ) @@ -63,8 +64,12 @@ func TestLookupCommonSizingHelpers(t *testing.T) { if _, err := checkedLookupU32(-1); !errors.Is(err, protocol.ErrOverflow) { t.Fatalf("checkedLookupU32 negative error = %v", err) } - if _, err := checkedLookupU32(int(uint64(^uint32(0)) + 1)); !errors.Is(err, protocol.ErrOverflow) { - t.Fatalf("checkedLookupU32 overflow error = %v", err) + // A 32-bit int cannot exceed uint32 max; non-constant so those builds compile. + if strconv.IntSize >= 64 { + maxU32 := uint64(^uint32(0)) + if _, err := checkedLookupU32(int(maxU32 + 1)); !errors.Is(err, protocol.ErrOverflow) { + t.Fatalf("checkedLookupU32 overflow error = %v", err) + } } } @@ -228,15 +233,15 @@ func TestLookupDeadlineAndTimeoutHelpers(t *testing.T) { if remaining, err := client.lookupRemainingTimeout(deadline); err != nil || remaining == 0 { t.Fatalf("lookupRemainingTimeout active = %d/%v", remaining, err) } - farFuture := time.Now().Add(time.Duration(^uint32(0)) * time.Millisecond).Add(time.Hour) + farFuture := monotime.After(time.Duration(^uint32(0))*time.Millisecond + time.Hour) if remaining, err := client.lookupRemainingTimeout(farFuture); err != nil || remaining != ^uint32(0) { t.Fatalf("lookupRemainingTimeout far future = %d/%v, want max uint32/nil", remaining, err) } - if _, err := client.lookupRemainingTimeout(time.Now().Add(-time.Millisecond)); !errors.Is(err, protocol.ErrTimeout) { + if _, err := client.lookupRemainingTimeout(monotime.After(-time.Millisecond)); !errors.Is(err, protocol.ErrTimeout) { t.Fatalf("lookupRemainingTimeout expired error = %v", err) } client.Abort() - if _, err := client.lookupRemainingTimeout(time.Now().Add(time.Second)); !errors.Is(err, protocol.ErrAborted) { + if _, err := client.lookupRemainingTimeout(monotime.After(time.Second)); !errors.Is(err, protocol.ErrAborted) { t.Fatalf("lookupRemainingTimeout aborted error = %v", err) } } @@ -287,7 +292,7 @@ func TestClientTimeoutAbortCapacityAndRetryHelpers(t *testing.T) { } client.state = StateReady - if err := client.callWithRetry(func() error { return nil }); err != nil { + if err := client.callWithRetry(0, func() error { return nil }); err != nil { t.Fatalf("successful retry wrapper error = %v", err) } if client.callCount != 1 { @@ -295,13 +300,13 @@ func TestClientTimeoutAbortCapacityAndRetryHelpers(t *testing.T) { } client.Abort() - if err := client.callWithRetry(func() error { return nil }); !errors.Is(err, protocol.ErrAborted) { + if err := client.callWithRetry(0, func() error { return nil }); !errors.Is(err, protocol.ErrAborted) { t.Fatalf("aborted retry wrapper error = %v, want ErrAborted", err) } client = NewIncrementClient("", "", testLookupClientConfig()) client.state = StateReady - if err := client.callWithRetry(func() error { return protocol.ErrTimeout }); !errors.Is(err, protocol.ErrTimeout) { + if err := client.callWithRetry(0, func() error { return protocol.ErrTimeout }); !errors.Is(err, protocol.ErrTimeout) { t.Fatalf("timeout retry wrapper error = %v, want ErrTimeout", err) } if client.state != StateBroken { @@ -310,7 +315,7 @@ func TestClientTimeoutAbortCapacityAndRetryHelpers(t *testing.T) { client = NewIncrementClient("", "", testLookupClientConfig()) client.state = StateReady - if err := client.callWithRetry(func() error { return protocol.ErrBadLayout }); !errors.Is(err, protocol.ErrBadLayout) { + if err := client.callWithRetry(0, func() error { return protocol.ErrBadLayout }); !errors.Is(err, protocol.ErrBadLayout) { t.Fatalf("non-overflow retry wrapper error = %v, want ErrBadLayout", err) } if client.errorCount == 0 { @@ -319,7 +324,7 @@ func TestClientTimeoutAbortCapacityAndRetryHelpers(t *testing.T) { client = NewIncrementClient("", "", testLookupClientConfig()) client.state = StateReady - if err := client.callWithRetry(func() error { return protocol.ErrOverflow }); !errors.Is(err, protocol.ErrOverflow) { + if err := client.callWithRetry(0, func() error { return protocol.ErrOverflow }); !errors.Is(err, protocol.ErrOverflow) { t.Fatalf("overflow retry wrapper error = %v, want ErrOverflow", err) } if client.errorCount == 0 { @@ -355,7 +360,7 @@ func TestLookupClientCapacityAndWaitHelpers(t *testing.T) { func TestLookupReadyAndCapacityGuards(t *testing.T) { client := &Client{abortCh: make(chan struct{})} - if err := client.ensureReadyForLogicalLookup(); !errors.Is(err, protocol.ErrBadLayout) { + if err := client.ensureReadyForLogicalLookup(); !errors.Is(err, protocol.ErrNotReady) { t.Fatalf("not-ready logical lookup error = %v", err) } if client.errorCount != 1 { @@ -372,30 +377,31 @@ func TestLookupReadyAndCapacityGuards(t *testing.T) { } client = &Client{abortCh: make(chan struct{})} - if err := client.ensureLookupRequestCapacity(-1); !errors.Is(err, protocol.ErrOverflow) { + if err := client.ensureLookupRequestCapacity(-1, monotime.After(time.Second)); !errors.Is(err, protocol.ErrOverflow) { t.Fatalf("negative request capacity error = %v", err) } if strconv.IntSize >= 64 { - if err := client.ensureLookupRequestCapacity(int(uint64(^uint32(0)) + 1)); !errors.Is(err, protocol.ErrOverflow) { + maxU32 := uint64(^uint32(0)) + if err := client.ensureLookupRequestCapacity(int(maxU32+1), monotime.After(time.Second)); !errors.Is(err, protocol.ErrOverflow) { t.Fatalf("unrepresentable request capacity error = %v", err) } } client = &Client{abortCh: make(chan struct{})} - if err := client.ensureLookupRequestCapacity(int(protocol.MaxPayloadDefault) + 1); !errors.Is(err, protocol.ErrOverflow) { + if err := client.ensureLookupRequestCapacity(int(protocol.MaxPayloadDefault)+1, monotime.After(time.Second)); !errors.Is(err, protocol.ErrOverflow) { t.Fatalf("default request capacity error = %v", err) } client.config.MaxRequestPayloadBytes = 8 - if err := client.ensureLookupRequestCapacity(9); !errors.Is(err, protocol.ErrOverflow) { + if err := client.ensureLookupRequestCapacity(9, monotime.After(time.Second)); !errors.Is(err, protocol.ErrOverflow) { t.Fatalf("configured request capacity error = %v", err) } client.config.MaxRequestPayloadBytes = 16 - if err := client.ensureLookupRequestCapacity(16); err != nil { + if err := client.ensureLookupRequestCapacity(16, monotime.After(time.Second)); err != nil { t.Fatalf("sessionless request capacity = %v", err) } client = &Client{abortCh: make(chan struct{}), state: StateReady} client.Abort() - if err := client.ensureLookupRequestCapacity(1); !errors.Is(err, protocol.ErrAborted) { + if err := client.ensureLookupRequestCapacity(1, monotime.After(time.Second)); !errors.Is(err, protocol.ErrAborted) { t.Fatalf("aborted capacity growth error = %v, want ErrAborted", err) } if client.state != StateBroken || client.errorCount != 1 { @@ -440,7 +446,8 @@ func TestIncrementBatchRejectsUnrepresentableItemCount(t *testing.T) { if strconv.IntSize < 64 { t.Skip("synthetic oversized slice guard requires 64-bit int") } - if _, err := incrementBatchRequestSize(int(uint64(^uint32(0)) + 1)); !errors.Is(err, protocol.ErrOverflow) { + maxU32 := uint64(^uint32(0)) + if _, err := incrementBatchRequestSize(int(maxU32 + 1)); !errors.Is(err, protocol.ErrOverflow) { t.Fatalf("huge increment batch error = %v, want ErrOverflow", err) } } @@ -671,7 +678,7 @@ func TestSimpleRawDispatchGuardCoverage(t *testing.T) { if protocol.StringReverseEncode("abc", req) == 0 { t.Fatal("encode string reverse request") } - dispatch := StringReverseDispatch(func(s string) (string, bool) { + dispatch := StringReverseDispatch(func([]byte) (string, bool) { return "cba", true }) resp := make([]byte, protocol.StringReverseHdrSize+4) @@ -680,13 +687,13 @@ func TestSimpleRawDispatchGuardCoverage(t *testing.T) { t.Fatalf("string reverse dispatch success = n %d err %v", n, err) } view, err := protocol.StringReverseDecode(resp[:n]) - if err != nil || view.Str != "cba" { + if err != nil || string(view.Str) != "cba" { t.Fatalf("string reverse dispatch view = %+v err %v", view, err) } if n, err := dispatch(req[:1], resp); n != 0 || !errors.Is(err, protocol.ErrTruncated) { t.Fatalf("string reverse bad request = n %d err %v, want 0 ErrTruncated", n, err) } - failing := StringReverseDispatch(func(string) (string, bool) { + failing := StringReverseDispatch(func([]byte) (string, bool) { return "", false }) if n, err := failing(req, resp); n != 0 || !errors.Is(err, errHandlerFailed) { diff --git a/src/go/pkg/netipc/service/raw/lookup_unix_test.go b/src/go/pkg/netipc/service/raw/lookup_unix_test.go index a71cc332..acd8aac3 100644 --- a/src/go/pkg/netipc/service/raw/lookup_unix_test.go +++ b/src/go/pkg/netipc/service/raw/lookup_unix_test.go @@ -8,9 +8,11 @@ import ( "fmt" "sync" "sync/atomic" + "syscall" "testing" "time" + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/monotime" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/posix" ) @@ -123,14 +125,7 @@ func startLookupTestServerWithConfig(service string, method uint16, handler Disp cleanupAll(service) s := NewServerWithWorkers(testRunDir, service, config, method, handler, workers) - doneCh := make(chan struct{}) - - go func() { - defer close(doneCh) - s.Run() - }() - - waitUnixServerReady(service) + doneCh := runServerForTest(s) return &testServer{server: s, doneCh: doneCh} } @@ -935,7 +930,7 @@ func TestLookupLogicalLimits(t *testing.T) { ccfg.MaxRequestPayloadBytes = 48 client := NewCgroupsLookupClient(testRunDir, "unused", ccfg) _, err := client.CallCgroupsLookup([][]byte{[]byte("/request-key-too-large-for-configured-cap")}) - if !errors.Is(err, protocol.ErrBadLayout) { + if !errors.Is(err, protocol.ErrNotReady) { t.Fatalf("expected not-ready failure before local oversized response, got %v", err) } client.Close() @@ -959,7 +954,7 @@ func TestLookupRequestCapacityReconnectPaths(t *testing.T) { } client.session.MaxRequestPayloadBytes = 64 - if err := client.ensureLookupRequestCapacity(128); err != nil { + if err := client.ensureLookupRequestCapacity(128, monotime.After(time.Second)); err != nil { t.Fatalf("capacity reconnect should succeed: %v", err) } if client.state != StateReady { @@ -970,14 +965,14 @@ func TestLookupRequestCapacityReconnectPaths(t *testing.T) { } client.session.MaxRequestPayloadBytes = 256 - if err := client.ensureLookupRequestCapacity(128); err != nil { + if err := client.ensureLookupRequestCapacity(128, monotime.After(time.Second)); err != nil { t.Fatalf("already-large session capacity = %v", err) } ts.stop() cleanupAll(svc) client.session.MaxRequestPayloadBytes = 64 - if err := client.ensureLookupRequestCapacity(128); !errors.Is(err, protocol.ErrOverflow) { + if err := client.ensureLookupRequestCapacity(128, monotime.After(time.Second)); !errors.Is(err, protocol.ErrOverflow) { t.Fatalf("capacity reconnect to stopped service = %v, want ErrOverflow", err) } if client.state == StateReady { @@ -987,7 +982,7 @@ func TestLookupRequestCapacityReconnectPaths(t *testing.T) { missing := NewAppsLookupClient(testRunDir, uniqueUnixService("go_svc_lookup_request_capacity_missing"), ccfg) defer missing.Close() missing.state = StateReady - if err := missing.ensureLookupRequestCapacity(128); err != nil { + if err := missing.ensureLookupRequestCapacity(128, monotime.After(time.Second)); err != nil { t.Fatalf("sessionless configured capacity should not reconnect: %v", err) } } @@ -1052,16 +1047,87 @@ func TestLookupCallsReconnectForRequestCapacityGrowth(t *testing.T) { }) } +// A capacity reconnect that fails ends the lookup with the reconnect's +// error, as in C and Rust: here a socket that never answers HELLO, so the +// lookup deadline ends it. +func TestLookupCapacityReconnectFailureReported(t *testing.T) { + t.Run("apps", func(t *testing.T) { + svc := uniqueUnixService("go_svc_apps_lookup_capacity_fail") + cfg := testServerConfig() + cfg.MaxRequestPayloadBytes = 256 + ts := startLookupTestServerWithConfig(svc, protocol.MethodAppsLookup, AppsLookupDispatch(testAppsLookupHandler), cfg, 8) + ccfg := testClientConfig() + ccfg.MaxRequestPayloadBytes = 256 + client := NewAppsLookupClient(testRunDir, svc, ccfg) + defer client.Close() + refreshUnixClientReady(t, client) + ts.stop() + fd := rawListener(t, svc) + defer syscall.Close(fd) + defer cleanupAll(svc) + client.session.MaxRequestPayloadBytes = 8 + + if _, err := client.CallAppsLookupWithTimeout([]uint32{1234}, 100); !errors.Is(err, protocol.ErrTimeout) { + t.Fatalf("apps lookup with a failed capacity reconnect = %v, want ErrTimeout", err) + } + }) + + t.Run("cgroups", func(t *testing.T) { + svc := uniqueUnixService("go_svc_cgroups_lookup_capacity_fail") + cfg := testServerConfig() + cfg.MaxRequestPayloadBytes = 256 + ts := startLookupTestServerWithConfig(svc, protocol.MethodCgroupsLookup, CgroupsLookupDispatch(testCgroupsLookupHandler), cfg, 8) + ccfg := testClientConfig() + ccfg.MaxRequestPayloadBytes = 256 + client := NewCgroupsLookupClient(testRunDir, svc, ccfg) + defer client.Close() + refreshUnixClientReady(t, client) + ts.stop() + fd := rawListener(t, svc) + defer syscall.Close(fd) + defer cleanupAll(svc) + client.session.MaxRequestPayloadBytes = 8 + + if _, err := client.CallCgroupsLookupWithTimeout([][]byte{[]byte("/known")}, 100); !errors.Is(err, protocol.ErrTimeout) { + t.Fatalf("cgroups lookup with a failed capacity reconnect = %v, want ErrTimeout", err) + } + }) + + // The item fits the client's limit, so a failed reconnect is an error, + // not an oversized item. + t.Run("cgroups endpoint gone", func(t *testing.T) { + svc := uniqueUnixService("go_svc_cgroups_lookup_capacity_gone") + cfg := testServerConfig() + cfg.MaxRequestPayloadBytes = 256 + ts := startLookupTestServerWithConfig(svc, protocol.MethodCgroupsLookup, CgroupsLookupDispatch(testCgroupsLookupHandler), cfg, 8) + ccfg := testClientConfig() + ccfg.MaxRequestPayloadBytes = 256 + client := NewCgroupsLookupClient(testRunDir, svc, ccfg) + defer client.Close() + refreshUnixClientReady(t, client) + ts.stop() + cleanupAll(svc) + client.session.MaxRequestPayloadBytes = 8 + + view, err := client.CallCgroupsLookup([][]byte{[]byte("/known")}) + if !errors.Is(err, protocol.ErrOverflow) { + t.Fatalf("cgroups lookup after its endpoint left = view %+v, err %v; want ErrOverflow", view, err) + } + }) +} + func TestLookupTimeoutDuringFollowupSubcall(t *testing.T) { t.Run("apps", func(t *testing.T) { svc := uniqueUnixService("go_svc_apps_lookup_followup_timeout") cfg := testServerConfig() cfg.MaxResponsePayloadBytes = 320 var calls atomic.Uint32 + // The second subcall is held until the client has given up on it. + release := make(chan struct{}) ts := startLookupTestServerWithConfig(svc, protocol.MethodAppsLookup, AppsLookupDispatch( func(req *protocol.AppsLookupRequestView, builder *protocol.AppsLookupBuilder) bool { if calls.Add(1) == 2 { - time.Sleep(150 * time.Millisecond) + <-release } builder.SetGeneration(9) for i := uint32(0); i < req.ItemCount; i++ { @@ -1081,12 +1147,16 @@ func TestLookupTimeoutDuringFollowupSubcall(t *testing.T) { }, ), cfg, 8) defer ts.stop() + defer close(release) client := NewAppsLookupClient(testRunDir, svc, testClientConfig()) defer client.Close() refreshUnixClientReady(t, client) - _, err := client.CallAppsLookupWithTimeout([]uint32{11, 22, 33}, 30) + // The budget leaves slow or emulated runners room for the first + // subcall; only the held second subcall can expire it. + + _, err := client.CallAppsLookupWithTimeout([]uint32{11, 22, 33}, 500) if !errors.Is(err, protocol.ErrTimeout) { t.Fatalf("apps follow-up timeout error = %v, want ErrTimeout", err) } @@ -1100,10 +1170,12 @@ func TestLookupTimeoutDuringFollowupSubcall(t *testing.T) { cfg := testServerConfig() cfg.MaxResponsePayloadBytes = 160 var calls atomic.Uint32 + // The second subcall is held until the client has given up on it. + release := make(chan struct{}) ts := startLookupTestServerWithConfig(svc, protocol.MethodCgroupsLookup, CgroupsLookupDispatch( func(req *protocol.CgroupsLookupRequestView, builder *protocol.CgroupsLookupBuilder) bool { if calls.Add(1) == 2 { - time.Sleep(150 * time.Millisecond) + <-release } builder.SetGeneration(7) for i := uint32(0); i < req.ItemCount; i++ { @@ -1123,12 +1195,16 @@ func TestLookupTimeoutDuringFollowupSubcall(t *testing.T) { }, ), cfg, 8) defer ts.stop() + defer close(release) client := NewCgroupsLookupClient(testRunDir, svc, testClientConfig()) defer client.Close() refreshUnixClientReady(t, client) - _, err := client.CallCgroupsLookupWithTimeout([][]byte{[]byte("/a"), []byte("/huge"), []byte("/b")}, 30) + // The budget leaves slow or emulated runners room for the first + // subcall; only the held second subcall can expire it. + + _, err := client.CallCgroupsLookupWithTimeout([][]byte{[]byte("/a"), []byte("/huge"), []byte("/b")}, 500) if !errors.Is(err, protocol.ErrTimeout) { t.Fatalf("cgroups follow-up timeout error = %v, want ErrTimeout", err) } @@ -1144,12 +1220,14 @@ func TestLookupAbortDuringFollowupSubcall(t *testing.T) { cfg := testServerConfig() cfg.MaxResponsePayloadBytes = 320 var calls atomic.Uint32 + // The second subcall is held until the client has given up on it. + release := make(chan struct{}) secondCall := make(chan struct{}, 1) ts := startLookupTestServerWithConfig(svc, protocol.MethodAppsLookup, AppsLookupDispatch( func(req *protocol.AppsLookupRequestView, builder *protocol.AppsLookupBuilder) bool { if calls.Add(1) == 2 { secondCall <- struct{}{} - time.Sleep(250 * time.Millisecond) + <-release } builder.SetGeneration(9) for i := uint32(0); i < req.ItemCount; i++ { @@ -1169,6 +1247,7 @@ func TestLookupAbortDuringFollowupSubcall(t *testing.T) { }, ), cfg, 8) defer ts.stop() + defer close(release) client := NewAppsLookupClient(testRunDir, svc, testClientConfig()) defer client.Close() @@ -1202,12 +1281,14 @@ func TestLookupAbortDuringFollowupSubcall(t *testing.T) { cfg := testServerConfig() cfg.MaxResponsePayloadBytes = 160 var calls atomic.Uint32 + // The second subcall is held until the client has given up on it. + release := make(chan struct{}) secondCall := make(chan struct{}, 1) ts := startLookupTestServerWithConfig(svc, protocol.MethodCgroupsLookup, CgroupsLookupDispatch( func(req *protocol.CgroupsLookupRequestView, builder *protocol.CgroupsLookupBuilder) bool { if calls.Add(1) == 2 { secondCall <- struct{}{} - time.Sleep(250 * time.Millisecond) + <-release } builder.SetGeneration(7) for i := uint32(0); i < req.ItemCount; i++ { @@ -1227,6 +1308,7 @@ func TestLookupAbortDuringFollowupSubcall(t *testing.T) { }, ), cfg, 8) defer ts.stop() + defer close(release) client := NewCgroupsLookupClient(testRunDir, svc, testClientConfig()) defer client.Close() @@ -1940,8 +2022,8 @@ func TestLookupEndpointAbsentBeforeCall(t *testing.T) { if client.state != StateNotFound { t.Fatalf("apps absent state = %d, want StateNotFound", client.state) } - if _, err := client.CallAppsLookupWithTimeout([]uint32{11, 22}, 1000); !errors.Is(err, protocol.ErrBadLayout) { - t.Fatalf("apps absent lookup error = %v, want ErrBadLayout", err) + if _, err := client.CallAppsLookupWithTimeout([]uint32{11, 22}, 1000); !errors.Is(err, protocol.ErrNotReady) { + t.Fatalf("apps absent lookup error = %v, want ErrNotReady", err) } }) @@ -1959,8 +2041,8 @@ func TestLookupEndpointAbsentBeforeCall(t *testing.T) { t.Fatalf("cgroups absent state = %d, want StateNotFound", client.state) } paths := [][]byte{[]byte("/a"), []byte("/b")} - if _, err := client.CallCgroupsLookupWithTimeout(paths, 1000); !errors.Is(err, protocol.ErrBadLayout) { - t.Fatalf("cgroups absent lookup error = %v, want ErrBadLayout", err) + if _, err := client.CallCgroupsLookupWithTimeout(paths, 1000); !errors.Is(err, protocol.ErrNotReady) { + t.Fatalf("cgroups absent lookup error = %v, want ErrNotReady", err) } }) } @@ -1998,7 +2080,6 @@ func TestCgroupsLookupRetryOnFailure(t *testing.T) { ts1.stop() cleanupAll(svc) - time.Sleep(50 * time.Millisecond) ts2 := startLookupTestServer(svc, protocol.MethodCgroupsLookup, CgroupsLookupDispatch(testCgroupsLookupHandler)) defer ts2.stop() @@ -2032,13 +2113,7 @@ func TestLookupConcurrentClients(t *testing.T) { wg.Go(func() { client := NewCgroupsLookupClient(testRunDir, svc, testClientConfig()) defer client.Close() - for range 200 { - client.Refresh() - if client.Ready() { - break - } - time.Sleep(5 * time.Millisecond) - } + client.Refresh() if !client.Ready() { errs <- fmt.Errorf("client not ready") return @@ -2081,13 +2156,7 @@ func TestLookupConcurrentClients(t *testing.T) { wg.Go(func() { client := NewAppsLookupClient(testRunDir, svc, testClientConfig()) defer client.Close() - for range 200 { - client.Refresh() - if client.Ready() { - break - } - time.Sleep(5 * time.Millisecond) - } + client.Refresh() if !client.Ready() { errs <- fmt.Errorf("client not ready") return diff --git a/src/go/pkg/netipc/service/raw/lookup_windows_test.go b/src/go/pkg/netipc/service/raw/lookup_windows_test.go index 1076a44b..5e856db4 100644 --- a/src/go/pkg/netipc/service/raw/lookup_windows_test.go +++ b/src/go/pkg/netipc/service/raw/lookup_windows_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/monotime" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" windows "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/windows" ) @@ -520,7 +521,7 @@ func TestWinLookupProactiveRequestSplitAndLimits(t *testing.T) { ccfg.MaxRequestPayloadBytes = 48 client := NewCgroupsLookupClient(winTestRunDir, "unused", ccfg) _, err := client.CallCgroupsLookup([][]byte{[]byte("/request-key-too-large-for-configured-cap")}) - if !errors.Is(err, protocol.ErrBadLayout) { + if !errors.Is(err, protocol.ErrNotReady) { t.Fatalf("expected not-ready failure before local oversized response, got %v", err) } client.Close() @@ -719,7 +720,7 @@ func TestWinLookupRequestCapacityReconnectPaths(t *testing.T) { } client.session.MaxRequestPayloadBytes = 64 - if err := client.ensureLookupRequestCapacity(128); err != nil { + if err := client.ensureLookupRequestCapacity(128, monotime.After(time.Second)); err != nil { t.Fatalf("capacity reconnect should succeed: %v", err) } if client.state != StateReady { @@ -730,14 +731,14 @@ func TestWinLookupRequestCapacityReconnectPaths(t *testing.T) { } client.session.MaxRequestPayloadBytes = 256 - if err := client.ensureLookupRequestCapacity(128); err != nil { + if err := client.ensureLookupRequestCapacity(128, monotime.After(time.Second)); err != nil { t.Fatalf("already-large session capacity = %v", err) } missing := NewAppsLookupClient(winTestRunDir, uniqueWinService("go_win_lookup_request_capacity_missing"), ccfg) defer missing.Close() missing.state = StateReady - if err := missing.ensureLookupRequestCapacity(128); err != nil { + if err := missing.ensureLookupRequestCapacity(128, monotime.After(time.Second)); err != nil { t.Fatalf("sessionless configured capacity should not reconnect: %v", err) } }) @@ -759,7 +760,7 @@ func TestWinLookupRequestCapacityReconnectPaths(t *testing.T) { client.session.MaxRequestPayloadBytes = 64 ts.stop() - if err := client.ensureLookupRequestCapacity(128); !errors.Is(err, protocol.ErrOverflow) { + if err := client.ensureLookupRequestCapacity(128, monotime.After(time.Second)); !errors.Is(err, protocol.ErrOverflow) { t.Fatalf("missing endpoint capacity reconnect error = %v, want ErrOverflow", err) } if client.errorCount == 0 { @@ -835,10 +836,12 @@ func TestWinLookupTimeoutDuringFollowupSubcall(t *testing.T) { cfg := testWinServerConfig() cfg.MaxResponsePayloadBytes = 320 var calls atomic.Uint32 + // The second subcall is held until the client has given up on it. + release := make(chan struct{}) ts := startTestServerWinWithConfig(svc, cfg, protocol.MethodAppsLookup, AppsLookupDispatch( func(req *protocol.AppsLookupRequestView, builder *protocol.AppsLookupBuilder) bool { if calls.Add(1) == 2 { - time.Sleep(150 * time.Millisecond) + <-release } builder.SetGeneration(9) for i := uint32(0); i < req.ItemCount; i++ { @@ -858,12 +861,16 @@ func TestWinLookupTimeoutDuringFollowupSubcall(t *testing.T) { }, )) defer ts.stop() + defer close(release) client := NewAppsLookupClient(winTestRunDir, svc, testWinClientConfig()) defer client.Close() waitWinClientReady(t, client) - _, err := client.CallAppsLookupWithTimeout([]uint32{11, 22, 33}, 30) + // The budget leaves slow or emulated runners room for the first + // subcall; only the held second subcall can expire it. + + _, err := client.CallAppsLookupWithTimeout([]uint32{11, 22, 33}, 500) if !errors.Is(err, protocol.ErrTimeout) { t.Fatalf("apps follow-up timeout error = %v, want ErrTimeout", err) } @@ -877,10 +884,12 @@ func TestWinLookupTimeoutDuringFollowupSubcall(t *testing.T) { cfg := testWinServerConfig() cfg.MaxResponsePayloadBytes = 160 var calls atomic.Uint32 + // The second subcall is held until the client has given up on it. + release := make(chan struct{}) ts := startTestServerWinWithConfig(svc, cfg, protocol.MethodCgroupsLookup, CgroupsLookupDispatch( func(req *protocol.CgroupsLookupRequestView, builder *protocol.CgroupsLookupBuilder) bool { if calls.Add(1) == 2 { - time.Sleep(150 * time.Millisecond) + <-release } builder.SetGeneration(7) for i := uint32(0); i < req.ItemCount; i++ { @@ -900,12 +909,16 @@ func TestWinLookupTimeoutDuringFollowupSubcall(t *testing.T) { }, )) defer ts.stop() + defer close(release) client := NewCgroupsLookupClient(winTestRunDir, svc, testWinClientConfig()) defer client.Close() waitWinClientReady(t, client) - _, err := client.CallCgroupsLookupWithTimeout([][]byte{[]byte("/a"), []byte("/huge"), []byte("/b")}, 30) + // The budget leaves slow or emulated runners room for the first + // subcall; only the held second subcall can expire it. + + _, err := client.CallCgroupsLookupWithTimeout([][]byte{[]byte("/a"), []byte("/huge"), []byte("/b")}, 500) if !errors.Is(err, protocol.ErrTimeout) { t.Fatalf("cgroups follow-up timeout error = %v, want ErrTimeout", err) } @@ -921,12 +934,14 @@ func TestWinLookupAbortDuringFollowupSubcall(t *testing.T) { cfg := testWinServerConfig() cfg.MaxResponsePayloadBytes = 320 var calls atomic.Uint32 + // The second subcall is held until the client has given up on it. + release := make(chan struct{}) secondCall := make(chan struct{}, 1) ts := startTestServerWinWithConfig(svc, cfg, protocol.MethodAppsLookup, AppsLookupDispatch( func(req *protocol.AppsLookupRequestView, builder *protocol.AppsLookupBuilder) bool { if calls.Add(1) == 2 { secondCall <- struct{}{} - time.Sleep(250 * time.Millisecond) + <-release } builder.SetGeneration(9) for i := uint32(0); i < req.ItemCount; i++ { @@ -946,6 +961,7 @@ func TestWinLookupAbortDuringFollowupSubcall(t *testing.T) { }, )) defer ts.stop() + defer close(release) client := NewAppsLookupClient(winTestRunDir, svc, testWinClientConfig()) defer client.Close() @@ -979,12 +995,14 @@ func TestWinLookupAbortDuringFollowupSubcall(t *testing.T) { cfg := testWinServerConfig() cfg.MaxResponsePayloadBytes = 160 var calls atomic.Uint32 + // The second subcall is held until the client has given up on it. + release := make(chan struct{}) secondCall := make(chan struct{}, 1) ts := startTestServerWinWithConfig(svc, cfg, protocol.MethodCgroupsLookup, CgroupsLookupDispatch( func(req *protocol.CgroupsLookupRequestView, builder *protocol.CgroupsLookupBuilder) bool { if calls.Add(1) == 2 { secondCall <- struct{}{} - time.Sleep(250 * time.Millisecond) + <-release } builder.SetGeneration(7) for i := uint32(0); i < req.ItemCount; i++ { @@ -1004,6 +1022,7 @@ func TestWinLookupAbortDuringFollowupSubcall(t *testing.T) { }, )) defer ts.stop() + defer close(release) client := NewCgroupsLookupClient(winTestRunDir, svc, testWinClientConfig()) defer client.Close() @@ -1849,8 +1868,8 @@ func TestWinLookupEndpointAbsentBeforeCall(t *testing.T) { if client.state != StateNotFound { t.Fatalf("apps absent state = %d, want StateNotFound", client.state) } - if _, err := client.CallAppsLookupWithTimeout([]uint32{11, 22}, 1000); !errors.Is(err, protocol.ErrBadLayout) { - t.Fatalf("apps absent lookup error = %v, want ErrBadLayout", err) + if _, err := client.CallAppsLookupWithTimeout([]uint32{11, 22}, 1000); !errors.Is(err, protocol.ErrNotReady) { + t.Fatalf("apps absent lookup error = %v, want ErrNotReady", err) } }) @@ -1865,8 +1884,8 @@ func TestWinLookupEndpointAbsentBeforeCall(t *testing.T) { t.Fatalf("cgroups absent state = %d, want StateNotFound", client.state) } paths := [][]byte{[]byte("/a"), []byte("/b")} - if _, err := client.CallCgroupsLookupWithTimeout(paths, 1000); !errors.Is(err, protocol.ErrBadLayout) { - t.Fatalf("cgroups absent lookup error = %v, want ErrBadLayout", err) + if _, err := client.CallCgroupsLookupWithTimeout(paths, 1000); !errors.Is(err, protocol.ErrNotReady) { + t.Fatalf("cgroups absent lookup error = %v, want ErrNotReady", err) } }) } diff --git a/src/go/pkg/netipc/service/raw/more_unix_test.go b/src/go/pkg/netipc/service/raw/more_unix_test.go index 2c1ae78c..fee39c4b 100644 --- a/src/go/pkg/netipc/service/raw/more_unix_test.go +++ b/src/go/pkg/netipc/service/raw/more_unix_test.go @@ -78,7 +78,7 @@ func startRawPosixHelloAckServer(t *testing.T, service string, packet []byte) *u path := filepath.Join(testRunDir, service+".sock") _ = os.Remove(path) - fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_SEQPACKET, 0) + fd, err := syscall.Socket(syscall.AF_UNIX, testSockType, 0) if err != nil { t.Fatalf("socket failed: %v", err) } @@ -119,7 +119,6 @@ func startRawPosixHelloAckServer(t *testing.T, service string, packet []byte) *u srv.doneCh <- nil }() - time.Sleep(100 * time.Millisecond) return srv } @@ -177,7 +176,6 @@ func startRawPosixSessionServerN( srv.doneCh <- nil }() - time.Sleep(200 * time.Millisecond) return srv } @@ -198,28 +196,27 @@ func (s *unixRawSessionServer) wait(t *testing.T) { } } +// refreshUnixClientReady refreshes once: servers in these tests are already +// listening, so a single connect must succeed. func refreshUnixClientReady(t *testing.T, client *Client) { t.Helper() - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - client.Refresh() - if client.Ready() { - return - } - time.Sleep(10 * time.Millisecond) + client.Refresh() + if !client.Ready() { + t.Fatalf("client not ready after one refresh, state=%d", client.state) } - t.Fatalf("client not ready, state=%d", client.state) } func TestUnixClientCallTimeoutOnWedgedPeer(t *testing.T) { cfg := testServerConfig() svc := uniqueUnixService("go_unix_call_timeout") + // The peer holds the request unanswered until the client has timed out. + timedOut := make(chan struct{}) srv := startRawPosixSessionServer(t, svc, cfg, func(session *posix.Session, hdr protocol.Header, payload []byte) error { _ = session _ = hdr _ = payload - time.Sleep(150 * time.Millisecond) + <-timedOut return nil }) @@ -230,6 +227,7 @@ func TestUnixClientCallTimeoutOnWedgedPeer(t *testing.T) { start := time.Now() _, err := client.CallIncrementWithTimeout(41, 30) elapsed := time.Since(start) + close(timedOut) if !errors.Is(err, protocol.ErrTimeout) { t.Fatalf("CallIncrementWithTimeout error = %v, want %v", err, protocol.ErrTimeout) } @@ -410,14 +408,7 @@ func startServerWithWorkersSocketReady( handler, workers, ) - doneCh := make(chan struct{}) - - go func() { - defer close(doneCh) - s.Run() - }() - - waitUnixSocketReady(service) + doneCh := runServerForTest(s) return &testServer{server: s, doneCh: doneCh} } @@ -431,14 +422,7 @@ func startServerWithConfigSocketReady( cleanupAll(service) s := NewServer(testRunDir, service, cfg, expectedMethodCode, handler) - doneCh := make(chan struct{}) - - go func() { - defer close(doneCh) - s.Run() - }() - - waitUnixSocketReady(service) + doneCh := runServerForTest(s) return &testServer{server: s, doneCh: doneCh} } @@ -457,12 +441,17 @@ func TestUnixServerStopWhileIdle(t *testing.T) { testSnapshotDispatch(), ) + server.timings = testServerTimings done := make(chan error, 1) go func() { done <- server.Run() }() - time.Sleep(100 * time.Millisecond) + select { + case <-server.Listening(): + case <-time.After(testWaitLimit): + t.Fatal("server never started listening") + } server.Stop() select { @@ -592,70 +581,6 @@ func TestUnixSingleResponseOverflowRetriesAndRecovers(t *testing.T) { } } -func TestUnixShmPrepareFailureFallsBackToBaseline(t *testing.T) { - svc := uniqueUnixService("go_unix_shm_create_fail") - obstruction := unixShmSessionPath(svc, 1) - - scfg := testUnixShmServerConfig() - ts := startServerWithConfigSocketReady( - svc, - scfg, - protocol.MethodIncrement, - IncrementDispatch(func(v uint64) (uint64, bool) { - return v + 1, true - }), - ) - defer func() { - ts.stop() - _ = os.RemoveAll(obstruction) - }() - - if err := os.MkdirAll(filepath.Join(obstruction, "keep"), 0700); err != nil { - t.Fatalf("mkdir SHM obstruction failed: %v", err) - } - - badClient := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) - defer badClient.Close() - - if changed := badClient.Refresh(); !changed { - t.Fatal("Refresh should transition to READY when baseline stays viable") - } - if !badClient.Ready() { - t.Fatal("client should stay usable over baseline after server SHM prepare failure") - } - if badClient.state != StateReady { - t.Fatalf("client state after SHM prepare failure = %d, want READY", badClient.state) - } - if badClient.shm != nil { - t.Fatalf("fallback baseline session must not attach SHM: selected=%#x session=%+v", badClient.session.SelectedProfile, badClient.session) - } - if badClient.session == nil || badClient.session.SelectedProfile != protocol.ProfileBaseline { - t.Fatalf("selected profile after SHM prepare failure = %#v, want baseline", badClient.session) - } - - if _, err := os.Stat(obstruction); err != nil { - t.Fatalf("SHM obstruction should still exist after failed create, stat err = %v", err) - } - if err := os.RemoveAll(obstruction); err != nil { - t.Fatalf("remove SHM obstruction failed: %v", err) - } - - goodClient := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) - defer goodClient.Close() - refreshUnixClientReady(t, goodClient) - - if goodClient.shm == nil { - t.Fatal("expected SHM attachment after obstruction removal") - } - got, err := goodClient.CallIncrement(4) - if err != nil { - t.Fatalf("CallIncrement after SHM create recovery failed: %v", err) - } - if got != 5 { - t.Fatalf("CallIncrement after SHM create recovery = %d, want 5", got) - } -} - func TestUnixServerRunRejectsInvalidServiceName(t *testing.T) { server := NewServer( testRunDir, @@ -680,7 +605,6 @@ func TestUnixServerRejectsSessionAtWorkerCapacity(t *testing.T) { released = true } } - defer releaseOnce() var handlerCalls atomic.Int32 ts := startServerWithWorkersSocketReady( @@ -700,6 +624,9 @@ func TestUnixServerRejectsSessionAtWorkerCapacity(t *testing.T) { 1, ) defer ts.stop() + // Deferred after ts.stop so it runs first: a t.Fatal must unblock the + // handler, or stopping the server waits for it forever. + defer releaseOnce() client1 := NewIncrementClient(testRunDir, svc, testClientConfig()) defer client1.Close() @@ -721,40 +648,16 @@ func TestUnixServerRejectsSessionAtWorkerCapacity(t *testing.T) { t.Fatal("first client did not occupy the only worker slot") } + // Over capacity the server closes before HELLO_ACK, so the connect fails. ccfg := testClientConfig() session2, err := posix.Connect(testRunDir, svc, &ccfg) - if err != nil { - t.Fatalf("second raw connect failed: %v", err) - } - defer session2.Close() - - time.Sleep(100 * time.Millisecond) - - reqHdr := &protocol.Header{ - Kind: protocol.KindRequest, - Code: protocol.MethodIncrement, - ItemCount: 1, - MessageID: 1, - TransportStatus: protocol.StatusOK, - } - var reqPayload [protocol.IncrementPayloadSize]byte - if protocol.IncrementEncode(9, reqPayload[:]) == 0 { - t.Fatal("IncrementEncode failed") - } - - sendErr := session2.Send(reqHdr, reqPayload[:]) - var recvErr error - if sendErr == nil { - recvBuf := make([]byte, protocol.HeaderSize+64) - _, _, recvErr = session2.Receive(recvBuf) - } - if sendErr == nil && recvErr == nil { - t.Fatal("second session should be rejected while the server is at worker capacity") + if err == nil { + session2.Close() + t.Fatal("second connect should fail while the server is at worker capacity") } if handlerCalls.Load() != 1 { t.Fatalf("handler entered %d times, want 1 while second session is rejected", handlerCalls.Load()) } - session2.Close() releaseOnce() res := <-callDone @@ -766,13 +669,12 @@ func TestUnixServerRejectsSessionAtWorkerCapacity(t *testing.T) { } client1.Close() - time.Sleep(100 * time.Millisecond) - + // The slot frees when the first session's worker exits. + waitSessionExits(ts.server, 1) verify := NewIncrementClient(testRunDir, svc, testClientConfig()) - defer verify.Close() refreshUnixClientReady(t, verify) - got, err := verify.CallIncrement(1) + verify.Close() if err != nil { t.Fatalf("verification call after worker-capacity reject failed: %v", err) } @@ -792,8 +694,7 @@ func TestUnixIdlePeerDisconnectKeepsServerHealthy(t *testing.T) { t.Fatalf("raw connect failed: %v", err) } session.Close() - - time.Sleep(200 * time.Millisecond) + waitSessionExits(ts.server, 1) verify := NewSnapshotClient(testRunDir, svc, testClientConfig()) defer verify.Close() @@ -820,11 +721,11 @@ func TestUnixClientTransportWithoutSession(t *testing.T) { TransportStatus: protocol.StatusOK, } - if err := client.transportSend(&hdr, nil); !errors.Is(err, protocol.ErrTruncated) { - t.Fatalf("transportSend without session = %v, want ErrTruncated", err) + if err := client.transportSend(&hdr, nil, 0); !errors.Is(err, protocol.ErrTransport) { + t.Fatalf("transportSend without session = %v, want ErrTransport", err) } - if _, _, err := client.transportReceive(); !errors.Is(err, protocol.ErrTruncated) { - t.Fatalf("transportReceive without session = %v, want ErrTruncated", err) + if _, _, err := client.transportReceive(); !errors.Is(err, protocol.ErrTransport) { + t.Fatalf("transportReceive without session = %v, want ErrTransport", err) } } @@ -838,7 +739,7 @@ func TestUnixCallWithRetryStopsWhenOverflowCannotGrowCapacity(t *testing.T) { refreshUnixClientReady(t, client) attempts := 0 - err := client.callWithRetry(func() error { + err := client.callWithRetry(0, func() error { attempts++ return protocol.ErrOverflow }) @@ -864,7 +765,7 @@ func TestUnixCallWithRetryCapsOverflowGrowthRetries(t *testing.T) { client.config.MaxRequestPayloadBytes = protocol.MaxPayloadDefault attempts := 0 - err := client.callWithRetry(func() error { + err := client.callWithRetry(0, func() error { attempts++ client.config.MaxRequestPayloadBytes++ return protocol.ErrOverflow @@ -880,43 +781,6 @@ func TestUnixCallWithRetryCapsOverflowGrowthRetries(t *testing.T) { } } -func TestUnixClientTransportReceiveShmError(t *testing.T) { - client := NewIncrementClient(testRunDir, uniqueUnixService("go_unix_transport_shm_err"), testClientConfig()) - defer client.Close() - - client.state = StateReady - client.shm = &posix.ShmContext{} - - if _, _, err := client.transportReceive(); !errors.Is(err, protocol.ErrTruncated) { - t.Fatalf("transportReceive with invalid SHM context = %v, want %v", err, protocol.ErrTruncated) - } -} - -func TestUnixClientTransportReceiveShmRejectsShortMessage(t *testing.T) { - client, serverShm := newRawPosixShmClient(t) - - if err := serverShm.ShmSend([]byte{1, 2, 3, 4}); err != nil { - t.Fatalf("ShmSend failed: %v", err) - } - - if _, _, err := client.transportReceive(); !errors.Is(err, protocol.ErrTruncated) { - t.Fatalf("transportReceive short SHM message = %v, want %v", err, protocol.ErrTruncated) - } -} - -func TestUnixClientTransportReceiveShmRejectsBadHeader(t *testing.T) { - client, serverShm := newRawPosixShmClient(t) - - msg := make([]byte, protocol.HeaderSize) - if err := serverShm.ShmSend(msg); err != nil { - t.Fatalf("ShmSend failed: %v", err) - } - - if _, _, err := client.transportReceive(); !errors.Is(err, protocol.ErrBadMagic) { - t.Fatalf("transportReceive bad SHM header = %v, want %v", err, protocol.ErrBadMagic) - } -} - func TestUnixDispatchSingleUnsupportedMethods(t *testing.T) { server := NewServer( testRunDir, @@ -983,8 +847,8 @@ func TestUnixNonRequestTerminatesSession(t *testing.T) { t.Fatalf("send non-request failed: %v", err) } - time.Sleep(200 * time.Millisecond) - + // The server ends the session on the bad message, so the receive below + // fails instead of returning a response. req := protocol.CgroupsRequest{LayoutVersion: 1, Flags: 0} var reqBuf [4]byte if req.Encode(reqBuf[:]) == 0 { @@ -1032,10 +896,8 @@ func TestUnixTruncatedRawRequestKeepsServerHealthy(t *testing.T) { session.Close() t.Fatalf("send truncated raw request failed: %v", err) } - time.Sleep(100 * time.Millisecond) session.Close() - - time.Sleep(200 * time.Millisecond) + waitSessionExits(ts.server, 1) verify := NewSnapshotClient(testRunDir, svc, testClientConfig()) defer verify.Close() @@ -1052,12 +914,17 @@ func TestUnixTruncatedRawRequestKeepsServerHealthy(t *testing.T) { func TestUnixPeerDisconnectDuringResponseKeepsServerHealthy(t *testing.T) { svc := uniqueUnixService("go_unix_send_fail") + // The first request is answered only after its client has gone, so the + // response send fails. + clientGone := make(chan struct{}) ts := startTestServerUnixWithConfig( svc, testServerConfig(), protocol.MethodIncrement, IncrementDispatch(func(v uint64) (uint64, bool) { - time.Sleep(100 * time.Millisecond) + if v == 41 { + <-clientGone + } return v + 1, true }), ) @@ -1084,8 +951,8 @@ func TestUnixPeerDisconnectDuringResponseKeepsServerHealthy(t *testing.T) { t.Fatalf("send increment request failed: %v", err) } session.Close() - - time.Sleep(250 * time.Millisecond) + close(clientGone) + waitSessionExits(ts.server, 1) verify := NewIncrementClient(testRunDir, svc, testClientConfig()) defer verify.Close() @@ -1135,12 +1002,7 @@ func TestUnixCallIncrementBatchWithMalformedTransportState(t *testing.T) { protocol.MethodIncrement, pingPongIncrementDispatch(), ) - doneCh := make(chan struct{}) - go func() { - defer close(doneCh) - s.Run() - }() - time.Sleep(100 * time.Millisecond) + doneCh := runServerForTest(s) defer func() { s.Stop() <-doneCh @@ -1674,3 +1536,53 @@ func TestUnixCallIncrementBatchRejectsMalformedPayload(t *testing.T) { cleanupAll(svc) }) } + +func TestUnixSendFailureWithLivePeerIsTransport(t *testing.T) { + ensureRunDir() + svc := uniqueUnixService("go_unix_send_live_peer") + cleanupAll(svc) + defer cleanupAll(svc) + + // The server accepts one session, then removes the endpoint so the client + // cannot reconnect, and keeps the session open until released. + listener, err := posix.Listen(testRunDir, svc, testServerConfig()) + if err != nil { + t.Fatalf("Listen failed: %v", err) + } + accepted := make(chan error, 1) + release := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + session, err := listener.Accept() + listener.Close() + accepted <- err + if err != nil { + return + } + <-release + session.Close() + }() + + client := NewIncrementClient(testRunDir, svc, testClientConfig()) + defer client.Close() + client.Refresh() + if client.state != StateReady { + t.Fatalf("client state = %d, want StateReady", client.state) + } + if err := <-accepted; err != nil { + t.Fatalf("Accept failed: %v", err) + } + + // Our own write shutdown makes send fail with EPIPE while the server still + // holds the session open. + if err := syscall.Shutdown(client.session.Fd(), syscall.SHUT_WR); err != nil { + t.Fatalf("shutdown: %v", err) + } + if _, err := client.CallIncrement(1); !errors.Is(err, protocol.ErrTransport) { + t.Fatalf("CallIncrement with live peer = %v, want ErrTransport", err) + } + + close(release) + <-done +} diff --git a/src/go/pkg/netipc/service/raw/more_windows_test.go b/src/go/pkg/netipc/service/raw/more_windows_test.go index fbc17847..4847267d 100644 --- a/src/go/pkg/netipc/service/raw/more_windows_test.go +++ b/src/go/pkg/netipc/service/raw/more_windows_test.go @@ -100,12 +100,14 @@ func TestWinClientRefreshFromReadyNoop(t *testing.T) { func TestWinClientCallTimeoutOnWedgedPeer(t *testing.T) { svc := uniqueWinService("go_win_call_timeout") + // The peer holds the request unanswered until the client has timed out. + timedOut := make(chan struct{}) srv := startRawWinSessionServer(t, svc, testWinServerConfig(), func(session *windows.Session, hdr protocol.Header, payload []byte) error { _ = session _ = hdr _ = payload - time.Sleep(150 * time.Millisecond) + <-timedOut return nil }) @@ -116,6 +118,7 @@ func TestWinClientCallTimeoutOnWedgedPeer(t *testing.T) { start := time.Now() _, err := client.CallIncrementWithTimeout(41, 30) elapsed := time.Since(start) + close(timedOut) if !errors.Is(err, protocol.ErrTimeout) { t.Fatalf("CallIncrementWithTimeout error = %v, want %v", err, protocol.ErrTimeout) } @@ -190,11 +193,9 @@ func TestWinServerStopWhileIdle(t *testing.T) { done <- server.Run() }() - deadline := time.Now().Add(2 * time.Second) - for server.listener == nil && time.Now().Before(deadline) { - time.Sleep(10 * time.Millisecond) - } - if server.listener == nil { + select { + case <-server.Listening(): + case <-time.After(testWaitLimit): t.Fatal("server listener did not start") } @@ -381,12 +382,12 @@ func TestWinClientTransportWithoutSession(t *testing.T) { TransportStatus: protocol.StatusOK, } - if err := client.transportSend(&hdr, nil); !errors.Is(err, protocol.ErrTruncated) { - t.Fatalf("transportSend without session = %v, want ErrTruncated", err) + if err := client.transportSend(&hdr, nil, 0); !errors.Is(err, protocol.ErrTransport) { + t.Fatalf("transportSend without session = %v, want ErrTransport", err) } - if _, _, err := client.transportReceive(); !errors.Is(err, protocol.ErrTruncated) { - t.Fatalf("transportReceive without session = %v, want ErrTruncated", err) + if _, _, err := client.transportReceive(); !errors.Is(err, protocol.ErrTransport) { + t.Fatalf("transportReceive without session = %v, want ErrTransport", err) } } @@ -397,8 +398,8 @@ func TestWinClientTransportReceiveWinShmError(t *testing.T) { client.state = StateReady client.shm = &windows.WinShmContext{} - if _, _, err := client.transportReceive(); !errors.Is(err, protocol.ErrTruncated) { - t.Fatalf("transportReceive with invalid WinSHM context = %v, want %v", err, protocol.ErrTruncated) + if _, _, err := client.transportReceive(); !errors.Is(err, protocol.ErrTransport) { + t.Fatalf("transportReceive with invalid WinSHM context = %v, want %v", err, protocol.ErrTransport) } } @@ -772,7 +773,7 @@ func TestWinCallWithRetryStopsWhenOverflowCannotGrowCapacity(t *testing.T) { waitWinClientReady(t, client) attempts := 0 - err := client.callWithRetry(func() error { + err := client.callWithRetry(0, func() error { attempts++ return protocol.ErrOverflow }) @@ -798,7 +799,7 @@ func TestWinCallWithRetryCapsOverflowGrowthRetries(t *testing.T) { client.config.MaxRequestPayloadBytes = protocol.MaxPayloadDefault attempts := 0 - err := client.callWithRetry(func() error { + err := client.callWithRetry(0, func() error { attempts++ client.config.MaxRequestPayloadBytes++ return protocol.ErrOverflow @@ -1130,7 +1131,8 @@ func TestWinIsErrorHelpers(t *testing.T) { t.Fatal("non-connect errors should not match connect classification") } - if !isAuthError(windows.ErrAuthFailed) || isAuthError(windows.ErrConnect) || isAuthError(nil) { + if !isAuthError(windows.ErrAuthFailed) || !isAuthError(windows.ErrUntrustedServer) || + isAuthError(windows.ErrConnect) || isAuthError(nil) { t.Fatal("auth error classification mismatch") } @@ -1162,7 +1164,8 @@ func TestWinNonRequestTerminatesSession(t *testing.T) { t.Fatalf("send non-request failed: %v", err) } - time.Sleep(200 * time.Millisecond) + // The server ends the session on the bad message, so the receive below + // fails instead of returning a response. req := protocol.CgroupsRequest{LayoutVersion: 1, Flags: 0} var reqBuf [4]byte @@ -1203,8 +1206,13 @@ func TestWinNonRequestTerminatesSession(t *testing.T) { func TestWinPeerDisconnectDuringResponseKeepsServerHealthy(t *testing.T) { svc := uniqueWinService("go_win_send_fail") + // The first request is answered only after its client has gone, so the + // response send fails. + clientGone := make(chan struct{}) ts := startTestServerWinWithConfig(svc, testWinServerConfig(), protocol.MethodIncrement, IncrementDispatch(func(v uint64) (uint64, bool) { - time.Sleep(100 * time.Millisecond) + if v == 41 { + <-clientGone + } return v + 1, true })) defer ts.stop() @@ -1230,8 +1238,8 @@ func TestWinPeerDisconnectDuringResponseKeepsServerHealthy(t *testing.T) { t.Fatalf("send increment request failed: %v", err) } session.Close() - - time.Sleep(250 * time.Millisecond) + close(clientGone) + waitSessionExits(ts.server, 1) verify := NewIncrementClient(winTestRunDir, svc, testWinClientConfig()) defer verify.Close() diff --git a/src/go/pkg/netipc/service/raw/ping_pong_test.go b/src/go/pkg/netipc/service/raw/ping_pong_test.go index b96822e8..f1fad72c 100644 --- a/src/go/pkg/netipc/service/raw/ping_pong_test.go +++ b/src/go/pkg/netipc/service/raw/ping_pong_test.go @@ -4,7 +4,6 @@ package raw import ( "testing" - "time" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" ) @@ -16,8 +15,8 @@ func pingPongIncrementDispatch() DispatchHandler { } func pingPongStringReverseDispatch() DispatchHandler { - return StringReverseDispatch(func(s string) (string, bool) { - return reverseString(s), true + return StringReverseDispatch(func(s []byte) (string, bool) { + return reverseString(string(s)), true }) } @@ -117,11 +116,12 @@ func TestStringReversePingPong(t *testing.T) { // verify response is the character-by-character reverse of the sent string expectedReversed := reverseString(current) - if view.Str != expectedReversed { + if string(view.Str) != expectedReversed { t.Fatalf("round %d: sent %q, expected reverse %q, got %q", i+1, current, expectedReversed, view.Str) } - current = view.Str + // The view borrows the client buffer that the next call reuses. + current = string(view.Str) } if responsesReceived != 6 { @@ -163,19 +163,12 @@ func TestIncrementBatch(t *testing.T) { protocol.MethodIncrement, pingPongIncrementDispatch(), ) - doneCh := make(chan struct{}) - go func() { - defer close(doneCh) - s.Run() - }() + doneCh := runServerForTest(s) defer func() { s.Stop() <-doneCh }() - // Wait for server - time.Sleep(100 * time.Millisecond) - // Client config with batch support cCfg := testClientConfig() cCfg.MaxRequestBatchItems = 16 diff --git a/src/go/pkg/netipc/service/raw/ping_pong_windows_test.go b/src/go/pkg/netipc/service/raw/ping_pong_windows_test.go index 580d0cab..7a74e34a 100644 --- a/src/go/pkg/netipc/service/raw/ping_pong_windows_test.go +++ b/src/go/pkg/netipc/service/raw/ping_pong_windows_test.go @@ -4,7 +4,6 @@ package raw import ( "testing" - "time" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" windows "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/windows" @@ -50,22 +49,12 @@ type winTestServer struct { // and starts it in a background goroutine. Returns a handle for cleanup. func startTestServerWin(service string, expectedMethodCode uint16, handler DispatchHandler) *winTestServer { s := NewServer(winTestRunDir, service, testWinServerConfig(), expectedMethodCode, handler) - doneCh := make(chan struct{}) - - go func() { - defer close(doneCh) - s.Run() - }() - - // Allow the Named Pipe listener to start - time.Sleep(200 * time.Millisecond) - - return &winTestServer{server: s, doneCh: doneCh} + return &winTestServer{server: s, doneCh: runServerForTest(s)} } func (ts *winTestServer) stop() { ts.server.Stop() - <-ts.doneCh + waitClosed(ts.doneCh, "server "+ts.server.serviceName+" Run") } func winPingPongIncrementDispatch() DispatchHandler { @@ -75,8 +64,8 @@ func winPingPongIncrementDispatch() DispatchHandler { } func winPingPongStringReverseDispatch() DispatchHandler { - return StringReverseDispatch(func(s string) (string, bool) { - return winReverseString(s), true + return StringReverseDispatch(func(s []byte) (string, bool) { + return winReverseString(string(s)), true }) } @@ -154,11 +143,12 @@ func TestWinStringReversePingPong(t *testing.T) { responsesReceived++ expectedReversed := winReverseString(current) - if view.Str != expectedReversed { + if string(view.Str) != expectedReversed { t.Fatalf("round %d: sent %q, expected reverse %q, got %q", i+1, current, expectedReversed, view.Str) } - current = view.Str + // The view borrows the client buffer that the next call reuses. + current = string(view.Str) } if responsesReceived != 6 { diff --git a/src/go/pkg/netipc/service/raw/poll_unix.go b/src/go/pkg/netipc/service/raw/poll_unix.go index 6a2e29a0..0580e1e7 100644 --- a/src/go/pkg/netipc/service/raw/poll_unix.go +++ b/src/go/pkg/netipc/service/raw/poll_unix.go @@ -4,24 +4,10 @@ package raw import ( "syscall" - "unsafe" -) -// poll constants (not exported by Go's syscall package) -const ( - _POLLIN = 0x0001 - _POLLERR = 0x0008 - _POLLHUP = 0x0010 - _POLLNVAL = 0x0020 + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/syspoll" ) -// pollfd matches struct pollfd from . -type pollfd struct { - fd int32 - events int16 - revents int16 -} - // pollFd polls a file descriptor for readability with a timeout in ms. // Returns: 1 = data ready, 0 = timeout, -1 = error/hangup. func pollFd(fd int, timeoutMs int) int { @@ -29,19 +15,12 @@ func pollFd(fd int, timeoutMs int) int { if fd < 0 || fd > maxInt32 { return -1 } - pfd := pollfd{ - fd: int32(fd), // #nosec G115 -- fd is checked against int32 range above. - events: _POLLIN, + pfd := syspoll.PollFd{ + Fd: int32(fd), // #nosec G115 -- fd is checked against int32 range above. + Events: syspoll.In, } - r, _, errno := syscall.Syscall( - syscall.SYS_POLL, - uintptr(unsafe.Pointer(&pfd)), // #nosec G103 -- raw poll syscall requires a pollfd pointer. - 1, - uintptr(timeoutMs), - ) - - n := int(r) + n, errno := syspoll.Poll(&pfd, timeoutMs) if n < 0 { if errno == syscall.EINTR { return 0 @@ -53,13 +32,59 @@ func pollFd(fd int, timeoutMs int) int { return 0 } - if pfd.revents&(_POLLERR|_POLLHUP|_POLLNVAL) != 0 { + if pfd.Revents&(syspoll.Err|syspoll.Hup|syspoll.Nval) != 0 { return -1 } - if pfd.revents&_POLLIN != 0 { + if pfd.Revents&syspoll.In != 0 { return 1 } return 0 } + +// peerDisconnected is a zero-wait probe of a session socket. SHM sessions keep +// the socket open for their whole life and the kernel closes it when the peer +// exits, so HUP/ERR/EOF here is the dead-peer signal for both directions. +func peerDisconnected(fd int) bool { + const maxInt32 = 1<<31 - 1 + if fd < 0 || fd > maxInt32 { + return true + } + pfd := syspoll.PollFd{ + Fd: int32(fd), // #nosec G115 -- fd is checked against int32 range above. + Events: syspoll.In | syspoll.Hup | syspoll.Err | syspoll.RdHup, + } + + var n int + var errno syscall.Errno + for { + n, errno = syspoll.Poll(&pfd, 0) + if n >= 0 || errno != syscall.EINTR { + break + } + } + if n < 0 { + return true + } + if n == 0 { + return false + } + if pfd.Revents&(syspoll.Hup|syspoll.Err|syspoll.Nval|syspoll.RdHup) != 0 { + return true + } + if pfd.Revents&syspoll.In != 0 { + var b [1]byte + for { + m, _, err := syscall.Recvfrom(fd, b[:], syscall.MSG_PEEK|syscall.MSG_DONTWAIT) + if err == syscall.EINTR { + continue + } + if err != nil { + return err != syscall.EAGAIN && err != syscall.EWOULDBLOCK + } + return m == 0 + } + } + return false +} diff --git a/src/go/pkg/netipc/service/raw/race_disabled_test.go b/src/go/pkg/netipc/service/raw/race_disabled_test.go new file mode 100644 index 00000000..81603ace --- /dev/null +++ b/src/go/pkg/netipc/service/raw/race_disabled_test.go @@ -0,0 +1,5 @@ +//go:build !race + +package raw + +const raceEnabled = false diff --git a/src/go/pkg/netipc/service/raw/race_enabled_test.go b/src/go/pkg/netipc/service/raw/race_enabled_test.go new file mode 100644 index 00000000..87071795 --- /dev/null +++ b/src/go/pkg/netipc/service/raw/race_enabled_test.go @@ -0,0 +1,7 @@ +//go:build race + +package raw + +// The race detector makes sync.Pool drop items on purpose, so pooled +// scratch allocates again and the allocation tests cannot hold. +const raceEnabled = true diff --git a/src/go/pkg/netipc/service/raw/robustness_shm_posix_test.go b/src/go/pkg/netipc/service/raw/robustness_shm_posix_test.go new file mode 100644 index 00000000..4da2ae4b --- /dev/null +++ b/src/go/pkg/netipc/service/raw/robustness_shm_posix_test.go @@ -0,0 +1,119 @@ +//go:build linux || (freebsd && (amd64 || 386)) + +package raw + +// SHM robustness reproduction tests (SOW-0040). SHM exists only on Linux. + +import ( + "fmt" + "path/filepath" + "testing" + "time" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" +) + +func shmFiles(service string) []string { + matches, _ := filepath.Glob(filepath.Join(testRunDir, service+"-*.ipcshm")) + return matches +} + +// K1: SHM server must release a session once its client leaves. + +func TestRobustShmSessionsReleasedAfterCleanClose(t *testing.T) { + svc := robustService("go_rob_shm_clean") + rs := startRobustServer(svc, testUnixShmServerConfig(), protocol.MethodIncrement, robustIncrementDispatch(0), 2) + defer cleanupAll(svc) + + // Each worker must exit and remove its region once its client closes; + // a leaked session fails waitSessionExits loudly. + var failures []string + for i := 0; i < 5; i++ { + if err := servedOnce(svc, testUnixShmClientConfig()); err != nil { + failures = append(failures, fmt.Sprintf("client %d: %v", i, err)) + } + waitSessionExits(rs.server, 1) + } + leftover := shmFiles(svc) + rs.stopWithin(5 * time.Second) + + if len(failures) > 0 { + t.Errorf("with 2 workers, sequential SHM clients must all be served: %v", failures) + } + if len(leftover) > 0 { + t.Errorf("SHM regions leaked: %v", leftover) + } +} + +func TestRobustShmSessionsReleasedAfterClientCrash(t *testing.T) { + svc := robustService("go_rob_shm_crash") + rs := startRobustServer(svc, testUnixShmServerConfig(), protocol.MethodIncrement, robustIncrementDispatch(0), 2) + defer cleanupAll(svc) + + for i := 0; i < 2; i++ { + cmd := spawnHelper(t, "shm_client", svc) + _ = cmd.Process.Kill() // SIGKILL + _ = cmd.Wait() + } + // Both workers must notice their crashed clients and free the slots. + waitSessionExits(rs.server, 2) + served := servedOnce(svc, testUnixShmClientConfig()) + waitSessionExits(rs.server, 1) + leftover := shmFiles(svc) + rs.stopWithin(5 * time.Second) + + if served != nil { + t.Errorf("after 2 crashed SHM clients on 2 workers, a new client must be served: %v", served) + } + if len(leftover) > 0 { + t.Errorf("SHM regions leaked: %v", leftover) + } +} + +// N5: an SHM client must notice a crashed server instead of waiting out its +// whole timeout. + +func TestRobustShmClientNoticesCrashedServer(t *testing.T) { + clientNoticesCrashedServer(t, true) +} + +// A server at worker capacity completes the handshake, then closes the +// session and removes the prepared SHM region. The client must not spend its +// attach retry budget (5 s) on a region that will never appear, and must keep +// SHM enabled for its next connect. +func TestRobustShmAttachStopsWhenServerClosesSession(t *testing.T) { + svc := robustService("go_rob_attach_refused") + rs := startRobustServer(svc, testUnixShmServerConfig(), protocol.MethodIncrement, robustIncrementDispatch(0), 1) + defer cleanupAll(svc) + + holder := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) + holder.Refresh() + if !holder.Ready() { + holder.Close() + rs.stopWithin(5 * time.Second) + t.Fatal("first client must hold the only worker") + } + + refused := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) + defer refused.Close() + start := time.Now() + refused.Refresh() + elapsed := time.Since(start) + + holder.Close() + waitSessionExits(rs.server, 1) + refused.Refresh() + ready, usesShm := refused.Ready(), refused.shm != nil + refused.Close() + rs.stopWithin(5 * time.Second) + + if elapsed >= time.Second { + t.Errorf("refused connect must return within 1s (attach retry budget is 5s): %v", elapsed) + } + if !ready { + t.Error("refused client must connect once the worker is free") + } + if !usesShm { + t.Error("refused client must still use SHM") + } +} diff --git a/src/go/pkg/netipc/service/raw/robustness_unix_test.go b/src/go/pkg/netipc/service/raw/robustness_unix_test.go new file mode 100644 index 00000000..9f512542 --- /dev/null +++ b/src/go/pkg/netipc/service/raw/robustness_unix_test.go @@ -0,0 +1,795 @@ +//go:build unix + +package raw + +// Robustness reproduction tests (SOW-0040). +// +// Each test asserts the correct behavior and runs every blocking step in a +// goroutine under a watchdog, so a regression fails instead of hanging. +// Bounds are deliberately lenient so they do not dictate a fix design. + +import ( + "bufio" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/syspoll" + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" + "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/posix" +) + +const robustHelperEnv = "NETIPC_ROBUSTNESS_HELPER" + +var robustCounter int + +func robustService(prefix string) string { + robustCounter++ + service := fmt.Sprintf("%s_%d_%d", prefix, os.Getpid(), robustCounter) + trackTestLock(testRunDir, service) + return service +} + +// within runs f in a goroutine and reports whether it finished in time. +// On timeout the goroutine keeps running; callers release what blocks it. +func within(limit time.Duration, f func()) bool { + done := make(chan struct{}) + go func() { + defer close(done) + f() + }() + select { + case <-done: + return true + case <-time.After(limit): + return false + } +} + +func robustIncrementDispatch(delay time.Duration) DispatchHandler { + return IncrementDispatch(func(v uint64) (uint64, bool) { + time.Sleep(delay) + return v + 1, true + }) +} + +type robustServer struct { + server *Server + doneCh chan struct{} +} + +func startRobustServer(service string, cfg posix.ServerConfig, method uint16, handler DispatchHandler, workers int) *robustServer { + ensureRunDir() + cleanupAll(service) + s := NewServerWithWorkers(testRunDir, service, cfg, method, handler, workers) + doneCh := runServerForTest(s) + return &robustServer{server: s, doneCh: doneCh} +} + +// stopWithin stops the server and reports whether Run returned in time. +func (rs *robustServer) stopWithin(limit time.Duration) bool { + rs.server.Stop() + select { + case <-rs.doneCh: + return true + case <-time.After(limit): + return false + } +} + +// servedOnce connects once and completes one increment call. Callers make +// sure a worker is free first, so any failure is a real one. +func servedOnce(service string, cfg posix.ClientConfig) error { + c := NewIncrementClient(testRunDir, service, cfg) + defer c.Close() + c.Refresh() + if !c.Ready() { + return fmt.Errorf("state %v", c.Status().State) + } + v, err := c.CallIncrementWithTimeout(1, 1000) + if err != nil || v != 2 { + return fmt.Errorf("call: v=%d err=%v", v, err) + } + return nil +} + +// blockingIncrementDispatch parks every request; the helper process holding +// it is killed by the test. +func blockingIncrementDispatch(entered func()) DispatchHandler { + return IncrementDispatch(func(v uint64) (uint64, bool) { + entered() + select {} + }) +} + +// silentConnect opens a transport-type connection without sending HELLO. +func silentConnect(t *testing.T, service string) int { + t.Helper() + fd, err := syscall.Socket(syscall.AF_UNIX, testSockType, 0) + if err != nil { + t.Fatalf("socket: %v", err) + } + addr := &syscall.SockaddrUnix{Name: testRunDir + "/" + service + ".sock"} + if err := syscall.Connect(fd, addr); err != nil { + syscall.Close(fd) + t.Fatalf("connect: %v", err) + } + return fd +} + +// rawListener binds and listens on the service socket but never accepts. +func rawListener(t *testing.T, service string) int { + t.Helper() + ensureRunDir() + cleanupAll(service) + fd, err := syscall.Socket(syscall.AF_UNIX, testSockType, 0) + if err != nil { + t.Fatalf("socket: %v", err) + } + if err := syscall.Bind(fd, &syscall.SockaddrUnix{Name: testRunDir + "/" + service + ".sock"}); err != nil { + t.Fatalf("bind: %v", err) + } + if err := syscall.Listen(fd, 4); err != nil { + t.Fatalf("listen: %v", err) + } + return fd +} + +// helperEntered reports, per helper server process, that its handler holds a +// request. +var helperEntered = map[*exec.Cmd]chan struct{}{} + +// spawnHelper re-executes the test binary as a client or server process. +func spawnHelper(t *testing.T, mode, service string) *exec.Cmd { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=^TestRobustnessHelperProcess$", "-test.v") + cmd.Env = append(os.Environ(), robustHelperEnv+"="+mode+":"+service) + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("stdout pipe: %v", err) + } + if err := cmd.Start(); err != nil { + t.Fatalf("start helper: %v", err) + } + ready := make(chan bool, 1) + entered := make(chan struct{}, 1) + go func() { + sc := bufio.NewScanner(stdout) + for sc.Scan() { + if strings.Contains(sc.Text(), "HELPER_READY") { + ready <- true + } + if strings.Contains(sc.Text(), "HELPER_HANDLER_ENTERED") { + entered <- struct{}{} + } + } + close(ready) + }() + helperEntered[cmd] = entered + select { + case ok := <-ready: + if !ok { + t.Fatalf("helper %s exited before ready", mode) + } + case <-time.After(10 * time.Second): + _ = cmd.Process.Kill() + t.Fatalf("helper %s did not become ready", mode) + } + return cmd +} + +// TestRobustnessHelperProcess is the child-process entry point; inert unless +// spawned by spawnHelper. +func TestRobustnessHelperProcess(t *testing.T) { + spec := os.Getenv(robustHelperEnv) + if spec == "" { + return + } + mode, service, _ := strings.Cut(spec, ":") + switch mode { + case "shm_client": + // The server listens before the helper starts: one connect. + c := NewIncrementClient(testRunDir, service, testUnixShmClientConfig()) + c.Refresh() + if v, err := c.CallIncrementWithTimeout(1, 2000); err != nil || v != 2 { + fmt.Printf("HELPER_FAILED v=%d err=%v\n", v, err) + os.Exit(1) + } + fmt.Println("HELPER_READY") + select {} // until the test kills this process + case "shm_server", "uds_server": + cfg := testServerConfig() + if mode == "shm_server" { + cfg = testUnixShmServerConfig() + } + startRobustServer(service, cfg, protocol.MethodIncrement, + blockingIncrementDispatch(func() { fmt.Println("HELPER_HANDLER_ENTERED") }), 2) + fmt.Println("HELPER_READY") + select {} // until the test kills this process + default: + t.Fatalf("unknown helper mode %q", mode) + } +} + +// K3: a client that connects but never sends HELLO must not block others or +// shutdown. + +func TestRobustSilentClientDoesNotBlockOtherClients(t *testing.T) { + svc := robustService("go_rob_silent") + rs := startRobustServer(svc, testServerConfig(), protocol.MethodIncrement, robustIncrementDispatch(0), 4) + defer cleanupAll(svc) + + silent := silentConnect(t, svc) + waitHandshakesStarted(rs.server, 1) + var served error = fmt.Errorf("did not finish") + finished := within(5*time.Second, func() { + served = servedOnce(svc, testClientConfig()) + }) + + syscall.Close(silent) + rs.stopWithin(5 * time.Second) + if !finished || served != nil { + t.Errorf("a legitimate client must be served within 5s while a silent client is connected (finished=%v err=%v)", finished, served) + } +} + +func TestRobustSilentClientDoesNotBlockShutdown(t *testing.T) { + svc := robustService("go_rob_silent_stop") + rs := startRobustServer(svc, testServerConfig(), protocol.MethodIncrement, robustIncrementDispatch(0), 4) + defer cleanupAll(svc) + + silent := silentConnect(t, svc) + waitHandshakesStarted(rs.server, 1) + stopped := rs.stopWithin(3 * time.Second) + + syscall.Close(silent) + <-rs.doneCh + if !stopped { + t.Error("server Run must return within 3s of Stop while a silent client is connected") + } +} + +// A silent client holds a worker only until the server handshake deadline. +func TestRobustSilentClientReleasedAtHandshakeDeadline(t *testing.T) { + svc := robustService("go_rob_silent_deadline") + rs := startRobustServer(svc, testServerConfig(), protocol.MethodIncrement, robustIncrementDispatch(0), 1) + defer cleanupAll(svc) + + silent := silentConnect(t, svc) + defer syscall.Close(silent) + start := time.Now() + waitHandshakesStarted(rs.server, 1) + waitSessionExits(rs.server, 1) + elapsed := time.Since(start) + served := servedOnce(svc, testClientConfig()) + deadline := time.Duration(rs.server.timings.handshakeMs) * time.Millisecond + if elapsed < deadline { + t.Errorf("silent client released after %v, before the %v handshake deadline", elapsed, deadline) + } + + var buf [1]byte + n, _, rerr := syscall.Recvfrom(silent, buf[:], syscall.MSG_DONTWAIT) + rs.stopWithin(5 * time.Second) + if served != nil { + t.Errorf("the only worker must be freed once the silent client misses the handshake deadline: %v (after %v)", served, elapsed) + } + if n != 0 || rerr != nil { + t.Errorf("server must close the silent connection at the deadline (recv n=%d err=%v)", n, rerr) + } +} + +// K4: a client call must be bounded even when the server never answers HELLO. + +func TestRobustClientBoundedWhenServerNeverAccepts(t *testing.T) { + svc := robustService("go_rob_noaccept") + listener := rawListener(t, svc) + defer cleanupAll(svc) + + done := make(chan struct{}) + start := time.Now() + finished := within(6*time.Second, func() { + defer close(done) + c := NewIncrementClient(testRunDir, svc, testClientConfig()) + c.SetCallTimeout(1000) + c.Refresh() + _, _ = c.CallIncrementWithTimeout(1, 1000) + c.Close() + }) + elapsed := time.Since(start) + + syscall.Close(listener) + <-done + if !finished { + t.Errorf("Refresh + call with a 1s timeout must return within 6s against a server that never accepts (blocked %v)", elapsed) + } +} + +func TestRobustClientBoundedWhenServerNeverSendsHelloAck(t *testing.T) { + svc := robustService("go_rob_nohelloack") + listener := rawListener(t, svc) + defer cleanupAll(svc) + + release := make(chan struct{}) + go func() { + fd, _, err := syscall.Accept(listener) + <-release + if err == nil { + syscall.Close(fd) + } + syscall.Close(listener) + }() + + done := make(chan struct{}) + finished := within(6*time.Second, func() { + defer close(done) + c := NewIncrementClient(testRunDir, svc, testClientConfig()) + c.SetCallTimeout(1000) + c.Refresh() + _, _ = c.CallIncrementWithTimeout(1, 1000) + c.Close() + }) + + close(release) + <-done + if !finished { + t.Error("Refresh + call with a 1s timeout must return within 6s when the server accepts but never sends HELLO_ACK") + } +} + +// acceptAndHold accepts one connection on listener and holds it without +// replying until release is closed. accepted is closed once a client +// connected; done once the holder released everything. +func acceptAndHold(listener int) (release chan struct{}, accepted, done <-chan struct{}) { + rel := make(chan struct{}) + acc := make(chan struct{}) + fin := make(chan struct{}) + go func() { + defer close(fin) + fd, _, err := syscall.Accept(listener) + close(acc) + <-rel + if err == nil { + syscall.Close(fd) + } + syscall.Close(listener) + }() + return rel, acc, fin +} + +func TestRobustCallReconnectBoundedByCallDeadline(t *testing.T) { + svc := robustService("go_rob_reconnect_noack") + rs := startRobustServer(svc, testServerConfig(), protocol.MethodIncrement, robustIncrementDispatch(0), 1) + defer cleanupAll(svc) + + c := NewIncrementClient(testRunDir, svc, testClientConfig()) + c.Refresh() + if !c.Ready() { + t.Fatalf("client not ready: state %v", c.Status().State) + } + if !rs.stopWithin(5 * time.Second) { + t.Fatal("server did not stop") + } + + // The call fails on the dead session and reconnects to a server that + // accepts but never sends HELLO_ACK. + release, _, held := acceptAndHold(rawListener(t, svc)) + var err error + var elapsed time.Duration + done := make(chan struct{}) + finished := within(6*time.Second, func() { + defer close(done) + start := time.Now() + _, err = c.CallIncrementWithTimeout(1, 1000) + elapsed = time.Since(start) + c.Close() + }) + + close(release) + <-held + <-done + if !finished { + t.Fatal("a call with a 1s timeout must return within 6s") + } + if !errors.Is(err, protocol.ErrTimeout) { + t.Errorf("call error = %v after %v, want ErrTimeout", err, elapsed) + } +} + +func TestRobustAbortEndsRefreshWaitingForHelloAck(t *testing.T) { + svc := robustService("go_rob_abort_refresh") + release, accepted, held := acceptAndHold(rawListener(t, svc)) + defer cleanupAll(svc) + + c := NewIncrementClient(testRunDir, svc, testClientConfig()) + // Only an abort can end the wait early. + c.SetCallTimeout(10000) + ready := make(chan bool, 1) + go func() { + c.Refresh() + ready <- c.Ready() + }() + select { + case <-accepted: + case <-time.After(testWaitLimit): + t.Fatal("server never accepted the connection") + } + c.Abort() + var got, returned bool + select { + case got = <-ready: + returned = true + case <-time.After(2 * time.Second): + } + + close(release) + <-held + if !returned { + <-ready + t.Fatal("refresh must return within 2s of the abort") + } + c.Close() + if got { + t.Error("refresh after abort reported ready") + } +} + +// N1 guard: the configured call timeout must be honored on the UDS path. + +func TestRobustUdsCallHonorsTimeoutLongerThan100ms(t *testing.T) { + svc := robustService("go_rob_slow_handler") + rs := startRobustServer(svc, testServerConfig(), protocol.MethodIncrement, robustIncrementDispatch(300*time.Millisecond), 2) + defer cleanupAll(svc) + defer rs.stopWithin(5 * time.Second) + + c := NewIncrementClient(testRunDir, svc, testClientConfig()) + defer c.Close() + c.Refresh() + start := time.Now() + v, err := c.CallIncrementWithTimeout(41, 2000) + if err != nil || v != 42 { + t.Errorf("a 300ms handler must succeed with a 2000ms timeout: v=%d err=%v after %v", v, err, time.Since(start)) + } +} + +// N2: shutdown must not wait forever on a worker blocked sending a response +// to a client that stopped reading. + +func TestRobustShutdownNotBlockedByNonReadingClient(t *testing.T) { + const big = 8 * 1024 * 1024 + svc := robustService("go_rob_noread") + scfg := testServerConfig() + scfg.MaxRequestPayloadBytes = big + scfg.MaxResponsePayloadBytes = big + scfg.PacketSize = 65536 // isolate from the default packet size issue (N11) + // After the handler returns, the worker can only be sending the 4 MiB + // response, which blocks because the client never reads. + handled := make(chan struct{}, 1) + reverse := pingPongStringReverseDispatch() + rs := startRobustServer(svc, scfg, protocol.MethodStringReverse, func(req, resp []byte) (int, error) { + n, err := reverse(req, resp) + handled <- struct{}{} + return n, err + }, 2) + defer cleanupAll(svc) + + ccfg := testClientConfig() + ccfg.MaxRequestPayloadBytes = big + ccfg.MaxResponsePayloadBytes = big + ccfg.PacketSize = 65536 + session, err := posix.Connect(testRunDir, svc, &ccfg) + if err != nil { + t.Fatalf("connect: %v", err) + } + text := strings.Repeat("x", 4*1024*1024) + payload := make([]byte, protocol.StringReverseHdrSize+len(text)+1) + n := protocol.StringReverseEncode(text, payload) + hdr := protocol.Header{Kind: protocol.KindRequest, Code: protocol.MethodStringReverse, ItemCount: 1, MessageID: 1} + if err := session.Send(&hdr, payload[:n]); err != nil { + t.Fatalf("send: %v", err) + } + waitClosed(handled, "string reverse handler") + + stopped := rs.stopWithin(3 * time.Second) + session.Close() + <-rs.doneCh + if !stopped { + t.Error("server Run must return within 3s of Stop while a worker is blocked sending") + } +} + +// N3: Stop issued before Run must not be lost. + +func TestRobustStopBeforeRunIsHonored(t *testing.T) { + svc := robustService("go_rob_stop_first") + ensureRunDir() + cleanupAll(svc) + defer cleanupAll(svc) + + s := NewServerWithWorkers(testRunDir, svc, testServerConfig(), protocol.MethodIncrement, robustIncrementDispatch(0), 2) + s.Stop() + doneCh := make(chan struct{}) + go func() { + defer close(doneCh) + _ = s.Run() + }() + + select { + case <-doneCh: + case <-time.After(2 * time.Second): + t.Error("Run after Stop must return promptly instead of serving forever") + s.Stop() + <-doneCh + } +} + +// N11: with the auto-detected packet size, a message larger than SO_SNDBUF +// must still be delivered (chunked). + +func TestRobustDefaultPacketSizeCarriesLargeMessage(t *testing.T) { + if err := robustLargeRoundTrip(0); err != nil { + t.Errorf("a 512 KiB request must round-trip with default packet size: %v", err) + } +} + +// Control: the same message with an explicit 64 KiB packet size passes, +// isolating the failure above to the auto-detected packet size. +func TestRobustExplicitPacketSizeCarriesLargeMessage(t *testing.T) { + if err := robustLargeRoundTrip(65536); err != nil { + t.Errorf("a 512 KiB request must round-trip with a 64 KiB packet size: %v", err) + } +} + +func robustLargeRoundTrip(packetSize uint32) error { + const big = 1024 * 1024 + svc := robustService("go_rob_bigmsg") + scfg := testServerConfig() + scfg.MaxRequestPayloadBytes = big + scfg.MaxResponsePayloadBytes = big + scfg.PacketSize = packetSize + rs := startRobustServer(svc, scfg, protocol.MethodStringReverse, pingPongStringReverseDispatch(), 2) + defer cleanupAll(svc) + defer rs.stopWithin(5 * time.Second) + + ccfg := testClientConfig() + ccfg.MaxRequestPayloadBytes = big + ccfg.MaxResponsePayloadBytes = big + ccfg.PacketSize = packetSize + c := NewStringReverseClient(testRunDir, svc, ccfg) + defer c.Close() + c.Refresh() + text := strings.Repeat("y", 512*1024) + view, err := c.CallStringReverseWithTimeout(text, 5000) + if err != nil { + return err + } + if view == nil || len(view.Str) != len(text) { + return fmt.Errorf("unexpected response length") + } + return nil +} + +// N5: a client must notice a crashed server instead of waiting out its +// whole timeout. +func TestRobustUdsClientReportsCrashedServerAsDisconnected(t *testing.T) { + clientNoticesCrashedServer(t, false) +} + +func clientNoticesCrashedServer(t *testing.T, shm bool) { + name, mode, ccfg := "go_rob_dead_uds_server", "uds_server", testClientConfig() + if shm { + name, mode, ccfg = "go_rob_dead_server", "shm_server", testUnixShmClientConfig() + } + svc := robustService(name) + ensureRunDir() + cleanupAll(svc) + defer cleanupAll(svc) + cmd := spawnHelper(t, mode, svc) + + c := NewIncrementClient(testRunDir, svc, ccfg) + defer c.Close() + c.Refresh() + if !c.Ready() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + t.Fatalf("client not ready against the helper server: %+v", c.Status()) + } + + type outcome struct { + err error + elapsed time.Duration + } + result := make(chan outcome, 1) + go func() { + start := time.Now() + _, err := c.CallIncrementWithTimeout(1, 10000) + result <- outcome{err, time.Since(start)} + }() + waitClosed(helperEntered[cmd], "helper server handler") // request parked in the stalled handler + _ = cmd.Process.Kill() + _ = cmd.Wait() + + r := <-result + if r.err == nil || r.elapsed > 3500*time.Millisecond { + t.Errorf("call must fail within 3s of the server crash (timeout is 10s): err=%v after %v", r.err, r.elapsed) + } + if !errors.Is(r.err, protocol.ErrDisconnected) { + t.Errorf("call must report the server as disconnected: err=%v", r.err) + } +} + +// Run closes the listening socket when its loop exits; the endpoint lock +// stays held until every worker has exited. +func TestRobustShutdownClosesSocketBeforeEndpoint(t *testing.T) { + svc := robustService("shut_order") + ensureRunDir() + cleanupAll(svc) + defer cleanupAll(svc) + + entered := make(chan struct{}) + release := make(chan struct{}) + var enteredOnce sync.Once + handler := IncrementDispatch(func(v uint64) (uint64, bool) { + enteredOnce.Do(func() { close(entered) }) + <-release + return v + 1, true + }) + s := NewServerWithWorkers(testRunDir, svc, testServerConfig(), protocol.MethodIncrement, handler, 1) + doneCh := runServerForTest(s) + + c := NewIncrementClient(testRunDir, svc, testClientConfig()) + defer c.Close() + c.Refresh() + if !c.Ready() { + s.Stop() + waitClosed(doneCh, "server Run") + t.Fatalf("client not ready: %+v", c.Status()) + } + callErr := make(chan error, 1) + go func() { + _, err := c.CallIncrementWithTimeout(1, 10000) + callErr <- err + }() + waitClosed(entered, "increment handler") + + // Run returns only after the workers exit. After its grace the server + // shuts the blocked worker's socket, which fails the call; the worker + // itself stays in the handler. + s.Stop() + if err := <-callErr; err == nil { + t.Error("blocked call succeeded; want a failure once its socket is shut") + } + _, statErr := os.Stat(testRunDir + "/" + svc + ".sock") + socketGone := errors.Is(statErr, os.ErrNotExist) + other, listenErr := posix.Listen(testRunDir, svc, testServerConfig()) + if listenErr == nil { + other.Close() + } + + close(release) + waitClosed(doneCh, "server Run") + free, freeErr := posix.Listen(testRunDir, svc, testServerConfig()) + if freeErr == nil { + free.Close() + } + + if !socketGone { + t.Errorf("socket file still present after the accept loop exited: stat err=%v", statErr) + } + if listenErr == nil { + t.Error("endpoint taken by another server while a worker still runs") + } + if freeErr != nil { + t.Errorf("endpoint not free once the server stopped: %v", freeErr) + } +} + +const ( + stalledReaderRequestBytes = 8 * 1024 * 1024 + stalledReaderPids = 1024 * 1024 +) + +// startStalledReader completes the handshake and never reads. It closes +// started once the request's first bytes arrived, then holds the session +// until release is closed. +func startStalledReader(t *testing.T, svc string, started, release chan struct{}) chan struct{} { + t.Helper() + ensureRunDir() + cleanupAll(svc) + cfg := testServerConfig() + cfg.MaxRequestPayloadBytes = stalledReaderRequestBytes + listener, err := posix.Listen(testRunDir, svc, cfg) + if err != nil { + t.Fatalf("listen: %v", err) + } + done := make(chan struct{}) + go func() { + defer close(done) + defer listener.Close() + session, err := listener.Accept() + if err != nil { + close(started) + return + } + defer session.Close() + pfd := syspoll.PollFd{Fd: int32(session.Fd()), Events: syspoll.In} // #nosec G115 -- test fds are small. + // The Go runtime's preemption signal can interrupt a raw poll. + n, errno := syspoll.Poll(&pfd, 10_000) + for errno == syscall.EINTR { + n, errno = syspoll.Poll(&pfd, 10_000) + } + if errno != 0 || n == 0 || pfd.Revents&syspoll.In == 0 { + t.Errorf("no request bytes arrived: n=%d revents=%#x errno=%v", n, pfd.Revents, errno) + } + close(started) + select { + case <-release: + case <-time.After(testWaitLimit): + } + }() + return done +} + +// A request the server never reads ends at the call deadline, or on abort, +// instead of blocking in the send. +func TestRobustSendBoundedByDeadlineAndAbort(t *testing.T) { + pids := make([]uint32, stalledReaderPids) + for i := range pids { + pids[i] = uint32(i + 1) // #nosec G115 -- bounded by stalledReaderPids. + } + for _, useAbort := range []bool{false, true} { + name := "send_deadline" + if useAbort { + name = "send_abort" + } + t.Run(name, func(t *testing.T) { + svc := robustService(name) + defer cleanupAll(svc) + started := make(chan struct{}) + release := make(chan struct{}) + done := startStalledReader(t, svc, started, release) + + ccfg := testClientConfig() + ccfg.MaxRequestPayloadBytes = stalledReaderRequestBytes + client := NewAppsLookupClient(testRunDir, svc, ccfg) + client.SetLookupLogicalConfig(LookupLogicalConfig{MaxItems: stalledReaderPids}) + refreshUnixClientReady(t, client) + + timeoutMs := uint32(200) + if useAbort { + timeoutMs = 30_000 + } + start := time.Now() + result := make(chan error, 1) + go func() { + _, err := client.CallAppsLookupWithTimeout(pids, timeoutMs) + result <- err + }() + if useAbort { + <-started + client.Abort() + } + err := <-result + elapsed := time.Since(start) + close(release) + <-done + client.Close() + + want := protocol.ErrTimeout + if useAbort { + want = protocol.ErrAborted + } + if !errors.Is(err, want) { + t.Fatalf("blocked send = %v, want %v", err, want) + } + if elapsed > 5*time.Second { + t.Fatalf("blocked send took %v", elapsed) + } + }) + } +} diff --git a/src/go/pkg/netipc/service/raw/robustness_windows_test.go b/src/go/pkg/netipc/service/raw/robustness_windows_test.go new file mode 100644 index 00000000..787e0b84 --- /dev/null +++ b/src/go/pkg/netipc/service/raw/robustness_windows_test.go @@ -0,0 +1,660 @@ +//go:build windows + +package raw + +// Robustness reproduction tests (SOW-0040), Windows. +// +// Each test asserts the correct behavior and runs every blocking step in a +// goroutine under a watchdog, so a regression fails instead of hanging. +// Bounds are deliberately lenient so they do not dictate a fix design. + +import ( + "bufio" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" + "unsafe" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" + windows "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/windows" +) + +const ( + winRobustHelperEnv = "NETIPC_ROBUSTNESS_HELPER" + winFileFlagFirstPipeInstance = 0x00080000 +) + +func winWithin(limit time.Duration, f func()) bool { + done := make(chan struct{}) + go func() { + defer close(done) + f() + }() + select { + case <-done: + return true + case <-time.After(limit): + return false + } +} + +func winSlowIncrementDispatch(delay time.Duration) DispatchHandler { + return IncrementDispatch(func(v uint64) (uint64, bool) { + time.Sleep(delay) + return v + 1, true + }) +} + +type winRobustServer struct { + server *Server + doneCh chan struct{} +} + +func startWinRobustServer(service string, cfg windows.ServerConfig, method uint16, handler DispatchHandler, workers int) *winRobustServer { + s := NewServerWithWorkers(winTestRunDir, service, cfg, method, handler, workers) + return &winRobustServer{server: s, doneCh: runServerForTest(s)} +} + +// stopWithin stops the server and reports whether Run returned in time. +func (rs *winRobustServer) stopWithin(limit time.Duration) bool { + stopped := make(chan struct{}) + go func() { + rs.server.Stop() + close(stopped) + }() + select { + case <-rs.doneCh: + return true + case <-time.After(limit): + return false + } +} + +// winServedOnce connects once and completes one increment call. Callers +// make sure a worker is free first, so any failure is a real one. +func winServedOnce(service string, cfg windows.ClientConfig) error { + c := NewIncrementClient(winTestRunDir, service, cfg) + defer c.Close() + c.Refresh() + if !c.Ready() { + return fmt.Errorf("state %v", c.Status().State) + } + v, err := c.CallIncrementWithTimeout(1, 1000) + if err != nil || v != 2 { + return fmt.Errorf("call: v=%d err=%v", v, err) + } + return nil +} + +// winBlockingIncrementDispatch parks every request; the helper process +// holding it is killed by the test. +func winBlockingIncrementDispatch(entered func()) DispatchHandler { + return IncrementDispatch(func(v uint64) (uint64, bool) { + entered() + select {} + }) +} + +// winHelperEntered reports, per helper server process, that its handler +// holds a request. +var winHelperEntered = map[*exec.Cmd]chan struct{}{} + +func winOpenPipeSilently(t *testing.T, service string) syscall.Handle { + t.Helper() + name, err := windows.BuildPipeName(winTestRunDir, service) + if err != nil { + t.Fatalf("pipe name: %v", err) + } + // The server is listening, so one open must succeed. + h, err := syscall.CreateFile(&name[0], syscall.GENERIC_READ|syscall.GENERIC_WRITE, 0, nil, syscall.OPEN_EXISTING, 0, 0) + if err != nil { + t.Fatalf("could not open pipe for %s: %v", service, err) + } + return h +} + +func winSpawnHelper(t *testing.T, mode, service string) *exec.Cmd { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=^TestWinRobustnessHelperProcess$", "-test.v") + cmd.Env = append(os.Environ(), winRobustHelperEnv+"="+mode+":"+service) + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("stdout pipe: %v", err) + } + if err := cmd.Start(); err != nil { + t.Fatalf("start helper: %v", err) + } + ready := make(chan bool, 1) + entered := make(chan struct{}, 1) + go func() { + sc := bufio.NewScanner(stdout) + for sc.Scan() { + if strings.Contains(sc.Text(), "HELPER_READY") { + ready <- true + } + if strings.Contains(sc.Text(), "HELPER_HANDLER_ENTERED") { + entered <- struct{}{} + } + } + close(ready) + }() + winHelperEntered[cmd] = entered + select { + case ok := <-ready: + if !ok { + t.Fatalf("helper %s exited before ready", mode) + } + case <-time.After(15 * time.Second): + _ = cmd.Process.Kill() + t.Fatalf("helper %s did not become ready", mode) + } + return cmd +} + +// TestWinRobustnessHelperProcess is the child-process entry point; inert +// unless spawned by winSpawnHelper. +func TestWinRobustnessHelperProcess(t *testing.T) { + spec := os.Getenv(winRobustHelperEnv) + if spec == "" { + return + } + mode, service, _ := strings.Cut(spec, ":") + switch mode { + case "shm_client": + // The server listens before the helper starts: one connect. + c := NewIncrementClient(winTestRunDir, service, testWinShmClientConfig()) + c.Refresh() + if v, err := c.CallIncrementWithTimeout(1, 2000); err != nil || v != 2 { + fmt.Printf("HELPER_FAILED v=%d err=%v\n", v, err) + os.Exit(1) + } + case "shm_server": + startWinRobustServer(service, testWinShmServerConfig(), protocol.MethodIncrement, + winBlockingIncrementDispatch(func() { fmt.Println("HELPER_HANDLER_ENTERED") }), 2) + default: + t.Fatalf("unknown helper mode %q", mode) + } + fmt.Println("HELPER_READY") + select {} // until the test kills this process +} + +// K2: SHM server must release a session once its client crashes. +func TestWinRobustShmSessionReleasedAfterClientCrash(t *testing.T) { + svc := uniqueWinService("go_wrob_shm_crash") + rs := startWinRobustServer(svc, testWinShmServerConfig(), protocol.MethodIncrement, winSlowIncrementDispatch(0), 2) + + for i := 0; i < 2; i++ { + cmd := winSpawnHelper(t, "shm_client", svc) + _ = cmd.Process.Kill() // TerminateProcess + _ = cmd.Wait() + } + // Both workers must notice their crashed clients and free the slots. + waitSessionExits(rs.server, 2) + served := winServedOnce(svc, testWinShmClientConfig()) + rs.stopWithin(5 * time.Second) + if served != nil { + t.Errorf("after 2 crashed SHM clients on 2 workers, a new client must be served: %v", served) + } +} + +// K3: a client that opens the pipe but never sends HELLO. +func TestWinRobustSilentClientDoesNotBlockOtherClients(t *testing.T) { + svc := uniqueWinService("go_wrob_silent") + rs := startWinRobustServer(svc, testWinServerConfig(), protocol.MethodIncrement, winSlowIncrementDispatch(0), 4) + + silent := winOpenPipeSilently(t, svc) + waitHandshakesStarted(rs.server, 1) + var served error = fmt.Errorf("did not finish") + finished := winWithin(5*time.Second, func() { + served = winServedOnce(svc, testWinClientConfig()) + }) + + syscall.CloseHandle(silent) + rs.stopWithin(5 * time.Second) + if !finished || served != nil { + t.Errorf("a legitimate client must be served within 5s while a silent client is connected (finished=%v err=%v)", finished, served) + } +} + +func TestWinRobustSilentClientDoesNotBlockShutdown(t *testing.T) { + svc := uniqueWinService("go_wrob_silent_stop") + rs := startWinRobustServer(svc, testWinServerConfig(), protocol.MethodIncrement, winSlowIncrementDispatch(0), 4) + + silent := winOpenPipeSilently(t, svc) + waitHandshakesStarted(rs.server, 1) + stopped := rs.stopWithin(3 * time.Second) + + syscall.CloseHandle(silent) + <-rs.doneCh + if !stopped { + t.Error("server Run must return within 3s of Stop while a silent client is connected") + } +} + +// A silent client holds a worker only until the server handshake deadline. +func TestWinRobustSilentClientReleasedAtHandshakeDeadline(t *testing.T) { + svc := uniqueWinService("go_wrob_silent_deadline") + rs := startWinRobustServer(svc, testWinServerConfig(), protocol.MethodIncrement, winSlowIncrementDispatch(0), 1) + + silent := winOpenPipeSilently(t, svc) + defer syscall.CloseHandle(silent) + start := time.Now() + waitHandshakesStarted(rs.server, 1) + waitSessionExits(rs.server, 1) + elapsed := time.Since(start) + served := winServedOnce(svc, testWinClientConfig()) + deadline := time.Duration(rs.server.timings.handshakeMs) * time.Millisecond + if elapsed < deadline { + t.Errorf("silent client released after %v, before the %v handshake deadline", elapsed, deadline) + } + + rs.stopWithin(5 * time.Second) + if served != nil { + t.Errorf("the only worker must be freed once the silent client misses the handshake deadline: %v (after %v)", served, elapsed) + } +} + +// K4: a client call must be bounded even when the server never answers HELLO. +// winFakeServerPipe creates a pipe that accepts clients but never answers +// HELLO. +func winFakeServerPipe(t *testing.T, service string) syscall.Handle { + t.Helper() + name, err := windows.BuildPipeName(winTestRunDir, service) + if err != nil { + t.Fatalf("pipe name: %v", err) + } + h, _, callErr := rawCreateNamedPipeW.Call( + uintptr(unsafe.Pointer(&name[0])), + uintptr(rawPipeAccessDuplex|winFileFlagFirstPipeInstance), + uintptr(rawPipeTypeMessage|rawPipeReadModeMsg|rawPipeWait), + 4, rawPipeBufSize, rawPipeBufSize, 0, 0, + ) + if syscall.Handle(h) == syscall.InvalidHandle { + t.Fatalf("CreateNamedPipeW: %v", callErr) + } + return syscall.Handle(h) +} + +func TestWinRobustClientBoundedWhenServerNeverSendsHelloAck(t *testing.T) { + svc := uniqueWinService("go_wrob_nohelloack") + h := winFakeServerPipe(t, svc) + + done := make(chan struct{}) + finished := winWithin(6*time.Second, func() { + defer close(done) + c := NewIncrementClient(winTestRunDir, svc, testWinClientConfig()) + c.SetCallTimeout(1000) + c.Refresh() + _, _ = c.CallIncrementWithTimeout(1, 1000) + c.Close() + }) + + syscall.CloseHandle(h) + <-done + if !finished { + t.Error("Refresh + call with a 1s timeout must return within 6s when the server never sends HELLO_ACK") + } +} + +func TestWinRobustCallReconnectBoundedByCallDeadline(t *testing.T) { + svc := uniqueWinService("go_wrob_reconnect_noack") + rs := startWinRobustServer(svc, testWinServerConfig(), protocol.MethodIncrement, winSlowIncrementDispatch(0), 1) + + c := NewIncrementClient(winTestRunDir, svc, testWinClientConfig()) + c.Refresh() + if !c.Ready() { + t.Fatalf("client not ready: state %v", c.Status().State) + } + if !rs.stopWithin(5 * time.Second) { + t.Fatal("server did not stop") + } + + // The call fails on the dead session and reconnects to a server that + // accepts but never sends HELLO_ACK. + h := winFakeServerPipe(t, svc) + var err error + var elapsed time.Duration + done := make(chan struct{}) + finished := winWithin(6*time.Second, func() { + defer close(done) + start := time.Now() + _, err = c.CallIncrementWithTimeout(1, 1000) + elapsed = time.Since(start) + c.Close() + }) + + syscall.CloseHandle(h) + <-done + if !finished { + t.Fatal("a call with a 1s timeout must return within 6s") + } + if !errors.Is(err, protocol.ErrTimeout) { + t.Errorf("call error = %v after %v, want ErrTimeout", err, elapsed) + } +} + +func TestWinRobustAbortEndsRefreshWaitingForHelloAck(t *testing.T) { + svc := uniqueWinService("go_wrob_abort_refresh") + h := winFakeServerPipe(t, svc) + + c := NewIncrementClient(winTestRunDir, svc, testWinClientConfig()) + // Only an abort can end the wait early. + c.SetCallTimeout(10000) + ready := make(chan bool, 1) + go func() { + c.Refresh() + ready <- c.Ready() + }() + if err := connectRawNamedPipe(h); err != nil { + if errno, ok := err.(syscall.Errno); !ok || errno != rawErrorPipeConnected { + syscall.CloseHandle(h) + t.Fatalf("client never connected to the fake server: %v", err) + } + } + c.Abort() + var got, returned bool + select { + case got = <-ready: + returned = true + case <-time.After(2 * time.Second): + } + + syscall.CloseHandle(h) + if !returned { + <-ready + t.Fatal("refresh must return within 2s of the abort") + } + c.Close() + if got { + t.Error("refresh after abort reported ready") + } +} + +// N1 guard: the configured call timeout must be honored on the pipe path. +func TestWinRobustPipeCallHonorsTimeoutLongerThan100ms(t *testing.T) { + svc := uniqueWinService("go_wrob_slow_handler") + rs := startWinRobustServer(svc, testWinServerConfig(), protocol.MethodIncrement, winSlowIncrementDispatch(300*time.Millisecond), 2) + defer rs.stopWithin(5 * time.Second) + + c := NewIncrementClient(winTestRunDir, svc, testWinClientConfig()) + defer c.Close() + c.Refresh() + start := time.Now() + v, err := c.CallIncrementWithTimeout(41, 2000) + if err != nil || v != 42 { + t.Errorf("a 300ms handler must succeed with a 2000ms timeout: v=%d err=%v after %v", v, err, time.Since(start)) + } +} + +// N2: shutdown must not wait forever on a worker blocked writing a response +// to a client that stopped reading. +func TestWinRobustShutdownNotBlockedByNonReadingClient(t *testing.T) { + const big = 8 * 1024 * 1024 + svc := uniqueWinService("go_wrob_noread") + scfg := testWinServerConfig() + scfg.MaxRequestPayloadBytes = big + scfg.MaxResponsePayloadBytes = big + // After the handler returns, the worker can only be writing the 4 MiB + // response, which blocks because the client never reads. + handled := make(chan struct{}, 1) + reverse := winStringReverseDispatchHandler() + rs := startWinRobustServer(svc, scfg, protocol.MethodStringReverse, func(req, resp []byte) (int, error) { + n, err := reverse(req, resp) + handled <- struct{}{} + return n, err + }, 2) + + ccfg := testWinClientConfig() + ccfg.MaxRequestPayloadBytes = big + ccfg.MaxResponsePayloadBytes = big + session, err := windows.Connect(winTestRunDir, svc, &ccfg) + if err != nil { + t.Fatalf("connect: %v", err) + } + text := strings.Repeat("x", 4*1024*1024) + payload := make([]byte, protocol.StringReverseHdrSize+len(text)+1) + n := protocol.StringReverseEncode(text, payload) + hdr := protocol.Header{Kind: protocol.KindRequest, Code: protocol.MethodStringReverse, ItemCount: 1, MessageID: 1} + if err := session.Send(&hdr, payload[:n]); err != nil { + t.Fatalf("send: %v", err) + } + waitClosed(handled, "string reverse handler") + + stopped := rs.stopWithin(3 * time.Second) + session.Close() + <-rs.doneCh + if !stopped { + t.Error("server Run must return within 3s of Stop while a worker is blocked writing") + } +} + +// N3: Stop issued before Run must not be lost. +func TestWinRobustStopBeforeRunIsHonored(t *testing.T) { + svc := uniqueWinService("go_wrob_stop_first") + s := NewServerWithWorkers(winTestRunDir, svc, testWinServerConfig(), protocol.MethodIncrement, winSlowIncrementDispatch(0), 2) + s.Stop() + doneCh := make(chan struct{}) + go func() { + defer close(doneCh) + _ = s.Run() + }() + + select { + case <-doneCh: + case <-time.After(2 * time.Second): + t.Error("Run after Stop must return promptly instead of serving forever") + s.Stop() + <-doneCh + } +} + +// N5: an SHM client must notice a crashed server instead of waiting out its +// whole timeout. +func TestWinRobustShmClientNoticesCrashedServer(t *testing.T) { + svc := uniqueWinService("go_wrob_dead_server") + cmd := winSpawnHelper(t, "shm_server", svc) + + c := NewIncrementClient(winTestRunDir, svc, testWinShmClientConfig()) + defer c.Close() + waitWinClientReady(t, c) + + type outcome struct { + err error + elapsed time.Duration + } + result := make(chan outcome, 1) + go func() { + start := time.Now() + _, err := c.CallIncrementWithTimeout(1, 10000) + result <- outcome{err, time.Since(start)} + }() + waitClosed(winHelperEntered[cmd], "helper server handler") // request parked in the stalled handler + _ = cmd.Process.Kill() + _ = cmd.Wait() + + r := <-result + if r.err == nil || r.elapsed > 3500*time.Millisecond { + t.Errorf("call must fail within 3s of the server crash (timeout is 10s): err=%v after %v", r.err, r.elapsed) + } + if !errors.Is(r.err, protocol.ErrDisconnected) { + t.Errorf("call must report the server as disconnected: err=%v", r.err) + } +} + +// A server at worker capacity completes the handshake, then closes the +// session and destroys the prepared WinSHM objects. The client must not spend +// its attach retry budget (5 s) on objects that will never appear, and must +// keep WinSHM enabled for its next connect. +func TestWinRobustShmAttachStopsWhenServerClosesSession(t *testing.T) { + svc := uniqueWinService("go_wrob_attach_refused") + rs := startWinRobustServer(svc, testWinShmServerConfig(), protocol.MethodIncrement, winSlowIncrementDispatch(0), 1) + + holder := NewIncrementClient(winTestRunDir, svc, testWinShmClientConfig()) + holder.Refresh() + holderReady := holder.Ready() + + refused := NewIncrementClient(winTestRunDir, svc, testWinShmClientConfig()) + defer refused.Close() + start := time.Now() + refused.Refresh() + elapsed := time.Since(start) + + holder.Close() + waitSessionExits(rs.server, 1) + refused.Refresh() + ready, usesShm := refused.Ready(), refused.shm != nil + refused.Close() + rs.stopWithin(5 * time.Second) + + if !holderReady { + t.Error("first client must hold the only worker") + } + if elapsed >= time.Second { + t.Errorf("refused connect must return within 1s (attach retry budget is 5s): %v", elapsed) + } + if !ready { + t.Error("refused client must connect once the worker is free") + } + if !usesShm { + t.Error("refused client must still use WinSHM") + } +} + +// A worker whose thread handle cannot be opened could not be cancelled at +// stop, so the server refuses its connection before HELLO_ACK and keeps +// serving the next one. +func TestWinRobustWorkerWithoutThreadHandleRefused(t *testing.T) { + // Installed before the server starts, restored after it stopped. + var failing atomic.Bool + failing.Store(true) + open := openWorkerThread + openWorkerThread = func(tid uintptr) syscall.Handle { + if failing.Load() { + return 0 + } + return open(tid) + } + defer func() { openWorkerThread = open }() + + svc := uniqueWinService("go_win_rob_nothread") + ts := startTestServerWin(svc, protocol.MethodIncrement, winPingPongIncrementDispatch()) + defer ts.stop() + + refused := NewIncrementClient(winTestRunDir, svc, testWinClientConfig()) + refused.Refresh() + ready := refused.Ready() + refused.Close() + if ready { + t.Fatal("client became ready although its worker had no thread handle") + } + + failing.Store(false) + c := NewIncrementClient(winTestRunDir, svc, testWinClientConfig()) + defer c.Close() + c.Refresh() + if v, err := c.CallIncrement(1); err != nil || v != 2 { + t.Fatalf("call after the refused connection = %d, %v", v, err) + } +} + +const ( + winStalledReaderRequestBytes = 8 * 1024 * 1024 + winStalledReaderPids = 1024 * 1024 +) + +// startWinStalledReader completes the handshake and never reads. It closes +// started once the request's first bytes arrived, then holds the session +// until release is closed. +func startWinStalledReader(t *testing.T, svc string, started, release chan struct{}) chan struct{} { + t.Helper() + cfg := testWinServerConfig() + cfg.MaxRequestPayloadBytes = winStalledReaderRequestBytes + listener, err := windows.Listen(winTestRunDir, svc, cfg) + if err != nil { + t.Fatalf("listen: %v", err) + } + done := make(chan struct{}) + go func() { + defer close(done) + defer listener.Close() + session, err := listener.Accept() + if err != nil { + close(started) + return + } + defer session.Close() + if readable, err := session.WaitReadable(10_000); err != nil || !readable { + t.Errorf("no request bytes arrived: readable=%v err=%v", readable, err) + } + close(started) + select { + case <-release: + case <-time.After(10 * time.Second): + } + }() + return done +} + +// A request the server never reads ends at the call deadline, or on abort, +// instead of blocking in the write. +func TestWinRobustSendBoundedByDeadlineAndAbort(t *testing.T) { + pids := make([]uint32, winStalledReaderPids) + for i := range pids { + pids[i] = uint32(i + 1) // #nosec G115 -- bounded by winStalledReaderPids. + } + for _, useAbort := range []bool{false, true} { + name := "send_deadline" + if useAbort { + name = "send_abort" + } + t.Run(name, func(t *testing.T) { + svc := uniqueWinService("go_winrob_" + name) + started := make(chan struct{}) + release := make(chan struct{}) + done := startWinStalledReader(t, svc, started, release) + + ccfg := testWinClientConfig() + ccfg.MaxRequestPayloadBytes = winStalledReaderRequestBytes + client := NewAppsLookupClient(winTestRunDir, svc, ccfg) + client.SetLookupLogicalConfig(LookupLogicalConfig{MaxItems: winStalledReaderPids}) + waitWinClientReady(t, client) + + timeoutMs := uint32(200) + if useAbort { + timeoutMs = 30_000 + } + start := time.Now() + result := make(chan error, 1) + go func() { + _, err := client.CallAppsLookupWithTimeout(pids, timeoutMs) + result <- err + }() + if useAbort { + <-started + client.Abort() + } + err := <-result + elapsed := time.Since(start) + close(release) + <-done + client.Close() + + want := protocol.ErrTimeout + if useAbort { + want = protocol.ErrAborted + } + if !errors.Is(err, want) { + t.Fatalf("blocked send = %v, want %v", err, want) + } + if elapsed > 5*time.Second { + t.Fatalf("blocked send took %v", elapsed) + } + }) + } +} diff --git a/src/go/pkg/netipc/service/raw/server.go b/src/go/pkg/netipc/service/raw/server.go index 2add206c..294f5ed9 100644 --- a/src/go/pkg/netipc/service/raw/server.go +++ b/src/go/pkg/netipc/service/raw/server.go @@ -2,6 +2,7 @@ package raw import ( "errors" + "math" "sync/atomic" "time" @@ -47,6 +48,84 @@ func (s *Server) initCommon( s.responsePayloadGrowthCeiling = responsePayloadGrowthCeiling s.learnedRequestPayloadBytes.Store(maxRequestPayloadBytes) s.learnedResponsePayloadBytes.Store(maxResponsePayloadBytes) + s.timings = defaultServerTimings() + s.listening = make(chan struct{}) +} + +const serverStopRetry = 50 * time.Millisecond + +// serverTimings holds the server's internal waits. Production always uses +// defaultServerTimings; tests shorten them per instance so they can prove +// timeout behaviour without waiting out production values. +type serverTimings struct { + // pollMs bounds every blocking wait, so it sets how fast stop is noticed. + pollMs uint32 + // stopGrace is how long stop waits for workers to exit on their own + // before interrupting their blocking I/O. + stopGrace time.Duration + // handshakeMs bounds how long a connection may take to send HELLO. + handshakeMs uint32 +} + +func defaultServerTimings() serverTimings { + return serverTimings{ + pollMs: serverPollTimeoutMs, + stopGrace: 1000 * time.Millisecond, + handshakeMs: protocol.ServerHandshakeTimeoutMs, + } +} + +// slotWait is how long a connection waits for a worker slot before it is +// refused. Two poll slices: a worker whose client left exits within one. +func (t serverTimings) slotWait() time.Duration { + return 2 * time.Duration(t.pollMs) * time.Millisecond +} + +// Listening returns a channel closed once Run has bound the listener and +// clients can connect. It stays open if Run fails or returns before binding. +func (s *Server) Listening() <-chan struct{} { + return s.listening +} + +// headerPayloadLen returns the SHM area size for a payload limit, or false +// when header plus payload does not fit in uint32. +func headerPayloadLen(payload uint32) (uint32, bool) { + if payload > math.MaxUint32-uint32(protocol.HeaderSize) { + return 0, false + } + return payload + uint32(protocol.HeaderSize), true +} + +// notifyTestHook signals a test hook channel; production servers leave the +// hooks nil. +func (s *Server) notifyTestHook(ch chan struct{}) { + if ch == nil { + return + } + select { + case ch <- struct{}{}: + default: + } +} + +// markListening records that the listener is bound and accepting. +func (s *Server) markListening() { + s.listeningOnce.Do(func() { close(s.listening) }) +} + +// stopChan is closed once a stop is requested; handshakes wait on it. +func (s *Server) stopChan() <-chan struct{} { + s.stopInit.Do(func() { s.stopCh = make(chan struct{}) }) + return s.stopCh +} + +// markStopped records a stop request. The flag is never cleared, so a stop +// that lands before Run starts still stops it. +func (s *Server) markStopped() { + s.stopRequested.Store(true) + s.running.Store(false) + s.stopChan() + s.stopClose.Do(func() { close(s.stopCh) }) } func (s *Server) retryAcceptAfter(cleanup func()) bool { @@ -60,17 +139,30 @@ func (s *Server) retryAcceptAfter(cleanup func()) bool { return true } +// acquireWorkerSlot waits up to timings.slotWait() for a free worker, so a +// client that reconnects right after closing is not refused while its old +// worker exits. The accept loop has nothing else to serve while full, so +// waiting costs nothing. func (s *Server) acquireWorkerSlot(sem chan struct{}, cleanup func(), closeSession func()) bool { select { case sem <- struct{}{}: return true default: - if cleanup != nil { - cleanup() - } - closeSession() - return false } + + timer := time.NewTimer(s.timings.slotWait()) + defer timer.Stop() + select { + case sem <- struct{}{}: + return true + case <-timer.C: + case <-s.stopChan(): + } + if cleanup != nil { + cleanup() + } + closeSession() + return false } func (s *Server) startSessionWorker(sem chan struct{}, run func()) { @@ -81,6 +173,7 @@ func (s *Server) startSessionWorker(sem chan struct{}, run func()) { // Session handler panicked; keep the accept loop alive. } <-sem + s.notifyTestHook(s.sessionExited) s.wg.Done() }() run() @@ -136,6 +229,9 @@ func (s *Server) handleServerSession(ops serverSessionOps) { defer ops.close() + // One response header per session: its address goes to ops.send, so a + // per-request variable would be a heap allocation per request. + var respHdr protocol.Header for s.running.Load() { hdr, payload, action := ops.receive(recvBuf) switch action { @@ -149,7 +245,9 @@ func (s *Server) handleServerSession(ops serverSessionOps) { return } - respHdr, responseLen, closeAfterSend := s.handleServerRequest( + var responseLen int + var closeAfterSend bool + respHdr, responseLen, closeAfterSend = s.handleServerRequest( hdr, payload, respBuf, itemRespBuf, ops.maxResponsePayloadBytes) if err := ops.send(&respHdr, respBuf[:responseLen], &msgBuf); err != nil { return diff --git a/src/go/pkg/netipc/service/raw/server_helpers_test.go b/src/go/pkg/netipc/service/raw/server_helpers_test.go new file mode 100644 index 00000000..1ee96c7a --- /dev/null +++ b/src/go/pkg/netipc/service/raw/server_helpers_test.go @@ -0,0 +1,98 @@ +package raw + +import ( + "fmt" + "time" +) + +// testServerTimings keep timeout tests in milliseconds while staying well +// above scheduling noise. +var testServerTimings = serverTimings{ + pollMs: 10, + stopGrace: 200 * time.Millisecond, + handshakeMs: 500, +} + +// testShmAttachTimeout replaces the 5 s production SHM attach window. +const testShmAttachTimeout = 300 * time.Millisecond + +// testWaitLimit bounds waits that only expire when the code under test is +// broken; reaching it fails the test. +const testWaitLimit = 10 * time.Second + +// runServerForTest runs s with test timings and returns once it accepts +// connections. It panics, failing the test binary loudly, if Run exits or +// never binds. +func runServerForTest(s *Server) chan struct{} { + s.timings = testServerTimings + s.sessionExited = make(chan struct{}, 64) + s.handshakeStarted = make(chan struct{}, 64) + s.shmCreated = make(chan struct{}, 64) + done := make(chan struct{}) + go func() { + defer close(done) + _ = s.Run() + }() + select { + case <-s.Listening(): + case <-done: + panic(fmt.Sprintf("server %s: Run returned before listening", s.serviceName)) + case <-time.After(testWaitLimit): + panic(fmt.Sprintf("server %s: not listening after %v", s.serviceName, testWaitLimit)) + } + return done +} + +// waitClosed fails loudly unless ch closes or delivers a value within +// testWaitLimit. +func waitClosed(ch <-chan struct{}, what string) { + select { + case <-ch: + case <-time.After(testWaitLimit): + panic(what + ": not done after " + testWaitLimit.String()) + } +} + +// useTestTimings shortens a client's internal waits. +func (c *Client) useTestTimings() *Client { + c.shmAttachTimeout = testShmAttachTimeout + return c +} + +// waitHandshakesStarted waits until n workers of s are waiting for HELLO. +func waitHandshakesStarted(s *Server, n int) { + for range n { + select { + case <-s.handshakeStarted: + case <-time.After(testWaitLimit): + panic(fmt.Sprintf("server %s: no handshake started within %v", s.serviceName, testWaitLimit)) + } + } +} + +// waitSessionExits waits until n session workers of s have exited and freed +// their slots. Only servers started by runServerForTest report exits. +func waitSessionExits(s *Server, n int) { + for range n { + select { + case <-s.sessionExited: + case <-time.After(testWaitLimit): + panic(fmt.Sprintf("server %s: session worker did not exit within %v", s.serviceName, testWaitLimit)) + } + } +} + +// shmCreatedCount drains and counts the SHM creations s has reported. A +// server creates the objects before it sends HELLO_ACK, so every creation for +// a handshake the client finished is already reported. +func shmCreatedCount(s *Server) int { + n := 0 + for { + select { + case <-s.shmCreated: + n++ + default: + return n + } + } +} diff --git a/src/go/pkg/netipc/service/raw/server_unix.go b/src/go/pkg/netipc/service/raw/server_unix.go index d3aa9718..e29ecbac 100644 --- a/src/go/pkg/netipc/service/raw/server_unix.go +++ b/src/go/pkg/netipc/service/raw/server_unix.go @@ -5,6 +5,8 @@ package raw import ( "sync" "sync/atomic" + "syscall" + "time" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/posix" @@ -26,8 +28,19 @@ type Server struct { nextSessionID atomic.Uint64 workerCount int wg sync.WaitGroup - listenerMu sync.Mutex - listener *posix.Listener + stopRequested atomic.Bool + stopInit sync.Once + stopClose sync.Once + stopCh chan struct{} + timings serverTimings + listening chan struct{} // closed once the listener accepts + listeningOnce sync.Once + sessionExited chan struct{} // test hook, nil in production: signalled after a worker frees its slot + handshakeStarted chan struct{} // test hook, nil in production: signalled before a worker waits for HELLO + shmCreated chan struct{} // test hook, nil in production: signalled once a session's SHM region exists + failShmCreate atomic.Bool // test hook: makes SHM region creation fail + sessionFdsMu sync.Mutex + sessionFds map[int]struct{} } // NewServer creates a new managed server. workerCount limits the @@ -64,55 +77,62 @@ func NewServerWithWorkers( const posixShmProfiles = protocol.ProfileSHMHybrid | protocol.ProfileSHMFutex -func (s *Server) prepareAcceptConfig() (uint64, posix.ServerConfig, *posix.ShmContext, bool) { +func (s *Server) prepareAcceptConfig() (uint64, posix.ServerConfig) { sessionID := s.nextSessionID.Add(1) cfg := s.config cfg.MaxRequestPayloadBytes = s.learnedRequestPayloadBytes.Load() cfg.MaxResponsePayloadBytes = s.learnedResponsePayloadBytes.Load() + return sessionID, cfg +} - if cfg.SupportedProfiles&posixShmProfiles == 0 { - return sessionID, cfg, nil, true +// createShm creates a session's SHM region for the agreed payload limits. +func (s *Server) createShm(sessionID uint64, requestPayload, responsePayload uint32) *posix.ShmContext { + if s.failShmCreate.Load() { + return nil } - - shm, err := posix.ShmServerCreate( - s.runDir, s.serviceName, sessionID, - cfg.MaxRequestPayloadBytes+uint32(protocol.HeaderSize), - cfg.MaxResponsePayloadBytes+uint32(protocol.HeaderSize), - ) - if err == nil { - return sessionID, cfg, shm, true + reqCap, reqOK := headerPayloadLen(requestPayload) + respCap, respOK := headerPayloadLen(responsePayload) + if !reqOK || !respOK { + return nil } - - cfg.SupportedProfiles &^= posixShmProfiles - cfg.PreferredProfiles &^= posixShmProfiles - if cfg.SupportedProfiles == 0 { - return sessionID, cfg, nil, false + shm, err := posix.ShmServerCreate(s.runDir, s.serviceName, sessionID, reqCap, respCap) + if err != nil { + return nil } - - return sessionID, cfg, nil, true + s.notifyTestHook(s.shmCreated) + return shm } -// Run starts the acceptor loop. Blocking. Accepts clients, spawns a -// goroutine per session (up to workerCount concurrently). -// Returns when Stop() is called or on fatal error. +// Run starts the acceptor loop. Blocking. Accepts clients and runs each +// connection, handshake included, in its own goroutine (up to workerCount +// concurrently). Returns when Stop() is called or on fatal error. The server +// is single-use: a Stop before Run makes Run return at once. func (s *Server) Run() error { + if s.stopRequested.Load() { + return nil + } + posix.ShmCleanupStale(s.runDir, s.serviceName) listener, err := posix.Listen(s.runDir, s.serviceName, s.config) if err != nil { return err } - s.setListener(listener) - defer s.closeListener(listener) + defer listener.Close() s.running.Store(true) + // Stop may have run between the check above and the store. + if s.stopRequested.Load() { + s.running.Store(false) + } + s.markListening() /* Semaphore channel limits concurrent sessions */ sem := make(chan struct{}, s.workerCount) for s.running.Load() { // Poll the listener fd before blocking on accept - ready := pollFd(listener.Fd(), serverPollTimeoutMs) + ready := pollFd(listener.Fd(), int(s.timings.pollMs)) if ready < 0 { break } @@ -120,81 +140,127 @@ func (s *Server) Run() error { continue } - sessionID, acceptCfg, precreatedShm, ok := s.prepareAcceptConfig() - if !ok { - s.retryAcceptAfter(nil) - continue - } - - session, err := listener.AcceptWithConfig(sessionID, acceptCfg) + pending, err := listener.AcceptPending() if err != nil { - if !s.retryAcceptAfter(func() { - if precreatedShm != nil { - precreatedShm.ShmDestroy() - } - }) { + if !s.retryAcceptAfter(nil) { break } continue } - if !s.acquireWorkerSlot(sem, func() { - if precreatedShm != nil { - precreatedShm.ShmDestroy() - } - }, session.Close) { + // Over capacity: close before HELLO_ACK so the client sees a + // failed connect instead of a session that dies on first use. + if !s.acquireWorkerSlot(sem, nil, pending.Close) { continue } - var shm *posix.ShmContext - if session.SelectedProfile == protocol.ProfileSHMHybrid || - session.SelectedProfile == protocol.ProfileSHMFutex { - if precreatedShm == nil { - session.Close() - <-sem - continue - } - shm = precreatedShm - } else if precreatedShm != nil { - precreatedShm.ShmDestroy() - } - s.startSessionWorker(sem, func() { - s.handleSession(session, shm) + s.serveConnection(pending) }) } - // Wait for all active session goroutines to finish - s.wg.Wait() + s.markStopped() + // Close the socket now; the deferred Close releases the endpoint lock + // after the workers have exited. + listener.StopListening() + s.stopSessionWorkers() return nil } -// Stop signals the server to stop and unblocks Accept by closing the listener. +// Stop signals the server to stop. Run owns the listener and closes it on +// exit, so Stop never touches a descriptor Run may still be polling. func (s *Server) Stop() { - s.running.Store(false) - s.closeListener(nil) + s.markStopped() } -func (s *Server) setListener(listener *posix.Listener) { - s.listenerMu.Lock() - s.listener = listener - s.listenerMu.Unlock() +func (s *Server) serveConnection(pending *posix.PendingConn) { + sessionID, acceptCfg := s.prepareAcceptConfig() + s.notifyTestHook(s.handshakeStarted) + + // The region is created inside the handshake, only once SHM is selected. + var shm *posix.ShmContext + prepare := func(profile, requestPayload, responsePayload uint32) bool { + if profile&posixShmProfiles == 0 { + return true + } + shm = s.createShm(sessionID, requestPayload, responsePayload) + return shm != nil + } + session, err := pending.HandshakePrepared( + sessionID, acceptCfg, s.timings.handshakeMs, s.stopChan(), prepare) + if err != nil { + if shm != nil { + shm.ShmDestroy() + } + return + } + + if session.SelectedProfile == protocol.ProfileSHMHybrid || + session.SelectedProfile == protocol.ProfileSHMFutex { + // SHM profile guarantee: the prepare step created the region before + // the ACK selected SHM. + if shm == nil { + session.Close() + return + } + } else if shm != nil { + shm.ShmDestroy() + shm = nil + } + + s.trackSessionFd(session.Fd()) + s.handleSession(session, shm) } -func (s *Server) closeListener(listener *posix.Listener) { - s.listenerMu.Lock() - target := listener - if target == nil { - target = s.listener +func (s *Server) trackSessionFd(fd int) { + s.sessionFdsMu.Lock() + if s.sessionFds == nil { + s.sessionFds = make(map[int]struct{}) } - if target != nil && s.listener == target { - s.listener = nil + s.sessionFds[fd] = struct{}{} + s.sessionFdsMu.Unlock() +} + +// closeTrackedSession closes a session under the same lock the stopper uses +// to shut sessions down, so a shutdown never reaches a reused fd number. +func (s *Server) closeTrackedSession(session *posix.Session) { + s.sessionFdsMu.Lock() + delete(s.sessionFds, session.Fd()) + session.Close() + s.sessionFdsMu.Unlock() +} + +// stopSessionWorkers gives workers a grace period to finish on their own, +// then shuts their sockets down so a worker blocked writing to a client that +// stopped reading wakes up. +func (s *Server) stopSessionWorkers() { + done := make(chan struct{}) + go func() { + s.wg.Wait() + close(done) + }() + + select { + case <-done: + return + case <-time.After(s.timings.stopGrace): } - s.listenerMu.Unlock() - if target != nil { - target.Close() + ticker := time.NewTicker(serverStopRetry) + defer ticker.Stop() + for { + s.sessionFdsMu.Lock() + for fd := range s.sessionFds { + _ = syscall.Shutdown(fd, syscall.SHUT_RDWR) + } + s.sessionFdsMu.Unlock() + + select { + case <-done: + return + case <-ticker.C: + } } } @@ -204,9 +270,14 @@ func (s *Server) handleSession(session *posix.Session, shm *posix.ShmContext) { maxResponsePayloadBytes: session.MaxResponsePayloadBytes, receive: func(recvBuf []byte) (protocol.Header, []byte, serverReceiveAction) { if shm != nil { - mlen, err := shm.ShmReceive(recvBuf, serverPollTimeoutMs) + mlen, err := shm.ShmReceive(recvBuf, s.timings.pollMs) if err != nil { if err == posix.ErrShmTimeout { + // A client that left (cleanly or by crashing) + // never sends again; its socket is the only sign. + if peerDisconnected(session.Fd()) { + return protocol.Header{}, nil, serverReceiveStop + } return protocol.Header{}, nil, serverReceiveContinue } return protocol.Header{}, nil, serverReceiveStop @@ -221,7 +292,7 @@ func (s *Server) handleSession(session *posix.Session, shm *posix.ShmContext) { return hdr, recvBuf[protocol.HeaderSize:mlen], serverReceiveOK } - ready := pollFd(session.Fd(), serverPollTimeoutMs) + ready := pollFd(session.Fd(), int(s.timings.pollMs)) if ready < 0 { return protocol.Header{}, nil, serverReceiveStop } @@ -249,7 +320,7 @@ func (s *Server) handleSession(session *posix.Session, shm *posix.ShmContext) { if shm != nil { shm.ShmDestroy() } - session.Close() + s.closeTrackedSession(session) }, }) } diff --git a/src/go/pkg/netipc/service/raw/server_windows.go b/src/go/pkg/netipc/service/raw/server_windows.go index b170e096..5e508c6b 100644 --- a/src/go/pkg/netipc/service/raw/server_windows.go +++ b/src/go/pkg/netipc/service/raw/server_windows.go @@ -3,8 +3,11 @@ package raw import ( + "runtime" "sync" "sync/atomic" + "syscall" + "time" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" windows "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/windows" @@ -26,7 +29,20 @@ type Server struct { workerCount int wg sync.WaitGroup listenerMu sync.Mutex - listener *windows.Listener // stored so Stop() can close it + listener *windows.Listener // stored so Stop() can wake it + stopRequested atomic.Bool + stopInit sync.Once + stopClose sync.Once + stopCh chan struct{} + timings serverTimings + listening chan struct{} // closed once the listener accepts + listeningOnce sync.Once + sessionExited chan struct{} // test hook, nil in production: signalled after a worker frees its slot + handshakeStarted chan struct{} // test hook, nil in production: signalled before a worker waits for HELLO + shmCreated chan struct{} // test hook, nil in production: signalled once a session's SHM objects exist + failShmCreate atomic.Bool // test hook: makes SHM object creation fail + workerThreadsMu sync.Mutex + workerThreads map[syscall.Handle]struct{} } // NewServer creates a new managed server. @@ -60,161 +76,119 @@ func NewServerWithWorkers( return s } -type preparedWinShm struct { - hybrid *windows.WinShmContext - busywait *windows.WinShmContext -} - -func (p *preparedWinShm) take(profile uint32) *windows.WinShmContext { - if p == nil { - return nil - } - switch profile { - case windows.WinShmProfileHybrid: - ctx := p.hybrid - p.hybrid = nil - return ctx - case windows.WinShmProfileBusywait: - ctx := p.busywait - p.busywait = nil - return ctx - default: - return nil - } -} - -func (p *preparedWinShm) destroyAll() { - if p == nil { - return - } - if p.hybrid != nil { - p.hybrid.WinShmDestroy() - p.hybrid = nil - } - if p.busywait != nil { - p.busywait.WinShmDestroy() - p.busywait = nil - } -} - const winShmProfiles = windows.WinShmProfileHybrid | windows.WinShmProfileBusywait -func (s *Server) prepareAcceptConfig() (uint64, windows.ServerConfig, *preparedWinShm, bool) { +func (s *Server) prepareAcceptConfig() (uint64, windows.ServerConfig) { sessionID := s.nextSessionID.Add(1) cfg := s.config cfg.MaxRequestPayloadBytes = s.learnedRequestPayloadBytes.Load() cfg.MaxResponsePayloadBytes = s.learnedResponsePayloadBytes.Load() + return sessionID, cfg +} - if cfg.SupportedProfiles&winShmProfiles == 0 { - return sessionID, cfg, nil, true - } - - prepared := &preparedWinShm{} - for _, profile := range []uint32{windows.WinShmProfileHybrid, windows.WinShmProfileBusywait} { - if cfg.SupportedProfiles&profile == 0 { - continue - } - shm, err := windows.WinShmServerCreate( - s.runDir, s.serviceName, - cfg.AuthToken, - sessionID, - profile, - cfg.MaxRequestPayloadBytes+uint32(protocol.HeaderSize), - cfg.MaxResponsePayloadBytes+uint32(protocol.HeaderSize), - ) - if err != nil { - cfg.SupportedProfiles &^= profile - cfg.PreferredProfiles &^= profile - continue - } - if profile == windows.WinShmProfileHybrid { - prepared.hybrid = shm - } else { - prepared.busywait = shm - } +// createShm creates a session's SHM objects for one profile and the agreed +// payload limits. +func (s *Server) createShm(sessionID uint64, profile, requestPayload, responsePayload uint32) *windows.WinShmContext { + if s.failShmCreate.Load() { + return nil } - - if cfg.SupportedProfiles == 0 { - prepared.destroyAll() - return sessionID, cfg, nil, false + reqCap, reqOK := headerPayloadLen(requestPayload) + respCap, respOK := headerPayloadLen(responsePayload) + if !reqOK || !respOK { + return nil } - - if prepared.hybrid == nil && prepared.busywait == nil { - return sessionID, cfg, nil, true + shm, err := windows.WinShmServerCreate( + s.runDir, s.serviceName, s.config.AuthToken, sessionID, profile, reqCap, respCap) + if err != nil { + return nil } - - return sessionID, cfg, prepared, true + s.notifyTestHook(s.shmCreated) + return shm } -// Run starts the acceptor loop. Blocking. +// Run starts the acceptor loop. Blocking. Accepts clients and runs each +// connection, handshake included, in its own goroutine (up to workerCount +// concurrently). The server is single-use: a Stop before Run makes Run +// return at once. func (s *Server) Run() error { + if s.stopRequested.Load() { + return nil + } + listener, err := windows.Listen(s.runDir, s.serviceName, s.config) if err != nil { return err } s.setListener(listener) - defer s.closeListener(listener) s.running.Store(true) + // Stop may have run before the listener was published, so it could not + // wake the accept below. + if s.stopRequested.Load() { + s.running.Store(false) + } + s.markListening() sem := make(chan struct{}, s.workerCount) for s.running.Load() { - sessionID, acceptCfg, preparedShm, ok := s.prepareAcceptConfig() - if !ok { - s.retryAcceptAfter(nil) - continue - } - - session, err := listener.AcceptWithConfig(sessionID, acceptCfg) + pending, err := listener.AcceptPending() if err != nil { - if !s.retryAcceptAfter(func() { - if preparedShm != nil { - preparedShm.destroyAll() - } - }) { + if !s.retryAcceptAfter(nil) { break } continue } - if !s.acquireWorkerSlot(sem, func() { - if preparedShm != nil { - preparedShm.destroyAll() - } - }, session.Close) { - continue + // The stop wake connection, or a client racing the stop. + if !s.running.Load() { + pending.Close() + break } - var shm *windows.WinShmContext - if session.SelectedProfile == windows.WinShmProfileHybrid || - session.SelectedProfile == windows.WinShmProfileBusywait { - shm = preparedShm.take(session.SelectedProfile) - if shm == nil { - if preparedShm != nil { - preparedShm.destroyAll() - } - session.Close() - <-sem - continue - } - } - if preparedShm != nil { - preparedShm.destroyAll() + // Over capacity: close before HELLO_ACK so the client sees a + // failed connect instead of a session that dies on first use. + if !s.acquireWorkerSlot(sem, nil, pending.Close) { + continue } s.startSessionWorker(sem, func() { - s.handleSession(session, shm) + s.serveConnection(pending) }) } - s.wg.Wait() + s.markStopped() + s.setListener(nil) + listener.Close() + s.stopSessionWorkers() return nil } -// Stop signals the server to stop and unblocks Accept by closing the listener. +// Stop signals the server to stop and wakes its accept. Run owns the +// listener and closes it on exit, so Stop never closes a handle Run may +// still be using. func (s *Server) Stop() { - s.running.Store(false) - s.closeListener(nil) + s.markStopped() + + s.listenerMu.Lock() + listener := s.listener + s.listenerMu.Unlock() + if listener == nil { + return + } + // One wake can be lost if it lands between accepts; repeat until Run + // unpublishes the listener. + go func() { + for { + listener.Wake() + time.Sleep(serverStopRetry) + s.listenerMu.Lock() + current := s.listener + s.listenerMu.Unlock() + if current != listener { + return + } + } + }() } func (s *Server) setListener(listener *windows.Listener) { @@ -223,19 +197,132 @@ func (s *Server) setListener(listener *windows.Listener) { s.listenerMu.Unlock() } -func (s *Server) closeListener(listener *windows.Listener) { - s.listenerMu.Lock() - target := listener - if target == nil { - target = s.listener +func (s *Server) serveConnection(pending *windows.PendingConn) { + // CancelSynchronousIo targets a thread, so the worker keeps one thread + // for its whole life and publishes it to the stopper. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + thread := s.trackWorkerThread() + if thread == 0 { + // Stop could not cancel this worker's blocking pipe I/O: refuse the + // connection before HELLO_ACK, as at worker capacity. + pending.Close() + return + } + defer s.untrackWorkerThread(thread) + + sessionID, acceptCfg := s.prepareAcceptConfig() + s.notifyTestHook(s.handshakeStarted) + + // The SHM objects are created inside the handshake, only for the + // selected SHM profile. + var shm *windows.WinShmContext + prepare := func(profile, requestPayload, responsePayload uint32) bool { + if profile&winShmProfiles == 0 { + return true + } + shm = s.createShm(sessionID, profile, requestPayload, responsePayload) + return shm != nil } - if target != nil && s.listener == target { - s.listener = nil + session, err := pending.HandshakePrepared( + sessionID, acceptCfg, s.timings.handshakeMs, s.stopChan(), prepare) + if err != nil { + if shm != nil { + shm.WinShmDestroy() + } + return + } + + if session.SelectedProfile == windows.WinShmProfileHybrid || + session.SelectedProfile == windows.WinShmProfileBusywait { + // SHM profile guarantee: the prepare step created the selected + // profile's objects before the ACK selected it. + if shm == nil { + session.Close() + return + } + } else if shm != nil { + shm.WinShmDestroy() + shm = nil } - s.listenerMu.Unlock() - if target != nil { - target.Close() + s.handleSession(session, shm) +} + +var ( + serverKernel32 = syscall.NewLazyDLL("kernel32.dll") + procOpenThread = serverKernel32.NewProc("OpenThread") + procCancelSynchronousIo = serverKernel32.NewProc("CancelSynchronousIo") + procGetCurrentThreadID = serverKernel32.NewProc("GetCurrentThreadId") + threadTerminateAccess = uintptr(0x0001) +) + +// openWorkerThread opens a cancellable handle to thread tid, 0 on failure. +// Tests replace it to fail. +var openWorkerThread = func(tid uintptr) syscall.Handle { + h, _, _ := procOpenThread.Call(threadTerminateAccess, 0, tid) + return syscall.Handle(h) +} + +// trackWorkerThread opens a handle to the calling (locked) OS thread and +// registers it for cancellation. Returns 0 if the handle cannot be opened. +func (s *Server) trackWorkerThread() syscall.Handle { + tid, _, _ := procGetCurrentThreadID.Call() + thread := openWorkerThread(tid) + if thread == 0 { + return 0 + } + s.workerThreadsMu.Lock() + if s.workerThreads == nil { + s.workerThreads = make(map[syscall.Handle]struct{}) + } + s.workerThreads[thread] = struct{}{} + s.workerThreadsMu.Unlock() + return thread +} + +// untrackWorkerThread runs under the stopper's lock, so a cancel never +// reaches a thread that went back to the Go scheduler. +func (s *Server) untrackWorkerThread(thread syscall.Handle) { + if thread == 0 { + return + } + s.workerThreadsMu.Lock() + delete(s.workerThreads, thread) + syscall.CloseHandle(thread) + s.workerThreadsMu.Unlock() +} + +// stopSessionWorkers gives workers a grace period to finish on their own, +// then cancels their blocking pipe I/O until they exit. A single cancel can +// land before a worker enters its next blocking call, so it repeats. +func (s *Server) stopSessionWorkers() { + done := make(chan struct{}) + go func() { + s.wg.Wait() + close(done) + }() + + select { + case <-done: + return + case <-time.After(s.timings.stopGrace): + } + + ticker := time.NewTicker(serverStopRetry) + defer ticker.Stop() + for { + s.workerThreadsMu.Lock() + for thread := range s.workerThreads { + _, _, _ = procCancelSynchronousIo.Call(uintptr(thread)) + } + s.workerThreadsMu.Unlock() + + select { + case <-done: + return + case <-ticker.C: + } } } @@ -245,9 +332,14 @@ func (s *Server) handleSession(session *windows.Session, shm *windows.WinShmCont maxResponsePayloadBytes: session.MaxResponsePayloadBytes, receive: func(recvBuf []byte) (protocol.Header, []byte, serverReceiveAction) { if shm != nil { - mlen, err := shm.WinShmReceive(recvBuf, serverPollTimeoutMs) + mlen, err := shm.WinShmReceive(recvBuf, s.timings.pollMs) if err != nil { if err == windows.ErrWinShmTimeout { + // A crashed client never sets the close flag; its + // broken pipe is the only sign it left. + if pipePeerDisconnected(session) { + return protocol.Header{}, nil, serverReceiveStop + } return protocol.Header{}, nil, serverReceiveContinue } return protocol.Header{}, nil, serverReceiveStop @@ -262,7 +354,7 @@ func (s *Server) handleSession(session *windows.Session, shm *windows.WinShmCont return hdr, recvBuf[protocol.HeaderSize:mlen], serverReceiveOK } - ready, waitErr := session.WaitReadable(serverPollTimeoutMs) + ready, waitErr := session.WaitReadable(s.timings.pollMs) if waitErr != nil { return protocol.Header{}, nil, serverReceiveStop } diff --git a/src/go/pkg/netipc/service/raw/server_windows_unit_test.go b/src/go/pkg/netipc/service/raw/server_windows_unit_test.go index 98298ece..e18821dd 100644 --- a/src/go/pkg/netipc/service/raw/server_windows_unit_test.go +++ b/src/go/pkg/netipc/service/raw/server_windows_unit_test.go @@ -9,41 +9,7 @@ import ( windows "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/windows" ) -func TestPreparedWinShmNilAndUnknownProfile(t *testing.T) { - var nilPrepared *preparedWinShm - if got := nilPrepared.take(windows.WinShmProfileHybrid); got != nil { - t.Fatalf("nil prepared take = %v, want nil", got) - } - nilPrepared.destroyAll() - - prepared := &preparedWinShm{} - if got := prepared.take(protocol.ProfileBaseline); got != nil { - t.Fatalf("unknown prepared profile take = %v, want nil", got) - } - prepared.destroyAll() -} - -func TestPreparedWinShmTakeProfiles(t *testing.T) { - hybrid := &windows.WinShmContext{} - busywait := &windows.WinShmContext{} - prepared := &preparedWinShm{hybrid: hybrid, busywait: busywait} - - if got := prepared.take(windows.WinShmProfileHybrid); got != hybrid { - t.Fatalf("hybrid take = %v, want %v", got, hybrid) - } - if prepared.hybrid != nil { - t.Fatalf("hybrid context should be consumed") - } - - if got := prepared.take(windows.WinShmProfileBusywait); got != busywait { - t.Fatalf("busywait take = %v, want %v", got, busywait) - } - if prepared.busywait != nil { - t.Fatalf("busywait context should be consumed") - } -} - -func TestPrepareAcceptConfigWithoutWinShm(t *testing.T) { +func TestPrepareAcceptConfigUsesLearnedCapacities(t *testing.T) { cfg := windows.ServerConfig{ SupportedProfiles: protocol.ProfileBaseline, PreferredProfiles: protocol.ProfileBaseline, @@ -52,10 +18,7 @@ func TestPrepareAcceptConfigWithoutWinShm(t *testing.T) { } server := NewServer("run", "svc", cfg, protocol.MethodIncrement, nil) - sessionID, acceptCfg, prepared, ok := server.prepareAcceptConfig() - if !ok || prepared != nil { - t.Fatalf("prepareAcceptConfig = ok %v prepared %v, want true/nil", ok, prepared) - } + sessionID, acceptCfg := server.prepareAcceptConfig() if sessionID != 1 { t.Fatalf("session id = %d, want 1", sessionID) } @@ -69,7 +32,7 @@ func TestPrepareAcceptConfigWithoutWinShm(t *testing.T) { } } -func TestPrepareAcceptConfigRejectsInvalidWinShmService(t *testing.T) { +func TestCreateShmFailsForInvalidWinShmService(t *testing.T) { cfg := windows.ServerConfig{ SupportedProfiles: windows.WinShmProfileHybrid | windows.WinShmProfileBusywait, PreferredProfiles: windows.WinShmProfileHybrid | windows.WinShmProfileBusywait, @@ -78,16 +41,10 @@ func TestPrepareAcceptConfigRejectsInvalidWinShmService(t *testing.T) { } server := NewServer("run", "bad/name", cfg, protocol.MethodIncrement, nil) - sessionID, acceptCfg, prepared, ok := server.prepareAcceptConfig() - if ok || prepared != nil { - t.Fatalf("prepareAcceptConfig = ok %v prepared %v, want false/nil", ok, prepared) - } - if sessionID != 1 { - t.Fatalf("session id = %d, want 1", sessionID) - } - if acceptCfg.SupportedProfiles != 0 || acceptCfg.PreferredProfiles != 0 { - t.Fatalf("profiles = supported 0x%x preferred 0x%x, want both zero", - acceptCfg.SupportedProfiles, - acceptCfg.PreferredProfiles) + for _, profile := range []uint32{windows.WinShmProfileHybrid, windows.WinShmProfileBusywait} { + if shm := server.createShm(1, profile, 101, 202); shm != nil { + shm.WinShmDestroy() + t.Fatalf("createShm(profile 0x%x) with an invalid service = %v, want nil", profile, shm) + } } } diff --git a/src/go/pkg/netipc/service/raw/shm_posix_test.go b/src/go/pkg/netipc/service/raw/shm_posix_test.go new file mode 100644 index 00000000..3c000354 --- /dev/null +++ b/src/go/pkg/netipc/service/raw/shm_posix_test.go @@ -0,0 +1,709 @@ +//go:build linux || (freebsd && (amd64 || 386)) + +// SHM service tests: the POSIX SHM data plane exists on Linux only. + +package raw + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" + "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/posix" +) + +func encodeRawUnixMessage(hdr protocol.Header, payload []byte) []byte { + hdr.Magic = protocol.MagicMsg + hdr.Version = protocol.Version + hdr.HeaderLen = protocol.HeaderLen + hdr.PayloadLen = uint32(len(payload)) + + msg := make([]byte, protocol.HeaderSize+len(payload)) + hdr.Encode(msg[:protocol.HeaderSize]) + copy(msg[protocol.HeaderSize:], payload) + return msg +} + +func encodeUnixIncrementBatchPayload(t *testing.T, values ...uint64) []byte { + t.Helper() + + itemCount := uint32(len(values)) + if itemCount == 0 { + return nil + } + + bufSize := protocol.Align8(int(itemCount)*8) + + int(itemCount)*protocol.IncrementPayloadSize + + int(itemCount)*protocol.Alignment + buf := make([]byte, bufSize) + entries := make([]protocol.BatchEntry, len(values)) + + offset := protocol.Align8(int(itemCount) * 8) + for i, v := range values { + itemOff := offset + itemLen := protocol.IncrementPayloadSize + entries[i] = protocol.BatchEntry{Offset: uint32(itemOff), Length: uint32(itemLen)} + if protocol.IncrementEncode(v, buf[itemOff:itemOff+itemLen]) == 0 { + t.Fatalf("IncrementEncode(%d) failed", v) + } + offset += protocol.Align8(itemLen) + } + + if n := protocol.BatchDirEncode(entries, buf[:int(itemCount)*8]); n != int(itemCount)*8 { + t.Fatalf("BatchDirEncode returned %d, want %d", n, int(itemCount)*8) + } + + return buf[:offset] +} + +func TestUnixShmRoundTrip(t *testing.T) { + svc := uniqueUnixService("go_unix_shm_roundtrip") + ts := startTestServerUnixWithConfig( + svc, + testUnixShmServerConfig(), + protocol.MethodIncrement, + pingPongIncrementDispatch(), + ) + defer ts.stop() + + client := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) + defer client.Close() + + refreshUnixClientReady(t, client) + + if client.session == nil { + t.Fatal("expected negotiated session") + } + if client.shm == nil { + t.Fatal("expected SHM attachment") + } + if client.session.SelectedProfile != protocol.ProfileSHMHybrid { + t.Fatalf("selected profile = %d, want %d", client.session.SelectedProfile, protocol.ProfileSHMHybrid) + } + + got, err := client.CallIncrement(41) + if err != nil { + t.Fatalf("CallIncrement failed: %v", err) + } + if got != 42 { + t.Fatalf("CallIncrement = %d, want 42", got) + } +} + +func TestUnixShmAttachFailureFallsBackToBaseline(t *testing.T) { + svc := uniqueUnixService("go_unix_shm_attach_fail") + cfg := testUnixShmServerConfig() + ensureRunDir() + cleanupAll(svc) + + listener, err := posix.Listen(testRunDir, svc, cfg) + if err != nil { + t.Fatalf("posix.Listen failed: %v", err) + } + defer listener.Close() + + type attachFailureResult struct { + firstSelected uint32 + secondSelected uint32 + err error + } + doneCh := make(chan attachFailureResult, 1) + go func() { + first, err := listener.Accept() + if err != nil { + doneCh <- attachFailureResult{err: err} + return + } + defer first.Close() + firstSelected := first.SelectedProfile + + recvBuf := make([]byte, protocol.HeaderSize+4096) + _, _, err = first.Receive(recvBuf) + if err == nil { + doneCh <- attachFailureResult{err: errors.New("expected first receive to fail after SHM attach fallback disconnect")} + return + } + + second, err := listener.Accept() + if err != nil { + doneCh <- attachFailureResult{err: err} + return + } + defer second.Close() + secondSelected := second.SelectedProfile + + _, _, err = second.Receive(recvBuf) + if err == nil { + doneCh <- attachFailureResult{err: errors.New("expected second receive to fail after client close")} + return + } + + doneCh <- attachFailureResult{ + firstSelected: firstSelected, + secondSelected: secondSelected, + } + }() + + // The listener negotiates SHM but never creates a region, so the attach + // window runs out; the test window keeps that in milliseconds. + client := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()).useTestTimings() + + if changed := client.Refresh(); !changed { + t.Fatal("refresh should transition to READY via baseline fallback after SHM attach failure") + } + if !client.Ready() { + t.Fatal("client should be ready after baseline fallback") + } + if client.state != StateReady { + t.Fatalf("client state = %d, want READY", client.state) + } + if client.shm != nil { + t.Fatal("expected no SHM attachment after fallback") + } + if client.session == nil { + t.Fatal("expected live baseline session after fallback") + } + if client.session.SelectedProfile != protocol.ProfileBaseline { + t.Fatalf("selected profile after fallback = %#x, want baseline", client.session.SelectedProfile) + } + if client.config.SupportedProfiles&protocol.ProfileSHMHybrid != 0 { + t.Fatalf("supported profiles should drop SHM after attach failure, got %#x", client.config.SupportedProfiles) + } + if client.config.PreferredProfiles&protocol.ProfileSHMHybrid != 0 { + t.Fatalf("preferred profiles should drop SHM after attach failure, got %#x", client.config.PreferredProfiles) + } + + client.Close() + + result := <-doneCh + if result.err != nil { + t.Fatalf("attach-failure fallback server failed: %v", result.err) + } + if result.firstSelected != protocol.ProfileSHMHybrid { + t.Fatalf("first selected profile = %#x, want SHM hybrid", result.firstSelected) + } + if result.secondSelected != protocol.ProfileBaseline { + t.Fatalf("second selected profile = %#x, want baseline", result.secondSelected) + } +} + +func TestUnixDoRawCallShmRejectsBadMessageID(t *testing.T) { + client, serverShm := newRawPosixShmClient(t) + + serverDone := make(chan error, 1) + go func() { + reqBuf := make([]byte, protocol.HeaderSize+32) + n, err := serverShm.ShmReceive(reqBuf, 1000) + if err != nil { + serverDone <- err + return + } + + hdr, err := protocol.DecodeHeader(reqBuf[:n]) + if err != nil { + serverDone <- err + return + } + + var respPayload [protocol.IncrementPayloadSize]byte + if protocol.IncrementEncode(42, respPayload[:]) == 0 { + serverDone <- errors.New("IncrementEncode failed") + return + } + + respHdr := protocol.Header{ + Kind: protocol.KindResponse, + Code: protocol.MethodIncrement, + ItemCount: 1, + MessageID: hdr.MessageID + 1, + TransportStatus: protocol.StatusOK, + } + serverDone <- serverShm.ShmSend(encodeRawUnixMessage(respHdr, respPayload[:])) + }() + + var reqPayload [protocol.IncrementPayloadSize]byte + if protocol.IncrementEncode(41, reqPayload[:]) == 0 { + t.Fatal("IncrementEncode failed") + } + + _, _, err := client.doRawCall(protocol.MethodIncrement, reqPayload[:]) + if !errors.Is(err, protocol.ErrBadLayout) { + t.Fatalf("doRawCall bad SHM message_id = %v, want %v", err, protocol.ErrBadLayout) + } + + if err := <-serverDone; err != nil { + t.Fatalf("raw POSIX SHM server failed: %v", err) + } +} + +func TestUnixCallIncrementBatchShmRejectsBadMessageID(t *testing.T) { + client, serverShm := newRawPosixShmClient(t) + + serverDone := make(chan error, 1) + go func() { + reqBuf := make([]byte, protocol.HeaderSize+256) + n, err := serverShm.ShmReceive(reqBuf, 1000) + if err != nil { + serverDone <- err + return + } + + hdr, err := protocol.DecodeHeader(reqBuf[:n]) + if err != nil { + serverDone <- err + return + } + + var itemA [protocol.IncrementPayloadSize]byte + var itemB [protocol.IncrementPayloadSize]byte + if protocol.IncrementEncode(2, itemA[:]) == 0 || protocol.IncrementEncode(3, itemB[:]) == 0 { + serverDone <- errors.New("IncrementEncode failed") + return + } + + respBuf := make([]byte, 64) + bb := protocol.NewBatchBuilder(respBuf, 2) + if err := bb.Add(itemA[:]); err != nil { + serverDone <- err + return + } + if err := bb.Add(itemB[:]); err != nil { + serverDone <- err + return + } + respLen, _ := bb.Finish() + + respHdr := protocol.Header{ + Kind: protocol.KindResponse, + Code: protocol.MethodIncrement, + Flags: protocol.FlagBatch, + ItemCount: 2, + MessageID: hdr.MessageID + 1, + TransportStatus: protocol.StatusOK, + } + serverDone <- serverShm.ShmSend(encodeRawUnixMessage(respHdr, respBuf[:respLen])) + }() + + _, err := client.CallIncrementBatch([]uint64{1, 2}) + if !errors.Is(err, protocol.ErrBadLayout) { + t.Fatalf("CallIncrementBatch bad SHM message_id = %v, want %v", err, protocol.ErrBadLayout) + } + + if err := <-serverDone; err != nil { + t.Fatalf("raw POSIX SHM server failed: %v", err) + } +} + +func TestUnixCallIncrementBatchShmRejectsMalformedPayload(t *testing.T) { + client, serverShm := newRawPosixShmClient(t) + + serverDone := make(chan error, 1) + go func() { + reqBuf := make([]byte, protocol.HeaderSize+256) + n, err := serverShm.ShmReceive(reqBuf, 1000) + if err != nil { + serverDone <- err + return + } + + hdr, err := protocol.DecodeHeader(reqBuf[:n]) + if err != nil { + serverDone <- err + return + } + + respHdr := protocol.Header{ + Kind: protocol.KindResponse, + Code: protocol.MethodIncrement, + Flags: protocol.FlagBatch, + ItemCount: 2, + MessageID: hdr.MessageID, + TransportStatus: protocol.StatusOK, + } + serverDone <- serverShm.ShmSend(encodeRawUnixMessage(respHdr, make([]byte, 8))) + }() + + _, err := client.CallIncrementBatch([]uint64{1, 2}) + if !errors.Is(err, protocol.ErrTruncated) { + t.Fatalf("CallIncrementBatch malformed SHM payload = %v, want %v", err, protocol.ErrTruncated) + } + + if err := <-serverDone; err != nil { + t.Fatalf("raw POSIX SHM server failed: %v", err) + } +} + +func TestUnixShmMalformedBatchRequestRecovers(t *testing.T) { + svc := uniqueUnixService("go_unix_shm_bad_batch") + ts := startTestServerUnixWithConfig( + svc, + testUnixShmServerConfig(), + protocol.MethodIncrement, + pingPongIncrementDispatch(), + ) + defer ts.stop() + + client := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) + defer client.Close() + + refreshUnixClientReady(t, client) + + if client.shm == nil { + t.Fatal("expected SHM attachment") + } + + reqHdr := protocol.Header{ + Kind: protocol.KindRequest, + Code: protocol.MethodIncrement, + Flags: protocol.FlagBatch, + ItemCount: 2, + MessageID: 1, + TransportStatus: protocol.StatusOK, + } + badPayload := encodeUnixIncrementBatchPayload(t, 1, 2)[:8] + if err := client.shm.ShmSend(encodeRawUnixMessage(reqHdr, badPayload)); err != nil { + t.Fatalf("ShmSend malformed batch request failed: %v", err) + } + + // The server answers the malformed batch with an error status and keeps + // the session. + respBuf := make([]byte, protocol.HeaderSize+4096) + n, err := client.shm.ShmReceive(respBuf, 5000) + if err != nil { + t.Fatalf("ShmReceive reply to malformed batch failed: %v", err) + } + respHdr, err := protocol.DecodeHeader(respBuf[:n]) + if err != nil { + t.Fatalf("decode reply to malformed batch: %v", err) + } + if respHdr.MessageID != 1 || respHdr.TransportStatus == protocol.StatusOK { + t.Fatalf("reply to malformed batch = id %d status %d, want id 1 with an error status", + respHdr.MessageID, respHdr.TransportStatus) + } + + got, err := client.CallIncrement(21) + if err != nil { + t.Fatalf("CallIncrement after malformed batch SHM request failed: %v", err) + } + if got != 22 { + t.Fatalf("CallIncrement after malformed batch SHM request = %d, want 22", got) + } + if !client.Ready() { + t.Fatalf("client should stay usable after malformed batch SHM request, got status %+v", client.Status()) + } +} + +func TestUnixShmShortRequestKeepsServerHealthy(t *testing.T) { + svc := uniqueUnixService("go_unix_shm_short_req") + ts := startTestServerUnixWithConfig( + svc, + testUnixShmServerConfig(), + protocol.MethodIncrement, + pingPongIncrementDispatch(), + ) + defer ts.stop() + + client := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) + defer client.Close() + refreshUnixClientReady(t, client) + + if client.shm == nil { + t.Fatal("expected SHM attachment") + } + if err := client.shm.ShmSend([]byte{1, 2, 3, 4}); err != nil { + t.Fatalf("ShmSend short request failed: %v", err) + } + + // The server ends the session on the malformed message. + waitSessionExits(ts.server, 1) + + verify := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) + defer verify.Close() + refreshUnixClientReady(t, verify) + + got, err := verify.CallIncrement(21) + if err != nil { + t.Fatalf("CallIncrement after short SHM request failed: %v", err) + } + if got != 22 { + t.Fatalf("CallIncrement after short SHM request = %d, want 22", got) + } +} + +func TestUnixShmBadHeaderKeepsServerHealthy(t *testing.T) { + svc := uniqueUnixService("go_unix_shm_bad_header") + ts := startTestServerUnixWithConfig( + svc, + testUnixShmServerConfig(), + protocol.MethodIncrement, + pingPongIncrementDispatch(), + ) + defer ts.stop() + + client := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) + defer client.Close() + refreshUnixClientReady(t, client) + + if client.shm == nil { + t.Fatal("expected SHM attachment") + } + badMsg := make([]byte, protocol.HeaderSize) + if err := client.shm.ShmSend(badMsg); err != nil { + t.Fatalf("ShmSend bad header request failed: %v", err) + } + + // The server ends the session on the malformed message. + waitSessionExits(ts.server, 1) + + verify := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) + defer verify.Close() + refreshUnixClientReady(t, verify) + + got, err := verify.CallIncrement(9) + if err != nil { + t.Fatalf("CallIncrement after bad SHM header failed: %v", err) + } + if got != 10 { + t.Fatalf("CallIncrement after bad SHM header = %d, want 10", got) + } +} + +func TestUnixShmBatchHandlerFailureNeedsRefresh(t *testing.T) { + svc := uniqueUnixService("go_unix_shm_batch_fail") + handler := IncrementDispatch(func(v uint64) (uint64, bool) { + if v == 99 { + return 0, false + } + return v + 1, true + }) + ts := startTestServerUnixWithConfig( + svc, + testUnixShmServerConfig(), + protocol.MethodIncrement, + handler, + ) + defer ts.stop() + + client := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) + defer client.Close() + + refreshUnixClientReady(t, client) + + _, err := client.CallIncrementBatch([]uint64{99}) + if !errors.Is(err, protocol.ErrBadLayout) { + t.Fatalf("CallIncrementBatch handler failure = %v, want %v", err, protocol.ErrBadLayout) + } + if client.state != StateBroken { + t.Fatalf("client state after batch handler failure = %d, want BROKEN", client.state) + } + + changed := client.Refresh() + if !changed || !client.Ready() { + t.Fatalf("Refresh after batch handler failure = %v, state=%d, want READY", changed, client.state) + } + + got, err := client.CallIncrement(5) + if err != nil { + t.Fatalf("CallIncrement after Refresh failed: %v", err) + } + if got != 6 { + t.Fatalf("CallIncrement after Refresh = %d, want 6", got) + } +} + +func TestUnixShmBatchResponseOverflowRespectsConfiguredCeiling(t *testing.T) { + svc := uniqueUnixService("go_unix_shm_batch_overflow") + + scfg := testUnixShmServerConfig() + scfg.MaxResponsePayloadBytes = 24 + scfg.MaxResponseBatchItems = 2 + ccfg := testUnixShmClientConfig() + ccfg.MaxResponsePayloadBytes = 24 + ccfg.MaxResponseBatchItems = 2 + ccfg.MaxRequestBatchItems = 2 + + ts := startTestServerUnixWithConfig( + svc, + scfg, + protocol.MethodIncrement, + IncrementDispatch(func(v uint64) (uint64, bool) { + return v + 1, true + }), + ) + defer ts.stop() + + client := NewIncrementClient(testRunDir, svc, ccfg) + defer client.Close() + + refreshUnixClientReady(t, client) + + gotBatch, err := client.CallIncrementBatch([]uint64{1, 2}) + if !errors.Is(err, protocol.ErrOverflow) { + t.Fatalf("CallIncrementBatch with capped SHM response = %v, want ErrOverflow", err) + } + if gotBatch != nil { + t.Fatalf("CallIncrementBatch with capped SHM response returned %v, want nil", gotBatch) + } + if client.state != StateBroken { + t.Fatalf("client state after capped batch overflow = %d, want BROKEN", client.state) + } + if client.reconnectCount < 1 { + t.Fatalf("expected reconnect_count >= 1 after capped SHM batch overflow, got %d", client.reconnectCount) + } +} + +func TestUnixShmRegionOnlyForShmSessions(t *testing.T) { + svc := uniqueUnixService("go_unix_shm_only_selected") + ts := startServerWithConfigSocketReady(svc, testUnixShmServerConfig(), protocol.MethodIncrement, + IncrementDispatch(func(v uint64) (uint64, bool) { return v + 1, true })) + defer ts.stop() + s := ts.server + + baseline := NewIncrementClient(testRunDir, svc, testClientConfig()) + baseline.config.SupportedProfiles = protocol.ProfileBaseline + baseline.config.PreferredProfiles = protocol.ProfileBaseline + defer baseline.Close() + baseline.Refresh() + if !baseline.Ready() { + t.Fatalf("baseline client not ready, state=%d", baseline.state) + } + if n := shmCreatedCount(s); n != 0 { + t.Fatalf("baseline session created %d SHM regions, want 0", n) + } + + badAuth := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) + badAuth.config.AuthToken++ + defer badAuth.Close() + badAuth.Refresh() + if badAuth.Ready() { + t.Fatal("client with a wrong auth token must not become ready") + } + if n := shmCreatedCount(s); n != 0 { + t.Fatalf("rejected handshake created %d SHM regions, want 0", n) + } + + shmClient := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) + defer shmClient.Close() + shmClient.Refresh() + if !shmClient.Ready() || shmClient.session.SelectedProfile != protocol.ProfileSHMHybrid { + t.Fatalf("SHM client not ready over SHM, state=%d session=%+v", shmClient.state, shmClient.session) + } + if n := shmCreatedCount(s); n != 1 { + t.Fatalf("SHM session created %d SHM regions, want 1", n) + } + + s.failShmCreate.Store(true) + fallback := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) + defer fallback.Close() + fallback.Refresh() + if !fallback.Ready() || fallback.session.SelectedProfile != protocol.ProfileBaseline { + t.Fatalf("client after SHM create failure not ready over baseline, state=%d session=%+v", + fallback.state, fallback.session) + } + if n := shmCreatedCount(s); n != 0 { + t.Fatalf("failed SHM create reported %d regions, want 0", n) + } +} + +func TestUnixShmPrepareFailureFallsBackToBaseline(t *testing.T) { + svc := uniqueUnixService("go_unix_shm_create_fail") + obstruction := unixShmSessionPath(svc, 1) + + scfg := testUnixShmServerConfig() + ts := startServerWithConfigSocketReady( + svc, + scfg, + protocol.MethodIncrement, + IncrementDispatch(func(v uint64) (uint64, bool) { + return v + 1, true + }), + ) + defer func() { + ts.stop() + _ = os.RemoveAll(obstruction) + }() + + if err := os.MkdirAll(filepath.Join(obstruction, "keep"), 0700); err != nil { + t.Fatalf("mkdir SHM obstruction failed: %v", err) + } + + badClient := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) + defer badClient.Close() + + if changed := badClient.Refresh(); !changed { + t.Fatal("Refresh should transition to READY when baseline stays viable") + } + if !badClient.Ready() { + t.Fatal("client should stay usable over baseline after server SHM prepare failure") + } + if badClient.state != StateReady { + t.Fatalf("client state after SHM prepare failure = %d, want READY", badClient.state) + } + if badClient.shm != nil { + t.Fatalf("fallback baseline session must not attach SHM: selected=%#x session=%+v", badClient.session.SelectedProfile, badClient.session) + } + if badClient.session == nil || badClient.session.SelectedProfile != protocol.ProfileBaseline { + t.Fatalf("selected profile after SHM prepare failure = %#v, want baseline", badClient.session) + } + + if _, err := os.Stat(obstruction); err != nil { + t.Fatalf("SHM obstruction should still exist after failed create, stat err = %v", err) + } + if err := os.RemoveAll(obstruction); err != nil { + t.Fatalf("remove SHM obstruction failed: %v", err) + } + + goodClient := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) + defer goodClient.Close() + refreshUnixClientReady(t, goodClient) + + if goodClient.shm == nil { + t.Fatal("expected SHM attachment after obstruction removal") + } + got, err := goodClient.CallIncrement(4) + if err != nil { + t.Fatalf("CallIncrement after SHM create recovery failed: %v", err) + } + if got != 5 { + t.Fatalf("CallIncrement after SHM create recovery = %d, want 5", got) + } +} + +func TestUnixClientTransportReceiveShmError(t *testing.T) { + client := NewIncrementClient(testRunDir, uniqueUnixService("go_unix_transport_shm_err"), testClientConfig()) + defer client.Close() + + client.state = StateReady + client.shm = &posix.ShmContext{} + + if _, _, err := client.transportReceive(); !errors.Is(err, protocol.ErrTransport) { + t.Fatalf("transportReceive with invalid SHM context = %v, want %v", err, protocol.ErrTransport) + } +} + +func TestUnixClientTransportReceiveShmRejectsShortMessage(t *testing.T) { + client, serverShm := newRawPosixShmClient(t) + + if err := serverShm.ShmSend([]byte{1, 2, 3, 4}); err != nil { + t.Fatalf("ShmSend failed: %v", err) + } + + if _, _, err := client.transportReceive(); !errors.Is(err, protocol.ErrTruncated) { + t.Fatalf("transportReceive short SHM message = %v, want %v", err, protocol.ErrTruncated) + } +} + +func TestUnixClientTransportReceiveShmRejectsBadHeader(t *testing.T) { + client, serverShm := newRawPosixShmClient(t) + + msg := make([]byte, protocol.HeaderSize) + if err := serverShm.ShmSend(msg); err != nil { + t.Fatalf("ShmSend failed: %v", err) + } + + if _, _, err := client.transportReceive(); !errors.Is(err, protocol.ErrBadMagic) { + t.Fatalf("transportReceive bad SHM header = %v, want %v", err, protocol.ErrBadMagic) + } +} diff --git a/src/go/pkg/netipc/service/raw/shm_unix_test.go b/src/go/pkg/netipc/service/raw/shm_unix_test.go index 45dc276d..4ec573fa 100644 --- a/src/go/pkg/netipc/service/raw/shm_unix_test.go +++ b/src/go/pkg/netipc/service/raw/shm_unix_test.go @@ -3,10 +3,6 @@ package raw import ( - "errors" - "testing" - "time" - "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/posix" ) @@ -35,535 +31,6 @@ func startTestServerUnixWithConfig( cleanupAll(service) s := NewServer(testRunDir, service, cfg, expectedMethodCode, handler) - doneCh := make(chan struct{}) - - go func() { - defer close(doneCh) - _ = s.Run() - }() - - waitUnixServerReady(service) + doneCh := runServerForTest(s) return &testServer{server: s, doneCh: doneCh} } - -func encodeRawUnixMessage(hdr protocol.Header, payload []byte) []byte { - hdr.Magic = protocol.MagicMsg - hdr.Version = protocol.Version - hdr.HeaderLen = protocol.HeaderLen - hdr.PayloadLen = uint32(len(payload)) - - msg := make([]byte, protocol.HeaderSize+len(payload)) - hdr.Encode(msg[:protocol.HeaderSize]) - copy(msg[protocol.HeaderSize:], payload) - return msg -} - -func encodeUnixIncrementBatchPayload(t *testing.T, values ...uint64) []byte { - t.Helper() - - itemCount := uint32(len(values)) - if itemCount == 0 { - return nil - } - - bufSize := protocol.Align8(int(itemCount)*8) + - int(itemCount)*protocol.IncrementPayloadSize + - int(itemCount)*protocol.Alignment - buf := make([]byte, bufSize) - entries := make([]protocol.BatchEntry, len(values)) - - offset := protocol.Align8(int(itemCount) * 8) - for i, v := range values { - itemOff := offset - itemLen := protocol.IncrementPayloadSize - entries[i] = protocol.BatchEntry{Offset: uint32(itemOff), Length: uint32(itemLen)} - if protocol.IncrementEncode(v, buf[itemOff:itemOff+itemLen]) == 0 { - t.Fatalf("IncrementEncode(%d) failed", v) - } - offset += protocol.Align8(itemLen) - } - - if n := protocol.BatchDirEncode(entries, buf[:int(itemCount)*8]); n != int(itemCount)*8 { - t.Fatalf("BatchDirEncode returned %d, want %d", n, int(itemCount)*8) - } - - return buf[:offset] -} - -func TestUnixShmRoundTrip(t *testing.T) { - svc := uniqueUnixService("go_unix_shm_roundtrip") - ts := startTestServerUnixWithConfig( - svc, - testUnixShmServerConfig(), - protocol.MethodIncrement, - pingPongIncrementDispatch(), - ) - defer ts.stop() - - client := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) - defer client.Close() - - refreshUnixClientReady(t, client) - - if client.session == nil { - t.Fatal("expected negotiated session") - } - if client.shm == nil { - t.Fatal("expected SHM attachment") - } - if client.session.SelectedProfile != protocol.ProfileSHMHybrid { - t.Fatalf("selected profile = %d, want %d", client.session.SelectedProfile, protocol.ProfileSHMHybrid) - } - - got, err := client.CallIncrement(41) - if err != nil { - t.Fatalf("CallIncrement failed: %v", err) - } - if got != 42 { - t.Fatalf("CallIncrement = %d, want 42", got) - } -} - -func TestUnixShmAttachFailureFallsBackToBaseline(t *testing.T) { - svc := uniqueUnixService("go_unix_shm_attach_fail") - cfg := testUnixShmServerConfig() - ensureRunDir() - cleanupAll(svc) - - listener, err := posix.Listen(testRunDir, svc, cfg) - if err != nil { - t.Fatalf("posix.Listen failed: %v", err) - } - defer listener.Close() - - type attachFailureResult struct { - firstSelected uint32 - secondSelected uint32 - err error - } - doneCh := make(chan attachFailureResult, 1) - go func() { - first, err := listener.Accept() - if err != nil { - doneCh <- attachFailureResult{err: err} - return - } - defer first.Close() - firstSelected := first.SelectedProfile - - recvBuf := make([]byte, protocol.HeaderSize+4096) - _, _, err = first.Receive(recvBuf) - if err == nil { - doneCh <- attachFailureResult{err: errors.New("expected first receive to fail after SHM attach fallback disconnect")} - return - } - - second, err := listener.Accept() - if err != nil { - doneCh <- attachFailureResult{err: err} - return - } - defer second.Close() - secondSelected := second.SelectedProfile - - _, _, err = second.Receive(recvBuf) - if err == nil { - doneCh <- attachFailureResult{err: errors.New("expected second receive to fail after client close")} - return - } - - doneCh <- attachFailureResult{ - firstSelected: firstSelected, - secondSelected: secondSelected, - } - }() - - client := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) - - if changed := client.Refresh(); !changed { - t.Fatal("refresh should transition to READY via baseline fallback after SHM attach failure") - } - if !client.Ready() { - t.Fatal("client should be ready after baseline fallback") - } - if client.state != StateReady { - t.Fatalf("client state = %d, want READY", client.state) - } - if client.shm != nil { - t.Fatal("expected no SHM attachment after fallback") - } - if client.session == nil { - t.Fatal("expected live baseline session after fallback") - } - if client.session.SelectedProfile != protocol.ProfileBaseline { - t.Fatalf("selected profile after fallback = %#x, want baseline", client.session.SelectedProfile) - } - if client.config.SupportedProfiles&protocol.ProfileSHMHybrid != 0 { - t.Fatalf("supported profiles should drop SHM after attach failure, got %#x", client.config.SupportedProfiles) - } - if client.config.PreferredProfiles&protocol.ProfileSHMHybrid != 0 { - t.Fatalf("preferred profiles should drop SHM after attach failure, got %#x", client.config.PreferredProfiles) - } - - client.Close() - - result := <-doneCh - if result.err != nil { - t.Fatalf("attach-failure fallback server failed: %v", result.err) - } - if result.firstSelected != protocol.ProfileSHMHybrid { - t.Fatalf("first selected profile = %#x, want SHM hybrid", result.firstSelected) - } - if result.secondSelected != protocol.ProfileBaseline { - t.Fatalf("second selected profile = %#x, want baseline", result.secondSelected) - } -} - -func TestUnixDoRawCallShmRejectsBadMessageID(t *testing.T) { - client, serverShm := newRawPosixShmClient(t) - - serverDone := make(chan error, 1) - go func() { - reqBuf := make([]byte, protocol.HeaderSize+32) - n, err := serverShm.ShmReceive(reqBuf, 1000) - if err != nil { - serverDone <- err - return - } - - hdr, err := protocol.DecodeHeader(reqBuf[:n]) - if err != nil { - serverDone <- err - return - } - - var respPayload [protocol.IncrementPayloadSize]byte - if protocol.IncrementEncode(42, respPayload[:]) == 0 { - serverDone <- errors.New("IncrementEncode failed") - return - } - - respHdr := protocol.Header{ - Kind: protocol.KindResponse, - Code: protocol.MethodIncrement, - ItemCount: 1, - MessageID: hdr.MessageID + 1, - TransportStatus: protocol.StatusOK, - } - serverDone <- serverShm.ShmSend(encodeRawUnixMessage(respHdr, respPayload[:])) - }() - - var reqPayload [protocol.IncrementPayloadSize]byte - if protocol.IncrementEncode(41, reqPayload[:]) == 0 { - t.Fatal("IncrementEncode failed") - } - - _, _, err := client.doRawCall(protocol.MethodIncrement, reqPayload[:]) - if !errors.Is(err, protocol.ErrBadLayout) { - t.Fatalf("doRawCall bad SHM message_id = %v, want %v", err, protocol.ErrBadLayout) - } - - if err := <-serverDone; err != nil { - t.Fatalf("raw POSIX SHM server failed: %v", err) - } -} - -func TestUnixCallIncrementBatchShmRejectsBadMessageID(t *testing.T) { - client, serverShm := newRawPosixShmClient(t) - - serverDone := make(chan error, 1) - go func() { - reqBuf := make([]byte, protocol.HeaderSize+256) - n, err := serverShm.ShmReceive(reqBuf, 1000) - if err != nil { - serverDone <- err - return - } - - hdr, err := protocol.DecodeHeader(reqBuf[:n]) - if err != nil { - serverDone <- err - return - } - - var itemA [protocol.IncrementPayloadSize]byte - var itemB [protocol.IncrementPayloadSize]byte - if protocol.IncrementEncode(2, itemA[:]) == 0 || protocol.IncrementEncode(3, itemB[:]) == 0 { - serverDone <- errors.New("IncrementEncode failed") - return - } - - respBuf := make([]byte, 64) - bb := protocol.NewBatchBuilder(respBuf, 2) - if err := bb.Add(itemA[:]); err != nil { - serverDone <- err - return - } - if err := bb.Add(itemB[:]); err != nil { - serverDone <- err - return - } - respLen, _ := bb.Finish() - - respHdr := protocol.Header{ - Kind: protocol.KindResponse, - Code: protocol.MethodIncrement, - Flags: protocol.FlagBatch, - ItemCount: 2, - MessageID: hdr.MessageID + 1, - TransportStatus: protocol.StatusOK, - } - serverDone <- serverShm.ShmSend(encodeRawUnixMessage(respHdr, respBuf[:respLen])) - }() - - _, err := client.CallIncrementBatch([]uint64{1, 2}) - if !errors.Is(err, protocol.ErrBadLayout) { - t.Fatalf("CallIncrementBatch bad SHM message_id = %v, want %v", err, protocol.ErrBadLayout) - } - - if err := <-serverDone; err != nil { - t.Fatalf("raw POSIX SHM server failed: %v", err) - } -} - -func TestUnixCallIncrementBatchShmRejectsMalformedPayload(t *testing.T) { - client, serverShm := newRawPosixShmClient(t) - - serverDone := make(chan error, 1) - go func() { - reqBuf := make([]byte, protocol.HeaderSize+256) - n, err := serverShm.ShmReceive(reqBuf, 1000) - if err != nil { - serverDone <- err - return - } - - hdr, err := protocol.DecodeHeader(reqBuf[:n]) - if err != nil { - serverDone <- err - return - } - - respHdr := protocol.Header{ - Kind: protocol.KindResponse, - Code: protocol.MethodIncrement, - Flags: protocol.FlagBatch, - ItemCount: 2, - MessageID: hdr.MessageID, - TransportStatus: protocol.StatusOK, - } - serverDone <- serverShm.ShmSend(encodeRawUnixMessage(respHdr, make([]byte, 8))) - }() - - _, err := client.CallIncrementBatch([]uint64{1, 2}) - if !errors.Is(err, protocol.ErrTruncated) { - t.Fatalf("CallIncrementBatch malformed SHM payload = %v, want %v", err, protocol.ErrTruncated) - } - - if err := <-serverDone; err != nil { - t.Fatalf("raw POSIX SHM server failed: %v", err) - } -} - -func TestUnixShmMalformedBatchRequestRecovers(t *testing.T) { - svc := uniqueUnixService("go_unix_shm_bad_batch") - ts := startTestServerUnixWithConfig( - svc, - testUnixShmServerConfig(), - protocol.MethodIncrement, - pingPongIncrementDispatch(), - ) - defer ts.stop() - - client := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) - defer client.Close() - - refreshUnixClientReady(t, client) - - if client.shm == nil { - t.Fatal("expected SHM attachment") - } - - reqHdr := protocol.Header{ - Kind: protocol.KindRequest, - Code: protocol.MethodIncrement, - Flags: protocol.FlagBatch, - ItemCount: 2, - MessageID: 1, - TransportStatus: protocol.StatusOK, - } - badPayload := encodeUnixIncrementBatchPayload(t, 1, 2)[:8] - if err := client.shm.ShmSend(encodeRawUnixMessage(reqHdr, badPayload)); err != nil { - t.Fatalf("ShmSend malformed batch request failed: %v", err) - } - - time.Sleep(50 * time.Millisecond) - - got, err := client.CallIncrement(21) - if err != nil { - t.Fatalf("CallIncrement after malformed batch SHM request failed: %v", err) - } - if got != 22 { - t.Fatalf("CallIncrement after malformed batch SHM request = %d, want 22", got) - } - if !client.Ready() { - t.Fatalf("client should stay usable after malformed batch SHM request, got status %+v", client.Status()) - } -} - -func TestUnixShmShortRequestKeepsServerHealthy(t *testing.T) { - svc := uniqueUnixService("go_unix_shm_short_req") - ts := startTestServerUnixWithConfig( - svc, - testUnixShmServerConfig(), - protocol.MethodIncrement, - pingPongIncrementDispatch(), - ) - defer ts.stop() - - client := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) - defer client.Close() - refreshUnixClientReady(t, client) - - if client.shm == nil { - t.Fatal("expected SHM attachment") - } - if err := client.shm.ShmSend([]byte{1, 2, 3, 4}); err != nil { - t.Fatalf("ShmSend short request failed: %v", err) - } - - time.Sleep(50 * time.Millisecond) - - verify := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) - defer verify.Close() - refreshUnixClientReady(t, verify) - - got, err := verify.CallIncrement(21) - if err != nil { - t.Fatalf("CallIncrement after short SHM request failed: %v", err) - } - if got != 22 { - t.Fatalf("CallIncrement after short SHM request = %d, want 22", got) - } -} - -func TestUnixShmBadHeaderKeepsServerHealthy(t *testing.T) { - svc := uniqueUnixService("go_unix_shm_bad_header") - ts := startTestServerUnixWithConfig( - svc, - testUnixShmServerConfig(), - protocol.MethodIncrement, - pingPongIncrementDispatch(), - ) - defer ts.stop() - - client := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) - defer client.Close() - refreshUnixClientReady(t, client) - - if client.shm == nil { - t.Fatal("expected SHM attachment") - } - badMsg := make([]byte, protocol.HeaderSize) - if err := client.shm.ShmSend(badMsg); err != nil { - t.Fatalf("ShmSend bad header request failed: %v", err) - } - - time.Sleep(50 * time.Millisecond) - - verify := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) - defer verify.Close() - refreshUnixClientReady(t, verify) - - got, err := verify.CallIncrement(9) - if err != nil { - t.Fatalf("CallIncrement after bad SHM header failed: %v", err) - } - if got != 10 { - t.Fatalf("CallIncrement after bad SHM header = %d, want 10", got) - } -} - -func TestUnixShmBatchHandlerFailureNeedsRefresh(t *testing.T) { - svc := uniqueUnixService("go_unix_shm_batch_fail") - handler := IncrementDispatch(func(v uint64) (uint64, bool) { - if v == 99 { - return 0, false - } - return v + 1, true - }) - ts := startTestServerUnixWithConfig( - svc, - testUnixShmServerConfig(), - protocol.MethodIncrement, - handler, - ) - defer ts.stop() - - client := NewIncrementClient(testRunDir, svc, testUnixShmClientConfig()) - defer client.Close() - - refreshUnixClientReady(t, client) - - _, err := client.CallIncrementBatch([]uint64{99}) - if !errors.Is(err, protocol.ErrBadLayout) { - t.Fatalf("CallIncrementBatch handler failure = %v, want %v", err, protocol.ErrBadLayout) - } - if client.state != StateBroken { - t.Fatalf("client state after batch handler failure = %d, want BROKEN", client.state) - } - - changed := client.Refresh() - if !changed || !client.Ready() { - t.Fatalf("Refresh after batch handler failure = %v, state=%d, want READY", changed, client.state) - } - - got, err := client.CallIncrement(5) - if err != nil { - t.Fatalf("CallIncrement after Refresh failed: %v", err) - } - if got != 6 { - t.Fatalf("CallIncrement after Refresh = %d, want 6", got) - } -} - -func TestUnixShmBatchResponseOverflowRespectsConfiguredCeiling(t *testing.T) { - svc := uniqueUnixService("go_unix_shm_batch_overflow") - - scfg := testUnixShmServerConfig() - scfg.MaxResponsePayloadBytes = 24 - scfg.MaxResponseBatchItems = 2 - ccfg := testUnixShmClientConfig() - ccfg.MaxResponsePayloadBytes = 24 - ccfg.MaxResponseBatchItems = 2 - ccfg.MaxRequestBatchItems = 2 - - ts := startTestServerUnixWithConfig( - svc, - scfg, - protocol.MethodIncrement, - IncrementDispatch(func(v uint64) (uint64, bool) { - return v + 1, true - }), - ) - defer ts.stop() - - client := NewIncrementClient(testRunDir, svc, ccfg) - defer client.Close() - - refreshUnixClientReady(t, client) - - gotBatch, err := client.CallIncrementBatch([]uint64{1, 2}) - if !errors.Is(err, protocol.ErrOverflow) { - t.Fatalf("CallIncrementBatch with capped SHM response = %v, want ErrOverflow", err) - } - if gotBatch != nil { - t.Fatalf("CallIncrementBatch with capped SHM response returned %v, want nil", gotBatch) - } - if client.state != StateBroken { - t.Fatalf("client state after capped batch overflow = %d, want BROKEN", client.state) - } - if client.reconnectCount < 1 { - t.Fatalf("expected reconnect_count >= 1 after capped SHM batch overflow, got %d", client.reconnectCount) - } -} diff --git a/src/go/pkg/netipc/service/raw/shm_windows_test.go b/src/go/pkg/netipc/service/raw/shm_windows_test.go index cc83476c..c6ca87c3 100644 --- a/src/go/pkg/netipc/service/raw/shm_windows_test.go +++ b/src/go/pkg/netipc/service/raw/shm_windows_test.go @@ -89,7 +89,7 @@ func TestWinShmRoundTrip(t *testing.T) { if err != nil { t.Fatalf("CallStringReverse failed: %v", err) } - if reversed.Str != "olleh" { + if string(reversed.Str) != "olleh" { t.Fatalf("string reverse result = %q, want %q", reversed.Str, "olleh") } }) @@ -105,7 +105,8 @@ func TestWinShmIdleTimeoutKeepsSessionAlive(t *testing.T) { waitWinClientReady(t, client) - time.Sleep(350 * time.Millisecond) + // Stay idle across several server receive timeouts. + time.Sleep(5 * time.Duration(testServerTimings.pollMs) * time.Millisecond) got, err := client.CallIncrement(9) if err != nil { @@ -175,7 +176,9 @@ func TestWinShmAttachFailureFallsBackToBaseline(t *testing.T) { } }() - client := NewIncrementClient(winTestRunDir, svc, testWinShmClientConfig()) + // The listener negotiates SHM but never creates the objects, so the + // attach window runs out; the test window keeps that in milliseconds. + client := NewIncrementClient(winTestRunDir, svc, testWinShmClientConfig()).useTestTimings() if changed := client.Refresh(); !changed { t.Fatal("refresh should transition to READY via baseline fallback after WinSHM attach failure") @@ -233,7 +236,8 @@ func TestWinShmMalformedShortRequestRecovers(t *testing.T) { t.Fatalf("WinShmSend malformed short request failed: %v", err) } - time.Sleep(50 * time.Millisecond) + // The server ends the session on the malformed message. + waitSessionExits(ts.server, 1) got, err := client.CallIncrement(9) if err != nil { @@ -265,7 +269,8 @@ func TestWinShmMalformedHeaderRequestRecovers(t *testing.T) { t.Fatalf("WinShmSend malformed header request failed: %v", err) } - time.Sleep(50 * time.Millisecond) + // The server ends the session on the malformed message. + waitSessionExits(ts.server, 1) got, err := client.CallIncrement(11) if err != nil { @@ -309,7 +314,8 @@ func TestWinShmUnexpectedMessageKindRecovers(t *testing.T) { t.Fatalf("WinShmSend unexpected-kind request failed: %v", err) } - time.Sleep(50 * time.Millisecond) + // The server ends the session on the malformed message. + waitSessionExits(ts.server, 1) got, err := client.CallIncrement(13) if err != nil { @@ -352,7 +358,21 @@ func TestWinShmMalformedBatchRequestRecovers(t *testing.T) { t.Fatalf("WinShmSend malformed batch request failed: %v", err) } - time.Sleep(50 * time.Millisecond) + // The server answers the malformed batch with an error status and keeps + // the session. + respBuf := make([]byte, protocol.HeaderSize+4096) + n, err := client.shm.WinShmReceive(respBuf, 5000) + if err != nil { + t.Fatalf("WinShmReceive reply to malformed batch failed: %v", err) + } + respHdr, err := protocol.DecodeHeader(respBuf[:n]) + if err != nil { + t.Fatalf("decode reply to malformed batch: %v", err) + } + if respHdr.MessageID != 1 || respHdr.TransportStatus == protocol.StatusOK { + t.Fatalf("reply to malformed batch = id %d status %d, want id 1 with an error status", + respHdr.MessageID, respHdr.TransportStatus) + } got, err := client.CallIncrement(21) if err != nil { @@ -436,3 +456,55 @@ func TestWinShmBatchResponseOverflowRespectsConfiguredCeiling(t *testing.T) { t.Fatalf("expected reconnect_count >= 1 after capped WinSHM batch overflow, got %d", client.reconnectCount) } } + +func TestWinShmRegionOnlyForShmSessions(t *testing.T) { + svc := uniqueWinService("go_win_shm_only_selected") + ts := startTestIncrementServerWinWithConfig(svc, testWinShmServerConfig()) + defer ts.stop() + s := ts.server + + baseline := NewIncrementClient(winTestRunDir, svc, testWinClientConfig()) + baseline.config.SupportedProfiles = protocol.ProfileBaseline + baseline.config.PreferredProfiles = protocol.ProfileBaseline + defer baseline.Close() + baseline.Refresh() + if !baseline.Ready() { + t.Fatalf("baseline client not ready, state=%d", baseline.state) + } + if n := shmCreatedCount(s); n != 0 { + t.Fatalf("baseline session created %d SHM regions, want 0", n) + } + + badAuth := NewIncrementClient(winTestRunDir, svc, testWinShmClientConfig()) + badAuth.config.AuthToken++ + defer badAuth.Close() + badAuth.Refresh() + if badAuth.Ready() { + t.Fatal("client with a wrong auth token must not become ready") + } + if n := shmCreatedCount(s); n != 0 { + t.Fatalf("rejected handshake created %d SHM regions, want 0", n) + } + + shmClient := NewIncrementClient(winTestRunDir, svc, testWinShmClientConfig()) + defer shmClient.Close() + shmClient.Refresh() + if !shmClient.Ready() || shmClient.session.SelectedProfile != protocol.ProfileSHMHybrid { + t.Fatalf("SHM client not ready over SHM, state=%d session=%+v", shmClient.state, shmClient.session) + } + if n := shmCreatedCount(s); n != 1 { + t.Fatalf("SHM session created %d SHM regions, want 1", n) + } + + s.failShmCreate.Store(true) + fallback := NewIncrementClient(winTestRunDir, svc, testWinShmClientConfig()) + defer fallback.Close() + fallback.Refresh() + if !fallback.Ready() || fallback.session.SelectedProfile != protocol.ProfileBaseline { + t.Fatalf("client after SHM create failure not ready over baseline, state=%d session=%+v", + fallback.state, fallback.session) + } + if n := shmCreatedCount(s); n != 0 { + t.Fatalf("failed SHM create reported %d regions, want 0", n) + } +} diff --git a/src/go/pkg/netipc/service/raw/socktype_other_test.go b/src/go/pkg/netipc/service/raw/socktype_other_test.go new file mode 100644 index 00000000..e3d8fffd --- /dev/null +++ b/src/go/pkg/netipc/service/raw/socktype_other_test.go @@ -0,0 +1,7 @@ +//go:build unix && !darwin && !freebsd + +package raw + +import "syscall" + +const testSockType = syscall.SOCK_SEQPACKET diff --git a/src/go/pkg/netipc/service/raw/socktype_stream_test.go b/src/go/pkg/netipc/service/raw/socktype_stream_test.go new file mode 100644 index 00000000..e29a9653 --- /dev/null +++ b/src/go/pkg/netipc/service/raw/socktype_stream_test.go @@ -0,0 +1,8 @@ +//go:build darwin || freebsd + +package raw + +import "syscall" + +// macOS and FreeBSD use the stream UDS baseline. +const testSockType = syscall.SOCK_STREAM diff --git a/src/go/pkg/netipc/service/raw/stress_test.go b/src/go/pkg/netipc/service/raw/stress_test.go index 0cfcba52..4e790b1f 100644 --- a/src/go/pkg/netipc/service/raw/stress_test.go +++ b/src/go/pkg/netipc/service/raw/stress_test.go @@ -1,4 +1,6 @@ -//go:build unix +//go:build unix && netipc_extended + +// Stress tests run only from tests/run-extended-stress.sh. package raw @@ -13,40 +15,6 @@ import ( "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/posix" ) -// simpleHash: djb2 matching the C implementation -func simpleHash(s string) uint32 { - var hash uint32 = 5381 - for _, c := range []byte(s) { - hash = ((hash << 5) + hash) + uint32(c) - } - return hash -} - -// largeHandler builds a snapshot with N items (realistic container names). -func largeHandler(n int) DispatchHandler { - return SnapshotDispatch(func(request *protocol.CgroupsRequest, builder *protocol.CgroupsBuilder) bool { - if request.LayoutVersion != 1 || request.Flags != 0 { - return false - } - builder.SetHeader(1, 42) - - for i := range n { - name := fmt.Sprintf("container-%04d", i) - path := fmt.Sprintf("/sys/fs/cgroup/docker/%04d", i) - hash := simpleHash(name) - enabled := uint32(1) - if i%5 == 0 { - enabled = 0 - } - if err := builder.Add(hash, 0x10, enabled, - []byte(name), []byte(path)); err != nil { - return false - } - } - return true - }, uint32(n)) -} - // verifySnapshot checks all items in a snapshot view match expected data. func verifySnapshot(t *testing.T, view *protocol.CgroupsResponseView, n int) bool { t.Helper() @@ -115,38 +83,7 @@ func startLargeServer(service string, handler DispatchHandler, maxResp uint32) * cfg.PacketSize = 65536 // force smaller packets for reliable chunking s := NewServer(testRunDir, service, cfg, protocol.MethodCgroupsSnapshot, handler) - doneCh := make(chan struct{}) - - go func() { - defer close(doneCh) - s.Run() - }() - - waitUnixServerReady(service) - return &testServer{server: s, doneCh: doneCh} -} - -// startServerWithWorkers creates a server with explicit worker count. -func startServerWithWorkers(service string, handler DispatchHandler, workers int) *testServer { - ensureRunDir() - cleanupAll(service) - - s := NewServerWithWorkers( - testRunDir, - service, - testServerConfig(), - protocol.MethodCgroupsSnapshot, - handler, - workers, - ) - doneCh := make(chan struct{}) - - go func() { - defer close(doneCh) - s.Run() - }() - - waitUnixServerReady(service) + doneCh := runServerForTest(s) return &testServer{server: s, doneCh: doneCh} } @@ -555,12 +492,7 @@ func TestStressMixedTransport(t *testing.T) { protocol.MethodCgroupsSnapshot, testSnapshotDispatch(), ) - doneCh := make(chan struct{}) - go func() { - defer close(doneCh) - s.Run() - }() - waitUnixServerReady(svc) + doneCh := runServerForTest(s) defer func() { s.Stop() diff --git a/src/go/pkg/netipc/service/raw/string_reverse.go b/src/go/pkg/netipc/service/raw/string_reverse.go index 22c82c88..bcd72fa7 100644 --- a/src/go/pkg/netipc/service/raw/string_reverse.go +++ b/src/go/pkg/netipc/service/raw/string_reverse.go @@ -2,8 +2,10 @@ package raw import "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" -// StringReverseHandler serves a single STRING_REVERSE service kind. -type StringReverseHandler func(string) (string, bool) +// StringReverseHandler serves a single STRING_REVERSE service kind. The +// request borrows the receive buffer and is valid only during the call; +// copy it to keep it. +type StringReverseHandler func([]byte) (string, bool) // CallStringReverse performs a blocking STRING_REVERSE call. // The returned view is valid until the next typed call on this client. @@ -16,9 +18,7 @@ func (c *Client) CallStringReverseWithTimeout(requestStr string, timeoutMs uint3 return nil, err } - var result *protocol.StringReverseView - - err := c.callWithRetry(func() error { + err := c.callWithRetry(timeoutMs, func() error { reqBuf := ensureClientScratch(&c.requestBuf, protocol.StringReverseHdrSize+len(requestStr)+1) if protocol.StringReverseEncode(requestStr, reqBuf) == 0 { return protocol.ErrTruncated @@ -33,13 +33,14 @@ func (c *Client) CallStringReverseWithTimeout(requestStr string, timeoutMs uint3 if derr != nil { return derr } - result = &view + c.stringReverseView = view return nil }) if err != nil { return nil, err } - return result, nil + // The view lives in the client, so returning it does not allocate. + return &c.stringReverseView, nil } // StringReverseDispatch adapts a typed string-reverse handler to the raw dispatch shape. diff --git a/src/go/pkg/netipc/service/raw/testlock_unix_test.go b/src/go/pkg/netipc/service/raw/testlock_unix_test.go new file mode 100644 index 00000000..d6eaec4e --- /dev/null +++ b/src/go/pkg/netipc/service/raw/testlock_unix_test.go @@ -0,0 +1,35 @@ +//go:build unix + +package raw + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +// Endpoint lock files stay after a server closes, by design +// (docs/level1-posix-uds.md, "Endpoint lock"). Test service names are +// unique per run, so without this every run would leave new locks behind: +// the package removes the locks it named once all its tests have finished. +var testLocks struct { + sync.Mutex + paths []string +} + +func trackTestLock(dir, service string) { + testLocks.Lock() + testLocks.paths = append(testLocks.paths, filepath.Join(dir, service+".lock")) + testLocks.Unlock() +} + +func TestMain(m *testing.M) { + code := m.Run() + testLocks.Lock() + for _, p := range testLocks.paths { + _ = os.Remove(p) + } + testLocks.Unlock() + os.Exit(code) +} diff --git a/src/go/pkg/netipc/transport/internal/framing/handshake.go b/src/go/pkg/netipc/transport/internal/framing/handshake.go index 40bca3de..b3737f9c 100644 --- a/src/go/pkg/netipc/transport/internal/framing/handshake.go +++ b/src/go/pkg/netipc/transport/internal/framing/handshake.go @@ -21,6 +21,9 @@ type HelloConfig struct { MaxResponsePayloadBytes uint32 MaxResponseBatchItems uint32 AuthToken uint64 + // PlatformProfiles limits the offered profiles to those the platform + // can provide; 0 means no limit. + PlatformProfiles uint32 } type ServerHelloConfig struct { @@ -30,6 +33,9 @@ type ServerHelloConfig struct { SupportedProfiles uint32 PreferredProfiles uint32 AuthToken uint64 + // PlatformProfiles limits the accepted profiles to those the platform + // can provide; 0 means no limit. + PlatformProfiles uint32 } type ClientHandshakeConfig struct { @@ -49,6 +55,11 @@ type ServerHandshakeConfig struct { ServerHelloConfig SessionID uint64 + // Prepare, when set, runs once a profile is selected and the payload + // limits are agreed, before HELLO_ACK is sent. Returning false drops + // that profile and the handshake selects again. + Prepare func(profile, maxRequestPayloadBytes, maxResponsePayloadBytes uint32) bool + Recv func([]byte) (int, error) SendAck func(uint16, protocol.HelloAck) error StatusError func(uint16) error @@ -79,16 +90,26 @@ func HelloAckLayoutIncompatible(buf []byte) bool { return len(buf) >= 2 && binary.NativeEndian.Uint16(buf[0:2]) != 1 } -func BuildHelloPacket(config HelloConfig) [protocol.HeaderSize + HelloPayloadSize]byte { - if config.SupportedProfiles == 0 { - config.SupportedProfiles = protocol.ProfileBaseline +// profiles applies the baseline default and then the platform limit. +func profiles(supported, preferred, platform uint32) (uint32, uint32) { + if supported == 0 { + supported = protocol.ProfileBaseline } + if platform != 0 { + supported &= platform + preferred &= platform + } + return supported, preferred +} + +func BuildHelloPacket(config HelloConfig) [protocol.HeaderSize + HelloPayloadSize]byte { + supported, preferred := profiles(config.SupportedProfiles, config.PreferredProfiles, config.PlatformProfiles) hello := protocol.Hello{ LayoutVersion: 1, Flags: 0, - SupportedProfiles: config.SupportedProfiles, - PreferredProfiles: config.PreferredProfiles, + SupportedProfiles: supported, + PreferredProfiles: preferred, MaxRequestPayloadBytes: config.MaxRequestPayloadBytes, MaxRequestBatchItems: config.MaxRequestBatchItems, MaxResponsePayloadBytes: config.MaxResponsePayloadBytes, @@ -176,14 +197,30 @@ func ClientHandshake(config ClientHandshakeConfig) (protocol.HelloAck, error) { } return protocol.HelloAck{}, config.ErrProtocol("ack payload: " + err.Error()) } + // HELLO_ACK is exactly one 48-byte payload: extra bytes in the packet, or + // a declared length a stream peer may still be sending, would otherwise be + // read as the session's first message. + if ackHdr.PayloadLen != HelloAckPayloadSize || n != protocol.HeaderSize+HelloAckPayloadSize { + return protocol.HelloAck{}, config.ErrProtocol("ack payload length mismatch") + } + + offered, _ := profiles(config.Hello.SupportedProfiles, 0, config.Hello.PlatformProfiles) + selected := ack.SelectedProfile + if selected == 0 || selected&(selected-1) != 0 || selected&offered == 0 { + return protocol.HelloAck{}, config.ErrProtocol("selected profile was not offered") + } + // A server may only lower our proposal: this side sizes its buffers, and + // on SEQPACKET its datagrams, from it. A packet must hold a payload byte. + if ack.AgreedPacketSize <= protocol.HeaderSize || ack.AgreedPacketSize > config.Hello.PacketSize { + return protocol.HelloAck{}, config.ErrProtocol("agreed packet_size out of range") + } return ack, nil } func NegotiateHello(hello protocol.Hello, config ServerHelloConfig) (protocol.HelloAck, uint16, bool) { - if config.SupportedProfiles == 0 { - config.SupportedProfiles = protocol.ProfileBaseline - } + config.SupportedProfiles, config.PreferredProfiles = profiles( + config.SupportedProfiles, config.PreferredProfiles, config.PlatformProfiles) intersection := hello.SupportedProfiles & config.SupportedProfiles if intersection == 0 { @@ -229,7 +266,7 @@ func ServerHandshake(config ServerHandshakeConfig) (protocol.HelloAck, error) { return protocol.HelloAck{}, config.ErrRecv("hello recv: " + err.Error()) } - _, err = DecodeControlHeader(buf[:n], protocol.CodeHello) + hdr, err := DecodeControlHeader(buf[:n], protocol.CodeHello) if err != nil { if errors.Is(err, protocol.ErrBadVersion) && HeaderVersionIncompatible(buf[:n], protocol.CodeHello) { @@ -251,6 +288,10 @@ func ServerHandshake(config ServerHandshakeConfig) (protocol.HelloAck, error) { } return protocol.HelloAck{}, config.ErrProtocol("hello payload: " + err.Error()) } + // HELLO is exactly one 44-byte payload, as HELLO_ACK is 48. + if hdr.PayloadLen != HelloPayloadSize || n != protocol.HeaderSize+HelloPayloadSize { + return protocol.HelloAck{}, config.ErrProtocol("hello payload length mismatch") + } ack, status, ok := NegotiateHello(hello, config.ServerHelloConfig) if !ok { @@ -259,6 +300,23 @@ func ServerHandshake(config ServerHandshakeConfig) (protocol.HelloAck, error) { } ack.SessionID = config.SessionID + // Set up the selected profile before the ACK promises it. A profile the + // caller cannot prepare is dropped, as if the server never offered it. + for config.Prepare != nil && !config.Prepare(ack.SelectedProfile, + ack.AgreedMaxRequestPayloadBytes, ack.AgreedMaxResponsePayloadBytes) { + ack.ServerSupportedProfiles &^= ack.SelectedProfile + ack.IntersectionProfiles &^= ack.SelectedProfile + if ack.IntersectionProfiles == 0 { + sendRejection(config.SendAck, protocol.StatusUnsupported) + return protocol.HelloAck{}, config.StatusError(protocol.StatusUnsupported) + } + preferred := ack.IntersectionProfiles & hello.PreferredProfiles & config.PreferredProfiles + ack.SelectedProfile = highestBit(ack.IntersectionProfiles) + if preferred != 0 { + ack.SelectedProfile = highestBit(preferred) + } + } + if err := config.SendAck(protocol.StatusOK, ack); err != nil { return protocol.HelloAck{}, config.ErrSend("hello_ack send: " + err.Error()) } diff --git a/src/go/pkg/netipc/transport/internal/framing/handshake_test.go b/src/go/pkg/netipc/transport/internal/framing/handshake_test.go new file mode 100644 index 00000000..b20e1899 --- /dev/null +++ b/src/go/pkg/netipc/transport/internal/framing/handshake_test.go @@ -0,0 +1,134 @@ +package framing + +import ( + "encoding/binary" + "errors" + "testing" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" +) + +var errTestProtocol = errors.New("protocol") + +func testErr(err error) func(string) error { + return func(string) error { return err } +} + +// padded returns pkt with 16 more bytes, the header declaring them too when +// declare is set. +func padded(pkt []byte, declare bool) []byte { + out := append(append([]byte(nil), pkt...), make([]byte, 16)...) + if declare { + binary.NativeEndian.PutUint32(out[16:20], uint32(len(pkt)-protocol.HeaderSize+16)) + } + return out +} + +// HELLO and HELLO_ACK are exactly 44 and 48 payload bytes: a packet with +// more, declared or not, is a protocol error. +func TestHandshakePayloadLengthExact(t *testing.T) { + hello := HelloConfig{ + PacketSize: 4096, + SupportedProfiles: protocol.ProfileBaseline, + MaxRequestPayloadBytes: 1024, + MaxRequestBatchItems: 1, + MaxResponsePayloadBytes: 1024, + MaxResponseBatchItems: 1, + } + ackPkt := BuildHelloAckPacket(protocol.StatusOK, protocol.HelloAck{ + LayoutVersion: 1, + SelectedProfile: protocol.ProfileBaseline, + AgreedPacketSize: 4096, + }) + client := func(reply []byte) error { + _, err := ClientHandshake(ClientHandshakeConfig{ + Hello: hello, + Send: func([]byte) error { return nil }, + Recv: func(dst []byte) (int, error) { return copy(dst, reply), nil }, + StatusError: func(uint16) error { return nil }, + ErrSend: testErr(errors.New("send")), + ErrRecv: testErr(errors.New("recv")), + ErrProtocol: testErr(errTestProtocol), + ErrIncompatible: testErr(errors.New("incompatible")), + }) + return err + } + if err := client(ackPkt[:]); err != nil { + t.Fatalf("exact HELLO_ACK: %v", err) + } + for _, declare := range []bool{false, true} { + if err := client(padded(ackPkt[:], declare)); !errors.Is(err, errTestProtocol) { + t.Fatalf("padded HELLO_ACK (declared %v): got %v", declare, err) + } + } + + helloPkt := BuildHelloPacket(hello) + server := func(pkt []byte) error { + _, err := ServerHandshake(ServerHandshakeConfig{ + ServerHelloConfig: ServerHelloConfig{ + PacketSize: 4096, + MaxRequestPayloadBytes: 1024, + MaxResponsePayloadBytes: 1024, + SupportedProfiles: protocol.ProfileBaseline, + }, + SessionID: 1, + Recv: func(dst []byte) (int, error) { return copy(dst, pkt), nil }, + SendAck: func(uint16, protocol.HelloAck) error { return nil }, + StatusError: func(uint16) error { return errors.New("status") }, + ErrRecv: testErr(errors.New("recv")), + ErrSend: testErr(errors.New("send")), + ErrProtocol: testErr(errTestProtocol), + ErrIncompatible: testErr(errors.New("incompatible")), + }) + return err + } + if err := server(helloPkt[:]); err != nil { + t.Fatalf("exact HELLO: %v", err) + } + for _, declare := range []bool{false, true} { + if err := server(padded(helloPkt[:], declare)); !errors.Is(err, errTestProtocol) { + t.Fatalf("padded HELLO (declared %v): got %v", declare, err) + } + } +} + +// A server may only lower the client's packet size proposal, and the agreed +// size must leave room for a payload after the header. +func TestClientRejectsAgreedPacketSizeOutOfRange(t *testing.T) { + hello := HelloConfig{ + PacketSize: 4096, + SupportedProfiles: protocol.ProfileBaseline, + MaxRequestPayloadBytes: 1024, + MaxRequestBatchItems: 1, + MaxResponsePayloadBytes: 1024, + MaxResponseBatchItems: 1, + } + client := func(agreed uint32) error { + reply := BuildHelloAckPacket(protocol.StatusOK, protocol.HelloAck{ + LayoutVersion: 1, + SelectedProfile: protocol.ProfileBaseline, + AgreedPacketSize: agreed, + }) + _, err := ClientHandshake(ClientHandshakeConfig{ + Hello: hello, + Send: func([]byte) error { return nil }, + Recv: func(dst []byte) (int, error) { return copy(dst, reply[:]), nil }, + StatusError: func(uint16) error { return nil }, + ErrSend: testErr(errors.New("send")), + ErrRecv: testErr(errors.New("recv")), + ErrProtocol: testErr(errTestProtocol), + ErrIncompatible: testErr(errors.New("incompatible")), + }) + return err + } + for _, agreed := range []uint32{protocol.HeaderSize + 1, 4096} { + if err := client(agreed); err != nil { + t.Fatalf("agreed %d: %v", agreed, err) + } + } + for _, agreed := range []uint32{0, protocol.HeaderSize, 4097} { + if err := client(agreed); !errors.Is(err, errTestProtocol) { + t.Fatalf("agreed %d: got %v, want protocol error", agreed, err) + } + } +} diff --git a/src/go/pkg/netipc/transport/internal/framing/receive.go b/src/go/pkg/netipc/transport/internal/framing/receive.go index feb5cdb5..9e77fb13 100644 --- a/src/go/pkg/netipc/transport/internal/framing/receive.go +++ b/src/go/pkg/netipc/transport/internal/framing/receive.go @@ -22,8 +22,12 @@ type Receiver struct { Recv func([]byte) (int, error) EnsurePacketScratch func(*[]byte, int) []byte - OnRecvError func(error) - PropagateRecvError func(error) bool + // A receive error that IsRecvDisconnect reports fails every in-flight + // request through FailAllInflight. Two fields, not one closure, so a + // receive builds no closure. + IsRecvDisconnect func(error) bool + FailAllInflight func() + PropagateRecvError func(error) bool ErrLimitExceeded func(string) error ErrProtocol func(string) error @@ -77,16 +81,13 @@ func SessionReceive(config SessionReceiveConfig, buf []byte) (protocol.Header, [ Recv: config.Recv, EnsurePacketScratch: config.EnsurePacketScratch, PropagateRecvError: config.PropagateRecvError, - OnRecvError: func(err error) { - if config.IsRecvDisconnect(err) { - config.FailAllInflight() - } - }, - ErrLimitExceeded: config.ErrLimitExceeded, - ErrProtocol: config.ErrProtocol, - ErrChunk: config.ErrChunk, - ErrUnknownMsgID: config.ErrUnknownMsgID, - ErrRecv: config.ErrRecv, + IsRecvDisconnect: config.IsRecvDisconnect, + FailAllInflight: config.FailAllInflight, + ErrLimitExceeded: config.ErrLimitExceeded, + ErrProtocol: config.ErrProtocol, + ErrChunk: config.ErrChunk, + ErrUnknownMsgID: config.ErrUnknownMsgID, + ErrRecv: config.ErrRecv, }.Receive(buf) } @@ -145,8 +146,8 @@ func (r Receiver) totalMessageLen(hdr protocol.Header) (int, error) { } func (r Receiver) noteRecvError(err error) { - if r.OnRecvError != nil { - r.OnRecvError(err) + if r.IsRecvDisconnect != nil && r.FailAllInflight != nil && r.IsRecvDisconnect(err) { + r.FailAllInflight() } } diff --git a/src/go/pkg/netipc/transport/posix/shm.go b/src/go/pkg/netipc/transport/posix/shm.go new file mode 100644 index 00000000..c115567f --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/shm.go @@ -0,0 +1,46 @@ +//go:build unix + +// SHM transport types shared by every POSIX system. The data plane needs a +// futex-style wait (shm_posix.go: Linux, and FreeBSD amd64 and 386); other +// systems get stubs (shm_other.go). + +package posix + +import "errors" + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +var ( + ErrShmPathTooLong = errors.New("SHM path exceeds limit") + ErrShmOpen = errors.New("SHM open failed") + ErrShmAllocate = errors.New("SHM allocation failed") + // Deprecated: SHM creation no longer uses ftruncate; use ErrShmAllocate. + ErrShmTruncate = errors.New("SHM ftruncate failed") + ErrShmMmap = errors.New("SHM mmap failed") + ErrShmBadMagic = errors.New("SHM header magic mismatch") + ErrShmBadVersion = errors.New("SHM header version mismatch") + ErrShmBadHeader = errors.New("SHM header_len mismatch") + ErrShmBadSize = errors.New("SHM file too small for declared areas") + ErrShmAddrInUse = errors.New("SHM region owned by live server") + ErrShmNotReady = errors.New("SHM server not ready") + ErrShmMsgTooLarge = errors.New("message exceeds SHM area capacity") + ErrShmTimeout = errors.New("SHM futex wait timed out") + ErrShmBadParam = errors.New("invalid SHM argument") + ErrShmPeerDead = errors.New("SHM owner process has exited") + // ErrShmUnsupported is returned on POSIX systems without SHM support. + ErrShmUnsupported = errors.New("SHM transport not supported on this platform") +) + +// --------------------------------------------------------------------------- +// Role +// --------------------------------------------------------------------------- + +// ShmRole distinguishes server vs client SHM contexts. +type ShmRole int + +const ( + ShmRoleServer ShmRole = 1 + ShmRoleClient ShmRole = 2 +) diff --git a/src/go/pkg/netipc/transport/posix/shm_edge_test.go b/src/go/pkg/netipc/transport/posix/shm_edge_test.go index 2b33a34d..8cef69bf 100644 --- a/src/go/pkg/netipc/transport/posix/shm_edge_test.go +++ b/src/go/pkg/netipc/transport/posix/shm_edge_test.go @@ -1,10 +1,11 @@ -//go:build linux +//go:build linux || (freebsd && (amd64 || 386)) package posix import ( "fmt" "os" + "syscall" "testing" ) @@ -59,24 +60,32 @@ func TestShmContextClientRole(t *testing.T) { } // --------------------------------------------------------------------------- -// OwnerAlive +// Close-on-exec // --------------------------------------------------------------------------- -func TestShmOwnerAlive(t *testing.T) { +// SHM fds must not leak into children a consumer forks and execs. +func TestShmFdsAreCloexec(t *testing.T) { ensureShmRunDir(t) - svc := "go_shm_alive" + svc := "go_shm_cloexec" cleanupShmFiles(t, svc) defer cleanupShmFiles(t, svc) - ctx, err := ShmServerCreate(testShmRunDir, svc, 1, 1024, 1024) + server, err := ShmServerCreate(testShmRunDir, svc, 1, 1024, 1024) if err != nil { t.Fatalf("server create: %v", err) } - defer ctx.ShmDestroy() + defer server.ShmDestroy() + client, err := ShmClientAttach(testShmRunDir, svc, 1) + if err != nil { + t.Fatalf("client attach: %v", err) + } + defer client.ShmClose() - // Owner is current process, should be alive - if !ctx.OwnerAlive() { - t.Error("expected OwnerAlive() = true for current process") + for name, fd := range map[string]int{"server": server.Fd(), "client": client.Fd()} { + flags, _, errno := syscall.Syscall(syscall.SYS_FCNTL, uintptr(fd), syscall.F_GETFD, 0) + if errno != 0 || flags&syscall.FD_CLOEXEC == 0 { + t.Errorf("%s SHM fd %d must be close-on-exec (flags=%#x errno=%v)", name, fd, flags, errno) + } } } diff --git a/src/go/pkg/netipc/transport/posix/shm_fallocate_freebsd_386.go b/src/go/pkg/netipc/transport/posix/shm_fallocate_freebsd_386.go new file mode 100644 index 00000000..1369bb5a --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/shm_fallocate_freebsd_386.go @@ -0,0 +1,17 @@ +//go:build freebsd && 386 + +package posix + +import "syscall" + +// posixFallocate returns the error number: the syscall reports it in the +// return value, not in errno. Each 64-bit off_t takes two stack words. +func posixFallocate(fd int, off, length int64) syscall.Errno { + r1, _, errno := syscall.Syscall6(syscall.SYS_POSIX_FALLOCATE, + uintptr(fd), uintptr(uint32(off)), uintptr(uint32(off>>32)), + uintptr(uint32(length)), uintptr(uint32(length>>32)), 0) + if errno != 0 { + return errno + } + return syscall.Errno(r1) +} diff --git a/src/go/pkg/netipc/transport/posix/shm_fallocate_freebsd_amd64.go b/src/go/pkg/netipc/transport/posix/shm_fallocate_freebsd_amd64.go new file mode 100644 index 00000000..617d5228 --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/shm_fallocate_freebsd_amd64.go @@ -0,0 +1,16 @@ +//go:build freebsd && amd64 + +package posix + +import "syscall" + +// posixFallocate returns the error number: the syscall reports it in the +// return value, not in errno. +func posixFallocate(fd int, off, length int64) syscall.Errno { + r1, _, errno := syscall.Syscall(syscall.SYS_POSIX_FALLOCATE, + uintptr(fd), uintptr(off), uintptr(length)) + if errno != 0 { + return errno + } + return syscall.Errno(r1) +} diff --git a/src/go/pkg/netipc/transport/posix/shm_futex_linux.go b/src/go/pkg/netipc/transport/posix/shm_futex_linux.go new file mode 100644 index 00000000..fbdc2eed --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/shm_futex_linux.go @@ -0,0 +1,59 @@ +//go:build linux + +// Linux SHM primitives: futex wait/wake and the reserving allocation. + +package posix + +import ( + "syscall" + "unsafe" +) + +// futex operations +const ( + futexWait = 0 + futexWake = 1 +) + +// shmAllocate reserves the whole region file. +func shmAllocate(fd int, mode uint32, off, length int64) error { + return syscall.Fallocate(fd, mode, off, length) +} + +func futexWakeCall(data []byte, off int, count int) int { + if off < 0 || off+4 > len(data) { + return -1 + } + addr := unsafe.Pointer(&data[off]) // #nosec G103 -- futex needs the address of the mmap signal word. + r1, _, _ := syscall.Syscall6( + syscall.SYS_FUTEX, + uintptr(addr), + uintptr(futexWake), + uintptr(count), + 0, 0, 0, + ) + return int(r1) +} + +func futexWaitCall(data []byte, off int, expected uint32, ts *syscall.Timespec) int { + if off < 0 || off+4 > len(data) { + return -1 + } + addr := unsafe.Pointer(&data[off]) // #nosec G103 -- futex needs the address of the mmap signal word. + var tsPtr uintptr + if ts != nil { + tsPtr = uintptr(unsafe.Pointer(ts)) // #nosec G103 -- futex syscall requires an optional Timespec pointer. + } + r1, _, errno := syscall.Syscall6( + syscall.SYS_FUTEX, + uintptr(addr), + uintptr(futexWait), + uintptr(expected), + tsPtr, + 0, 0, + ) + if errno != 0 { + return -int(errno) + } + return int(r1) +} diff --git a/src/go/pkg/netipc/transport/posix/shm_more_edge_test.go b/src/go/pkg/netipc/transport/posix/shm_more_edge_test.go index d15c895e..def59774 100644 --- a/src/go/pkg/netipc/transport/posix/shm_more_edge_test.go +++ b/src/go/pkg/netipc/transport/posix/shm_more_edge_test.go @@ -1,4 +1,4 @@ -//go:build linux +//go:build linux || (freebsd && (amd64 || 386)) package posix @@ -9,8 +9,17 @@ import ( "path/filepath" "syscall" "testing" + "unsafe" ) +// alignedShmData returns an 8-byte aligned buffer standing in for the mmap'd +// region: small byte slices may live on the stack unaligned, and atomics on +// them fault on arm64 and panic on 32-bit architectures. +func alignedShmData(n int) []byte { + words := make([]uint64, (n+7)/8) + return unsafe.Slice((*byte)(unsafe.Pointer(&words[0])), n)[:n:n] // #nosec G103 -- test-only aligned view. +} + func defaultShmLayout() (uint32, uint32, uint32, uint32) { reqCap := shmAlign64(128) respCap := shmAlign64(128) @@ -78,33 +87,36 @@ func TestShmServerCreatePreallocation(t *testing.T) { } }) - t.Run("unsupported allocation has no sparse fallback", func(t *testing.T) { - runDir := t.TempDir() - const ( - svc = "go_shm_allocate_unsupported" - sessionID = uint64(43) - ) - - ctx, err := shmServerCreate(runDir, svc, sessionID, 1024, 1024, - func(_ int, _ uint32, _, _ int64) error { - return syscall.EOPNOTSUPP - }) - if ctx != nil { - ctx.ShmDestroy() - t.Fatal("unsupported allocation returned a context") - } - if !errors.Is(err, ErrShmAllocate) || !errors.Is(err, syscall.EOPNOTSUPP) { - t.Fatalf("create error = %v, want allocation error wrapping EOPNOTSUPP", err) - } + // EINVAL is how FreeBSD ZFS rejects posix_fallocate. + for _, cause := range []syscall.Errno{syscall.EOPNOTSUPP, syscall.EINVAL} { + t.Run("unsupported allocation has no sparse fallback/"+cause.Error(), func(t *testing.T) { + runDir := t.TempDir() + const ( + svc = "go_shm_allocate_unsupported" + sessionID = uint64(43) + ) + + ctx, err := shmServerCreate(runDir, svc, sessionID, 1024, 1024, + func(_ int, _ uint32, _, _ int64) error { + return cause + }) + if ctx != nil { + ctx.ShmDestroy() + t.Fatal("unsupported allocation returned a context") + } + if !errors.Is(err, ErrShmAllocate) || !errors.Is(err, cause) { + t.Fatalf("create error = %v, want allocation error wrapping %v", err, cause) + } - path, pathErr := buildShmPath(runDir, svc, sessionID) - if pathErr != nil { - t.Fatalf("build path: %v", pathErr) - } - if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { - t.Fatalf("unsupported allocation file should be removed, stat err = %v", statErr) - } - }) + path, pathErr := buildShmPath(runDir, svc, sessionID) + if pathErr != nil { + t.Fatalf("build path: %v", pathErr) + } + if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("unsupported allocation file should be removed, stat err = %v", statErr) + } + }) + } t.Run("EINTR retries", func(t *testing.T) { runDir := t.TempDir() @@ -115,7 +127,7 @@ func TestShmServerCreatePreallocation(t *testing.T) { if calls == 1 { return syscall.EINTR } - return syscall.Fallocate(fd, mode, off, len) + return shmAllocate(fd, mode, off, len) }) if err != nil { t.Fatalf("create after EINTR: %v", err) @@ -165,36 +177,6 @@ func writeRawShmRegionFile(t *testing.T, runDir, service string, sessionID uint6 return path } -func TestShmOwnerAliveFalsePaths(t *testing.T) { - if (&ShmContext{data: make([]byte, 8)}).OwnerAlive() { - t.Fatal("short header should not be treated as alive") - } - - runDir := t.TempDir() - svc := "go_shm_owner_false" - ctx, err := ShmServerCreate(runDir, svc, 1, 1024, 1024) - if err != nil { - t.Fatalf("server create: %v", err) - } - defer ctx.ShmDestroy() - - binary.NativeEndian.PutUint32(ctx.data[shmHeaderOwnerPidOff:shmHeaderOwnerPidOff+4], 0) - if ctx.OwnerAlive() { - t.Fatal("owner pid 0 should not be treated as alive") - } - - binary.NativeEndian.PutUint32(ctx.data[shmHeaderOwnerPidOff:shmHeaderOwnerPidOff+4], uint32(int32(os.Getpid()))) - binary.NativeEndian.PutUint32(ctx.data[shmHeaderOwnerGenOff:shmHeaderOwnerGenOff+4], ctx.ownerGeneration+1) - if ctx.OwnerAlive() { - t.Fatal("generation mismatch should not be treated as alive") - } - - ctx.ownerGeneration = 0 - if !ctx.OwnerAlive() { - t.Fatal("legacy zero cached generation should skip generation check") - } -} - func TestShmServerCreateRejectsLiveRegion(t *testing.T) { runDir := t.TempDir() svc := "go_shm_live_region" @@ -292,6 +274,24 @@ func TestShmClientAttachRejectsMalformedHeaders(t *testing.T) { }, want: ErrShmBadSize, }, + { + // The uint32 sum wraps to a small value; on 32-bit the old int + // arithmetic went negative and accepted the header. + name: "request-end-wraps-uint32", + size: validSize, + fill: func(data []byte) { + fillShmHeader(data, int32(os.Getpid()), 1, 0xFFFFFFC0, 0x80, respOff, respCap) + }, + want: ErrShmBadSize, + }, + { + name: "response-end-wraps-uint32", + size: validSize, + fill: func(data []byte) { + fillShmHeader(data, int32(os.Getpid()), 1, reqOff, reqCap, 0xFFFFFFC0, 0x40) + }, + want: ErrShmBadSize, + }, { name: "declared-region-beyond-file-size", size: int(shmHeaderLen) + 64, @@ -333,7 +333,7 @@ func TestShmSendBadParamGuards(t *testing.T) { storeLenCtx := &ShmContext{ role: ShmRoleClient, - data: make([]byte, 39), + data: alignedShmData(39), requestCapacity: 64, } if err := storeLenCtx.ShmSend([]byte{1}); !errors.Is(err, ErrShmBadParam) { @@ -342,7 +342,7 @@ func TestShmSendBadParamGuards(t *testing.T) { addSeqCtx := &ShmContext{ role: ShmRoleClient, - data: make([]byte, 48), + data: alignedShmData(48), requestCapacity: 64, } if err := addSeqCtx.ShmSend([]byte{1}); !errors.Is(err, ErrShmBadParam) { @@ -351,7 +351,7 @@ func TestShmSendBadParamGuards(t *testing.T) { addSignalCtx := &ShmContext{ role: ShmRoleClient, - data: make([]byte, 56), + data: alignedShmData(56), requestCapacity: 64, } if err := addSignalCtx.ShmSend([]byte{1}); !errors.Is(err, ErrShmBadParam) { @@ -364,13 +364,13 @@ func TestShmReceiveBadParamAndTimeoutPaths(t *testing.T) { t.Fatalf("ShmReceive nil context = %v, want %v", err, ErrShmBadParam) } - if _, err := (&ShmContext{data: make([]byte, 8)}).ShmReceive(nil, 1); !errors.Is(err, ErrShmBadParam) { + if _, err := (&ShmContext{data: alignedShmData(8)}).ShmReceive(nil, 1); !errors.Is(err, ErrShmBadParam) { t.Fatalf("ShmReceive empty buffer = %v, want %v", err, ErrShmBadParam) } spinLoadSeqCtx := &ShmContext{ role: ShmRoleClient, - data: make([]byte, 8), + data: alignedShmData(8), responseCapacity: 64, SpinTries: 1, } @@ -380,7 +380,7 @@ func TestShmReceiveBadParamAndTimeoutPaths(t *testing.T) { spinLoadLenCtx := &ShmContext{ role: ShmRoleClient, - data: make([]byte, 48), + data: alignedShmData(48), responseCapacity: 64, SpinTries: 1, } @@ -394,7 +394,7 @@ func TestShmReceiveBadParamAndTimeoutPaths(t *testing.T) { futexLoadSignalCtx := &ShmContext{ role: ShmRoleClient, - data: make([]byte, 8), + data: alignedShmData(8), responseCapacity: 64, SpinTries: 0, } @@ -404,7 +404,7 @@ func TestShmReceiveBadParamAndTimeoutPaths(t *testing.T) { futexLoadSeqCtx := &ShmContext{ role: ShmRoleClient, - data: make([]byte, 48), + data: alignedShmData(48), responseCapacity: 64, SpinTries: 0, } @@ -481,9 +481,9 @@ func TestCheckShmStaleVariants(t *testing.T) { t.Fatalf("legacy stale file should be removed, stat err = %v", err) } - unreadablePath := writeRawShmRegionFile(t, runDir, "go_shm_check_unreadable", 5, validSize, func(data []byte) { - fillShmHeader(data, int32(os.Getpid()), 1, reqOff, reqCap, respOff, respCap) - }) + // The header has no magic: a non-root run fails to open the file, and + // root, which opens a mode-000 file, finds no region. Both reclaim it. + unreadablePath := writeRawShmRegionFile(t, runDir, "go_shm_check_unreadable", 5, validSize, func([]byte) {}) if err := os.Chmod(unreadablePath, 0); err != nil { t.Fatalf("chmod unreadable stale file: %v", err) } @@ -591,3 +591,9 @@ func TestShmCleanupStaleMissingDirAndUnrelatedFile(t *testing.T) { t.Fatalf("unrelated file should remain after cleanup, stat err = %v", err) } } + +func TestShmEmptyRunDirRejected(t *testing.T) { + if _, err := ShmServerCreate("", "go_shm_empty_dir", 1, 1024, 1024); !errors.Is(err, ErrShmBadParam) { + t.Errorf("ShmServerCreate with empty run_dir: err = %v, want ErrShmBadParam", err) + } +} diff --git a/src/go/pkg/netipc/transport/posix/shm_other.go b/src/go/pkg/netipc/transport/posix/shm_other.go new file mode 100644 index 00000000..70e74956 --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/shm_other.go @@ -0,0 +1,52 @@ +//go:build unix && !linux && !(freebsd && (amd64 || 386)) + +package posix + +import "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" + +// platformProfiles limits the handshake to the baseline profile: POSIX SHM +// needs a futex-style wait (Linux futex, FreeBSD _umtx_op), and Go on +// FreeBSD provides it only on amd64 and 386. +const platformProfiles = protocol.ProfileBaseline + +// ShmContext is a handle to a shared memory region. Without SHM support no +// context is ever created. +type ShmContext struct { + role ShmRole + fd int + + SpinTries uint32 +} + +// Role returns the context role. +func (c *ShmContext) Role() ShmRole { return c.role } + +// Fd returns the file descriptor. +func (c *ShmContext) Fd() int { return c.fd } + +// ShmServerCreate reports that SHM is not supported here. +func ShmServerCreate(runDir, serviceName string, sessionID uint64, reqCapacity, respCapacity uint32) (*ShmContext, error) { + return nil, ErrShmUnsupported +} + +// ShmClientAttach reports that SHM is not supported here. +func ShmClientAttach(runDir, serviceName string, sessionID uint64) (*ShmContext, error) { + return nil, ErrShmUnsupported +} + +// ShmDestroy does nothing: no region exists. +func (c *ShmContext) ShmDestroy() {} + +// ShmClose does nothing: no region exists. +func (c *ShmContext) ShmClose() {} + +// ShmSend reports that SHM is not supported here. +func (c *ShmContext) ShmSend(msg []byte) error { return ErrShmUnsupported } + +// ShmReceive reports that SHM is not supported here. +func (c *ShmContext) ShmReceive(buf []byte, timeoutMs uint32) (int, error) { + return 0, ErrShmUnsupported +} + +// ShmCleanupStale does nothing: no regions are ever created here. +func ShmCleanupStale(runDir, serviceName string) {} diff --git a/src/go/pkg/netipc/transport/posix/shm_pause_amd64.go b/src/go/pkg/netipc/transport/posix/shm_pause_amd64.go index 1cc0e93e..27cdf40d 100644 --- a/src/go/pkg/netipc/transport/posix/shm_pause_amd64.go +++ b/src/go/pkg/netipc/transport/posix/shm_pause_amd64.go @@ -1,4 +1,4 @@ -//go:build linux && amd64 +//go:build (linux || freebsd) && amd64 package posix diff --git a/src/go/pkg/netipc/transport/posix/shm_pause_amd64.s b/src/go/pkg/netipc/transport/posix/shm_pause_amd64.s index 6728f272..a71b0a09 100644 --- a/src/go/pkg/netipc/transport/posix/shm_pause_amd64.s +++ b/src/go/pkg/netipc/transport/posix/shm_pause_amd64.s @@ -1,4 +1,4 @@ -//go:build linux && amd64 +//go:build (linux || freebsd) && amd64 #include "textflag.h" diff --git a/src/go/pkg/netipc/transport/posix/shm_pause_other.go b/src/go/pkg/netipc/transport/posix/shm_pause_other.go index 6e8871f9..5d12ddd4 100644 --- a/src/go/pkg/netipc/transport/posix/shm_pause_other.go +++ b/src/go/pkg/netipc/transport/posix/shm_pause_other.go @@ -1,4 +1,4 @@ -//go:build linux && !amd64 +//go:build (linux && !amd64) || (freebsd && 386) package posix diff --git a/src/go/pkg/netipc/transport/posix/shm_linux.go b/src/go/pkg/netipc/transport/posix/shm_posix.go similarity index 82% rename from src/go/pkg/netipc/transport/posix/shm_linux.go rename to src/go/pkg/netipc/transport/posix/shm_posix.go index 235fac1b..f1cdeea7 100644 --- a/src/go/pkg/netipc/transport/posix/shm_linux.go +++ b/src/go/pkg/netipc/transport/posix/shm_posix.go @@ -1,7 +1,9 @@ -//go:build linux +//go:build linux || (freebsd && (amd64 || 386)) -// SHM transport for Linux — shared memory data plane with spin+futex -// synchronization. Wire-compatible with the C and Rust implementations. +// SHM transport for Linux and FreeBSD — shared memory data plane with +// spin+futex synchronization (FreeBSD: _umtx_op, shm_umtx_freebsd.go). +// Wire-compatible with the C and Rust implementations. FreeBSD is limited +// to amd64 and 386, whose raw syscall layouts are tested. // // Pure Go — no cgo. Works with CGO_ENABLED=0. @@ -17,6 +19,9 @@ import ( "syscall" "time" "unsafe" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/monotime" + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" ) // --------------------------------------------------------------------------- @@ -47,47 +52,12 @@ const ( shmHeaderReqSignalOff = 56 shmHeaderRespSignalOff = 60 - // futex operations - futexWait = 0 - futexWake = 1 - shmMaxPath = 256 ) -// --------------------------------------------------------------------------- -// Errors -// --------------------------------------------------------------------------- - -var ( - ErrShmPathTooLong = errors.New("SHM path exceeds limit") - ErrShmOpen = errors.New("SHM open failed") - ErrShmAllocate = errors.New("SHM allocation failed") - // Deprecated: SHM creation no longer uses ftruncate; use ErrShmAllocate. - ErrShmTruncate = errors.New("SHM ftruncate failed") - ErrShmMmap = errors.New("SHM mmap failed") - ErrShmBadMagic = errors.New("SHM header magic mismatch") - ErrShmBadVersion = errors.New("SHM header version mismatch") - ErrShmBadHeader = errors.New("SHM header_len mismatch") - ErrShmBadSize = errors.New("SHM file too small for declared areas") - ErrShmAddrInUse = errors.New("SHM region owned by live server") - ErrShmNotReady = errors.New("SHM server not ready") - ErrShmMsgTooLarge = errors.New("message exceeds SHM area capacity") - ErrShmTimeout = errors.New("SHM futex wait timed out") - ErrShmBadParam = errors.New("invalid SHM argument") - ErrShmPeerDead = errors.New("SHM owner process has exited") -) - -// --------------------------------------------------------------------------- -// Role -// --------------------------------------------------------------------------- - -// ShmRole distinguishes server vs client SHM contexts. -type ShmRole int - -const ( - ShmRoleServer ShmRole = 1 - ShmRoleClient ShmRole = 2 -) +// platformProfiles is every POSIX profile this system provides; unassigned +// bits are never offered or accepted. +const platformProfiles = protocol.ProfileBaseline | protocol.ProfileSHMHybrid | protocol.ProfileSHMFutex // --------------------------------------------------------------------------- // SHM context @@ -107,9 +77,8 @@ type ShmContext struct { localReqSeq uint64 localRespSeq uint64 - SpinTries uint32 - ownerGeneration uint32 // cached for PID reuse detection - path string + SpinTries uint32 + path string } // Role returns the context role. @@ -118,24 +87,14 @@ func (c *ShmContext) Role() ShmRole { return c.role } // Fd returns the file descriptor. func (c *ShmContext) Fd() int { return c.fd } -// OwnerAlive checks if the region's owner process is still alive. -func (c *ShmContext) OwnerAlive() bool { - if len(c.data) < int(shmHeaderLen) { - return false - } - pid := int32(binary.NativeEndian.Uint32(c.data[shmHeaderOwnerPidOff : shmHeaderOwnerPidOff+4])) - if !pidAlive(int(pid)) { - return false - } - // Verify generation matches to detect PID reuse. - // Skip check if cached generation is 0 (legacy region). - if c.ownerGeneration != 0 { - curGen := binary.NativeEndian.Uint32(c.data[shmHeaderOwnerGenOff : shmHeaderOwnerGenOff+4]) - if curGen != c.ownerGeneration { - return false - } +// dupCloexec duplicates fd with close-on-exec set, so a consumer's exec'd +// children do not inherit the SHM region (syscall.Dup does not set it). +func dupCloexec(fd int) (int, error) { + r, _, errno := syscall.Syscall(syscall.SYS_FCNTL, uintptr(fd), syscall.F_DUPFD_CLOEXEC, 0) + if errno != 0 { + return -1, errno } - return true + return int(r), nil } // --------------------------------------------------------------------------- @@ -156,7 +115,7 @@ func shmPreallocate(fd int, size int64, fallocate shmFallocateFunc) error { // ShmServerCreate creates a SHM region at {runDir}/{serviceName}-{sessionID}.ipcshm. func ShmServerCreate(runDir, serviceName string, sessionID uint64, reqCapacity, respCapacity uint32) (*ShmContext, error) { - return shmServerCreate(runDir, serviceName, sessionID, reqCapacity, respCapacity, syscall.Fallocate) + return shmServerCreate(runDir, serviceName, sessionID, reqCapacity, respCapacity, shmAllocate) } func shmServerCreate(runDir, serviceName string, sessionID uint64, reqCapacity, respCapacity uint32, fallocate shmFallocateFunc) (*ShmContext, error) { @@ -188,6 +147,20 @@ func shmServerCreate(runDir, serviceName string, sessionID uint64, reqCapacity, return nil, fmt.Errorf("%w: %v", ErrShmOpen, err) } fd := int(f.Fd()) + // The file takes 0600 & ~umask; a umask without owner read or write would + // stop the client from attaching read-write. A region the client cannot + // attach is not created: the session then drops SHM. + for { + err = syscall.Fchmod(fd, 0o600) + if err != syscall.EINTR { + break + } + } + if err != nil { + _ = f.Close() + _ = os.Remove(path) + return nil, fmt.Errorf("%w: fchmod: %v", ErrShmOpen, err) + } if err := shmPreallocate(fd, int64(regionSize), fallocate); err != nil { _ = f.Close() @@ -232,7 +205,7 @@ func shmServerCreate(runDir, serviceName string, sessionID uint64, reqCapacity, // not use os.File.Close(). We already have the fd from f.Fd(). // Trick: prevent Go's finalizer from closing fd. // The safe way: dup the fd, then close the file. - newFd, err := syscall.Dup(fd) + newFd, err := dupCloexec(fd) if err != nil { _ = syscall.Munmap(data) _ = f.Close() @@ -252,7 +225,6 @@ func shmServerCreate(runDir, serviceName string, sessionID uint64, reqCapacity, localReqSeq: 0, localRespSeq: 0, SpinTries: shmDefaultSpin, - ownerGeneration: generation, path: path, }, nil } @@ -346,21 +318,23 @@ func ShmClientAttach(runDir, serviceName string, sessionID uint64) (*ShmContext, return nil, ErrShmNotReady } + // Region ends are computed in uint64: uint32 sums wrap, and int sums go + // negative on 32-bit, either of which would let a forged header pass. + const align = uint64(shmRegionAlignment) + reqEnd := uint64(reqOff) + uint64(reqCap) + respEnd := uint64(respOff) + uint64(respCap) if reqOff%shmRegionAlignment != 0 || reqCap%shmRegionAlignment != 0 || respOff%shmRegionAlignment != 0 || respCap%shmRegionAlignment != 0 || - respOff < shmAlign64(reqOff+reqCap) { + uint64(respOff) < (reqEnd+align-1)&^(align-1) { _ = syscall.Munmap(data) _ = f.Close() return nil, ErrShmBadSize } // Validate region size - reqEnd := int(reqOff) + int(reqCap) - respEnd := int(respOff) + int(respCap) - needed := max(respEnd, reqEnd) - if fileSize < needed { + if uint64(fileSize) < max(respEnd, reqEnd) { _ = syscall.Munmap(data) _ = f.Close() return nil, ErrShmBadSize @@ -379,10 +353,8 @@ func ShmClientAttach(runDir, serviceName string, sessionID uint64) (*ShmContext, _ = f.Close() return nil, fmt.Errorf("%w: load resp_seq: %v", ErrShmBadParam, err) } - ownerGen := binary.NativeEndian.Uint32(data[shmHeaderOwnerGenOff : shmHeaderOwnerGenOff+4]) - // Dup fd and close file - newFd, err := syscall.Dup(fd) + newFd, err := dupCloexec(fd) if err != nil { _ = syscall.Munmap(data) _ = f.Close() @@ -401,7 +373,6 @@ func ShmClientAttach(runDir, serviceName string, sessionID uint64) (*ShmContext, localReqSeq: curReqSeq, localRespSeq: curRespSeq, SpinTries: shmDefaultSpin, - ownerGeneration: ownerGen, path: path, }, nil } @@ -510,7 +481,9 @@ func (c *ShmContext) ShmReceive(buf []byte, timeoutMs uint32) (int, error) { expectedSeq = c.localRespSeq + 1 } - // Limit copy to the smaller of caller buffer and SHM area capacity + // Limit copy to the smaller of caller buffer and SHM area capacity. + // mlen is compared as uint64: int(mlen) wraps negative on 32-bit, which + // would let a forged length pass the bound. maxCopy := min(int(areaCap), len(buf)) // Phase 1: spin. Copy immediately on observing the advance. @@ -526,7 +499,7 @@ func (c *ShmContext) ShmReceive(buf []byte, timeoutMs uint32) (int, error) { if err != nil { return 0, fmt.Errorf("%w: load msg_len: %v", ErrShmBadParam, err) } - if mlen > 0 && int(mlen) <= maxCopy { + if mlen > 0 && uint64(mlen) <= uint64(maxCopy) { copy(buf[:mlen], c.data[areaOff:areaOff+mlen]) } observed = true @@ -539,15 +512,12 @@ func (c *ShmContext) ShmReceive(buf []byte, timeoutMs uint32) (int, error) { // // Handles spurious wakeups (EAGAIN when signal word changed // between read and syscall, or EINTR from signal delivery). - // Computes a wall-clock deadline so total wait never exceeds + // Computes a monotonic deadline so total wait never exceeds // timeoutMs regardless of retries. if !observed { var deadlineNs uint64 if timeoutMs > 0 { - var nowTs syscall.Timespec - syscall.Syscall(syscall.SYS_CLOCK_GETTIME, 1 /* CLOCK_MONOTONIC */, uintptr(unsafe.Pointer(&nowTs)), 0) // #nosec G103 -- syscall requires passing a Timespec pointer. - deadlineNs = uint64(nowTs.Sec)*1_000_000_000 + uint64(nowTs.Nsec) + - uint64(timeoutMs)*1_000_000 + deadlineNs = monotonicNs() + uint64(timeoutMs)*1_000_000 } for { @@ -567,9 +537,7 @@ func (c *ShmContext) ShmReceive(buf []byte, timeoutMs uint32) (int, error) { // Compute remaining timeout for this futex_wait call var ts *syscall.Timespec if deadlineNs > 0 { - var nowTs syscall.Timespec - syscall.Syscall(syscall.SYS_CLOCK_GETTIME, 1 /* CLOCK_MONOTONIC */, uintptr(unsafe.Pointer(&nowTs)), 0) // #nosec G103 -- syscall requires passing a Timespec pointer. - nowVal := uint64(nowTs.Sec)*1_000_000_000 + uint64(nowTs.Nsec) + nowVal := monotonicNs() if nowVal >= deadlineNs { return 0, ErrShmTimeout } @@ -595,7 +563,7 @@ func (c *ShmContext) ShmReceive(buf []byte, timeoutMs uint32) (int, error) { if lerr != nil { return 0, fmt.Errorf("%w: load msg_len: %v", ErrShmBadParam, lerr) } - if mlen > 0 && int(mlen) <= maxCopy { + if mlen > 0 && uint64(mlen) <= uint64(maxCopy) { copy(buf[:mlen], c.data[areaOff:areaOff+mlen]) } } @@ -608,9 +576,13 @@ func (c *ShmContext) ShmReceive(buf []byte, timeoutMs uint32) (int, error) { } // Message larger than safe copy limit - if int(mlen) > maxCopy { + if uint64(mlen) > uint64(maxCopy) { return int(mlen), ErrShmMsgTooLarge } + // Send rejects zero-length messages, so one here is corruption. + if mlen == 0 { + return 0, fmt.Errorf("%w: zero-length message", ErrShmBadHeader) + } return int(mlen), nil } @@ -644,6 +616,9 @@ func validateShmServiceName(name string) error { } func buildShmPath(runDir, serviceName string, sessionID uint64) (string, error) { + if runDir == "" { + return "", fmt.Errorf("%w: empty run_dir", ErrShmBadParam) + } if err := validateShmServiceName(serviceName); err != nil { return "", err } @@ -709,42 +684,10 @@ func atomicAddU32(data []byte, off int, val uint32) error { return nil } -func futexWakeCall(data []byte, off int, count int) int { - if off < 0 || off+4 > len(data) { - return -1 - } - addr := unsafe.Pointer(&data[off]) // #nosec G103 -- futex needs the address of the mmap signal word. - r1, _, _ := syscall.Syscall6( - syscall.SYS_FUTEX, - uintptr(addr), - uintptr(futexWake), - uintptr(count), - 0, 0, 0, - ) - return int(r1) -} - -func futexWaitCall(data []byte, off int, expected uint32, ts *syscall.Timespec) int { - if off < 0 || off+4 > len(data) { - return -1 - } - addr := unsafe.Pointer(&data[off]) // #nosec G103 -- futex needs the address of the mmap signal word. - var tsPtr uintptr - if ts != nil { - tsPtr = uintptr(unsafe.Pointer(ts)) // #nosec G103 -- futex syscall requires an optional Timespec pointer. - } - r1, _, errno := syscall.Syscall6( - syscall.SYS_FUTEX, - uintptr(addr), - uintptr(futexWait), - uintptr(expected), - tsPtr, - 0, 0, - ) - if errno != 0 { - return -int(errno) - } - return int(r1) +// monotonicNs returns monotonic nanoseconds since process start; the wait +// only needs differences, for relative futex/umtx timeouts. +func monotonicNs() uint64 { + return uint64(monotime.Now()) // #nosec G115 -- monotonic time since start is non-negative. } // --------------------------------------------------------------------------- diff --git a/src/go/pkg/netipc/transport/posix/shm_linux_test.go b/src/go/pkg/netipc/transport/posix/shm_posix_test.go similarity index 90% rename from src/go/pkg/netipc/transport/posix/shm_linux_test.go rename to src/go/pkg/netipc/transport/posix/shm_posix_test.go index 1f27321f..60d6956b 100644 --- a/src/go/pkg/netipc/transport/posix/shm_linux_test.go +++ b/src/go/pkg/netipc/transport/posix/shm_posix_test.go @@ -1,4 +1,4 @@ -//go:build linux +//go:build linux || (freebsd && (amd64 || 386)) package posix @@ -7,6 +7,7 @@ import ( "encoding/binary" "errors" "fmt" + "math" "os" "sync" "testing" @@ -17,6 +18,10 @@ import ( const testShmRunDir = "/tmp/nipc_shm_go_test" +// testDeadPID is above every OS PID limit, so never a live process; a small +// fixed PID such as 99999 can be in use on hosts with a large pid_max. +const testDeadPID = math.MaxInt32 + func ensureShmRunDir(t *testing.T) { t.Helper() if err := os.MkdirAll(testShmRunDir, 0700); err != nil { @@ -41,32 +46,20 @@ func uniqueShmService(t *testing.T, prefix string) string { return fmt.Sprintf("%s_%s", prefix, uniqueService(t)) } -func waitShmClientAttach(t *testing.T, runDir, service string, sessionID uint64) *ShmContext { +// waitShmClientAttach attaches once, after created closes: the server side +// has made the region by then, so any failure is real. +func waitShmClientAttach(t *testing.T, created <-chan struct{}, runDir, service string, sessionID uint64) *ShmContext { t.Helper() - - deadline := time.Now().Add(5 * time.Second) - var lastErr error - for time.Now().Before(deadline) { - ctx, err := ShmClientAttach(runDir, service, sessionID) - if err == nil { - return ctx - } - lastErr = err - - if !errors.Is(err, ErrShmOpen) && - !errors.Is(err, ErrShmNotReady) && - !errors.Is(err, ErrShmBadMagic) && - !errors.Is(err, ErrShmBadVersion) && - !errors.Is(err, ErrShmBadHeader) && - !errors.Is(err, ErrShmBadSize) { - t.Fatalf("attach should not fail with non-transient error while waiting for readiness: %v", err) - } - - time.Sleep(10 * time.Millisecond) + select { + case <-created: + case <-time.After(10 * time.Second): + t.Fatal("server did not create the SHM region within 10s") } - - t.Fatalf("timed out waiting for SHM region readiness: %v", lastErr) - return nil + ctx, err := ShmClientAttach(runDir, service, sessionID) + if err != nil { + t.Fatalf("ShmClientAttach: %v", err) + } + return ctx } // buildShmMessage creates a complete wire message (32-byte header + payload). @@ -96,8 +89,10 @@ func TestShmDirectRoundtrip(t *testing.T) { var wg sync.WaitGroup var serverErr error + created := make(chan struct{}) wg.Go(func() { ctx, err := ShmServerCreate(testShmRunDir, svc, 1, 4096, 4096) + close(created) if err != nil { serverErr = fmt.Errorf("server create: %w", err) return @@ -130,7 +125,7 @@ func TestShmDirectRoundtrip(t *testing.T) { } }) - client := waitShmClientAttach(t, testShmRunDir, svc, 1) + client := waitShmClientAttach(t, created, testShmRunDir, svc, 1) defer client.ShmClose() payload := []byte{0xCA, 0xFE, 0xBA, 0xBE} @@ -179,8 +174,10 @@ func TestShmMultipleRoundtrips(t *testing.T) { var wg sync.WaitGroup var serverErr error + created := make(chan struct{}) wg.Go(func() { ctx, err := ShmServerCreate(testShmRunDir, svc, 2, 4096, 4096) + close(created) if err != nil { serverErr = fmt.Errorf("server create: %w", err) return @@ -209,7 +206,7 @@ func TestShmMultipleRoundtrips(t *testing.T) { } }) - client := waitShmClientAttach(t, testShmRunDir, svc, 2) + client := waitShmClientAttach(t, created, testShmRunDir, svc, 2) defer client.ShmClose() respBuf := make([]byte, 65536) @@ -259,8 +256,8 @@ func TestShmStaleRecovery(t *testing.T) { } // Write a dead PID into the header - binary.NativeEndian.PutUint32(first.data[8:12], 99999) // very unlikely alive - first.ShmClose() // close without unlink + binary.NativeEndian.PutUint32(first.data[8:12], testDeadPID) + first.ShmClose() // close without unlink // Clean up stale regions (as production server would) ShmCleanupStale(testShmRunDir, svc) @@ -289,8 +286,8 @@ func TestShmStaleRecoveryInGroupWritableRunDir(t *testing.T) { if err != nil { t.Fatalf("first create: %v", err) } - binary.NativeEndian.PutUint32(first.data[8:12], 99999) // very unlikely alive - first.ShmClose() // close without unlink + binary.NativeEndian.PutUint32(first.data[8:12], testDeadPID) + first.ShmClose() // close without unlink // ServerCreate must reclaim the dead-owner region through its EEXIST path second, err := ShmServerCreate(runDir, svc, 3, 1024, 1024) @@ -342,8 +339,10 @@ func TestShmLargeMessage(t *testing.T) { var wg sync.WaitGroup var serverErr error + created := make(chan struct{}) wg.Go(func() { ctx, err := ShmServerCreate(testShmRunDir, svc, 4, 65536, 65536) + close(created) if err != nil { serverErr = fmt.Errorf("server create: %w", err) return @@ -370,7 +369,7 @@ func TestShmLargeMessage(t *testing.T) { } }) - client := waitShmClientAttach(t, testShmRunDir, svc, 4) + client := waitShmClientAttach(t, created, testShmRunDir, svc, 4) defer client.ShmClose() // 60000 bytes of payload @@ -458,9 +457,10 @@ func TestShmChaosForgedLength(t *testing.T) { mlen, recvErr := srv.ShmReceive(buf, 1000) if forgedLen == 0 { - // Zero-length: no copy, no error, returns 0 bytes - if recvErr != nil { - t.Errorf("forged req_len=0: unexpected error: %v", recvErr) + // Zero-length: send rejects it, so it is corruption (spec, + // level1-posix-shm.md "Server receives a request", step 4). + if !errors.Is(recvErr, ErrShmBadHeader) { + t.Errorf("forged req_len=0: got err=%v, want ErrShmBadHeader", recvErr) } if mlen != 0 { t.Errorf("forged req_len=0: got mlen=%d, want 0", mlen) @@ -501,8 +501,8 @@ func TestShmChaosForgedLength(t *testing.T) { mlen, recvErr := client.ShmReceive(buf, 1000) if forgedLen == 0 { - if recvErr != nil { - t.Errorf("forged resp_len=0: unexpected error: %v", recvErr) + if !errors.Is(recvErr, ErrShmBadHeader) { + t.Errorf("forged resp_len=0: got err=%v, want ErrShmBadHeader", recvErr) } if mlen != 0 { t.Errorf("forged resp_len=0: got mlen=%d, want 0", mlen) @@ -572,12 +572,16 @@ func TestShmMultiClient(t *testing.T) { }) } + // The regions were created above, before the goroutines started. + shmCreated := make(chan struct{}) + close(shmCreated) + // Attach clients and send unique messages clients := make([]*ShmContext, numClients) payloads := make([][]byte, numClients) for i := range numClients { sessionID := uint64(i + 1) - c := waitShmClientAttach(t, testShmRunDir, svc, sessionID) + c := waitShmClientAttach(t, shmCreated, testShmRunDir, svc, sessionID) defer c.ShmClose() clients[i] = c diff --git a/src/go/pkg/netipc/transport/posix/shm_umask_test.go b/src/go/pkg/netipc/transport/posix/shm_umask_test.go new file mode 100644 index 00000000..57c30bd9 --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/shm_umask_test.go @@ -0,0 +1,60 @@ +//go:build linux || (freebsd && (amd64 || 386)) + +package posix + +import ( + "os" + "os/exec" + "syscall" + "testing" +) + +const shmUmaskHelperEnv = "NETIPC_SHM_UMASK_HELPER" + +// The umask is process-wide, so the restrictive-umask case runs in a child +// test process instead of the parallel test runner. +func TestShmAttachUnderRestrictiveUmask(t *testing.T) { + ensureShmRunDir(t) + cmd := exec.Command(os.Args[0], "-test.run=^TestShmUmaskHelperProcess$", "-test.v") + cmd.Env = append(os.Environ(), shmUmaskHelperEnv+"=1") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("attach under umask 0477 failed: %v\n%s", err, out) + } +} + +// TestShmUmaskHelperProcess is the child entry point; it is inert unless +// spawned by TestShmAttachUnderRestrictiveUmask. +func TestShmUmaskHelperProcess(t *testing.T) { + if os.Getenv(shmUmaskHelperEnv) == "" { + return + } + // Restore the umask before exit so files the process writes then + // (coverage data) keep their normal mode. + defer syscall.Umask(syscall.Umask(0o477)) + svc := "go_shm_umask" + cleanupShmFiles(t, svc) + defer cleanupShmFiles(t, svc) + + server, err := ShmServerCreate(testShmRunDir, svc, 7101, 1024, 1024) + if err != nil { + t.Fatalf("server create: %v", err) + } + defer server.ShmDestroy() + path, err := buildShmPath(testShmRunDir, svc, 7101) + if err != nil { + t.Fatalf("path: %v", err) + } + st, err := os.Stat(path) + if err != nil { + t.Fatalf("region stat: %v", err) + } + if mode := st.Mode().Perm(); mode != 0o600 { + t.Fatalf("region file mode %o, want 600", mode) + } + client, err := ShmClientAttach(testShmRunDir, svc, 7101) + if err != nil { + t.Fatalf("client attach: %v", err) + } + client.ShmClose() +} diff --git a/src/go/pkg/netipc/transport/posix/shm_umtx_freebsd.go b/src/go/pkg/netipc/transport/posix/shm_umtx_freebsd.go new file mode 100644 index 00000000..fc91e30e --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/shm_umtx_freebsd.go @@ -0,0 +1,74 @@ +//go:build freebsd && (amd64 || 386) + +// FreeBSD SHM primitives: _umtx_op wait/wake, CLOCK_MONOTONIC and the +// reserving allocation. + +package posix + +import ( + "syscall" + "unsafe" +) + +// _umtx_op operations. The shared (not _PRIVATE) ones key on the mapped +// object, so they cross processes. +const ( + umtxOpWake = 3 + umtxOpWaitUint = 11 +) + +const shmClockMonotonic = 4 + +// umtxTime is struct _umtx_time: a relative timeout on a clock. +type umtxTime struct { + timeout syscall.Timespec + flags uint32 + clockid uint32 +} + +// shmAllocate reserves the whole region file with posix_fallocate. ZFS +// rejects it with EINVAL: SHM preparation fails and the session uses +// baseline. +func shmAllocate(fd int, mode uint32, off, length int64) error { + _ = mode + if errno := posixFallocate(fd, off, length); errno != 0 { + return errno + } + return nil +} + +func futexWakeCall(data []byte, off int, count int) int { + if off < 0 || off+4 > len(data) { + return -1 + } + addr := unsafe.Pointer(&data[off]) // #nosec G103 -- _umtx_op needs the address of the mmap signal word. + r1, _, errno := syscall.Syscall6(syscall.SYS__UMTX_OP, + uintptr(addr), umtxOpWake, uintptr(count), 0, 0, 0) + if errno != 0 { + return -int(errno) + } + return int(r1) +} + +// futexWaitCall waits while the word equals expected. A stale expected +// value returns 0 at once (a Linux futex returns EAGAIN); callers re-check +// the sequence after every return. +func futexWaitCall(data []byte, off int, expected uint32, ts *syscall.Timespec) int { + if off < 0 || off+4 > len(data) { + return -1 + } + addr := unsafe.Pointer(&data[off]) // #nosec G103 -- _umtx_op needs the address of the mmap signal word. + var size, tp uintptr + var t umtxTime + if ts != nil { + t = umtxTime{timeout: *ts, clockid: shmClockMonotonic} + size = unsafe.Sizeof(t) + tp = uintptr(unsafe.Pointer(&t)) // #nosec G103 -- _umtx_op takes an optional _umtx_time pointer. + } + r1, _, errno := syscall.Syscall6(syscall.SYS__UMTX_OP, + uintptr(addr), umtxOpWaitUint, uintptr(expected), size, tp, 0) + if errno != 0 { + return -int(errno) + } + return int(r1) +} diff --git a/src/go/pkg/netipc/transport/posix/shm_umtx_freebsd_test.go b/src/go/pkg/netipc/transport/posix/shm_umtx_freebsd_test.go new file mode 100644 index 00000000..54983899 --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/shm_umtx_freebsd_test.go @@ -0,0 +1,58 @@ +//go:build freebsd && (amd64 || 386) + +package posix + +import ( + "os" + "path/filepath" + "syscall" + "testing" +) + +// The raw posix_fallocate syscall reports its error in the return value; +// the per-architecture argument layout must reserve exactly the range. +func TestPosixFallocateRawSyscall(t *testing.T) { + f, err := os.Create(filepath.Join(t.TempDir(), "region")) + if err != nil { + t.Fatal(err) + } + defer f.Close() + // Above 4 GiB, so both halves of each 64-bit argument matter on 386. + const off, length = int64(5) << 30, int64(8192) + if errno := posixFallocate(int(f.Fd()), off, length); errno != 0 { + t.Fatalf("posix_fallocate on a file: %v", errno) + } + info, err := f.Stat() + if err != nil { + t.Fatal(err) + } + if info.Size() != off+length { + t.Fatalf("size %d, want %d", info.Size(), off+length) + } + + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer r.Close() + defer w.Close() + if errno := posixFallocate(int(w.Fd()), 0, 4096); errno != syscall.ESPIPE && errno != syscall.ENODEV { + t.Fatalf("posix_fallocate on a pipe: %v, want ESPIPE or ENODEV", errno) + } +} + +// A wait with a stale expected value returns at once; a wait on the +// current value times out with ETIMEDOUT. +func TestUmtxWaitStaleValueAndTimeout(t *testing.T) { + word := make([]byte, 8) + if ret := futexWaitCall(word, 0, 1, nil); ret != 0 { + t.Fatalf("stale wait = %d, want 0", ret) + } + ts := syscall.NsecToTimespec(10_000_000) + if ret := futexWaitCall(word, 0, 0, &ts); ret != -int(syscall.ETIMEDOUT) { + t.Fatalf("timed wait = %d, want -ETIMEDOUT", ret) + } + if ret := futexWakeCall(word, 0, 1); ret < 0 { + t.Fatalf("wake = %d", ret) + } +} diff --git a/src/go/pkg/netipc/transport/posix/testlock_unix_test.go b/src/go/pkg/netipc/transport/posix/testlock_unix_test.go new file mode 100644 index 00000000..1a66fc1e --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/testlock_unix_test.go @@ -0,0 +1,35 @@ +//go:build unix + +package posix + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +// Endpoint lock files stay after a server closes, by design +// (docs/level1-posix-uds.md, "Endpoint lock"). Test service names are +// unique per run, so without this every run would leave new locks behind: +// the package removes the locks it named once all its tests have finished. +var testLocks struct { + sync.Mutex + paths []string +} + +func trackTestLock(dir, service string) { + testLocks.Lock() + testLocks.paths = append(testLocks.paths, filepath.Join(dir, service+".lock")) + testLocks.Unlock() +} + +func TestMain(m *testing.M) { + code := m.Run() + testLocks.Lock() + for _, p := range testLocks.paths { + _ = os.Remove(p) + } + testLocks.Unlock() + os.Exit(code) +} diff --git a/src/go/pkg/netipc/transport/posix/uds.go b/src/go/pkg/netipc/transport/posix/uds.go index e460b228..331eb531 100644 --- a/src/go/pkg/netipc/transport/posix/uds.go +++ b/src/go/pkg/netipc/transport/posix/uds.go @@ -1,9 +1,11 @@ //go:build unix -// Package posix implements the L1 POSIX UDS SEQPACKET transport. +// Package posix implements the L1 POSIX UDS transport. // // Connection lifecycle, handshake with profile/limit negotiation, -// and send/receive with transparent chunking over AF_UNIX SEQPACKET sockets. +// and send/receive with transparent chunking over AF_UNIX SEQPACKET sockets +// (a stream socket carrying the same packets on macOS and FreeBSD, see +// uds_stream.go). // Wire-compatible with the C and Rust implementations. // // Pure Go — no cgo. Works with CGO_ENABLED=0. @@ -25,6 +27,7 @@ const ( defaultBacklog = 16 defaultBatchItems = 1 defaultPacketSizeFallback uint32 = 65536 + packetSizeMax uint32 = 262144 helloPayloadSize = 44 helloAckPayloadSize = 48 @@ -90,6 +93,11 @@ func validateServiceName(name string) error { // buildSocketPath constructs {runDir}/{serviceName}.sock and validates length. func buildSocketPath(runDir, serviceName string) (string, error) { + // An empty runDir would put the socket in the working directory and + // the lock at "/.lock". + if runDir == "" { + return "", wrapErr(ErrBadParam, "empty run_dir") + } if err := validateServiceName(serviceName); err != nil { return "", err } @@ -102,13 +110,14 @@ func buildSocketPath(runDir, serviceName string) (string, error) { return path, nil } -// detectPacketSize reads SO_SNDBUF from the socket. -func detectPacketSize(fd int) uint32 { - val, err := syscall.GetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_SNDBUF) - if err != nil || val <= 0 { - return defaultPacketSizeFallback +// packetSizeLimit returns the packet size this side may propose: configured +// (0 = no preference) capped by what the kernel accepts as one packet. +func packetSizeLimit(fd int, configured uint32) uint32 { + limit := min(kernelPacketLimit(fd), packetSizeMax) + if configured != 0 && configured < limit { + return configured } - return uint32(val) + return limit } // highestBit returns the highest set bit in a bitmask (0 if empty). @@ -141,30 +150,32 @@ func minU32(a, b uint32) uint32 { // Low-level I/O // --------------------------------------------------------------------------- +// sendPacket sends one packet: one SEQPACKET message, or all its bytes on a +// stream. +func sendPacket(fd int, hdr []byte, payload []byte) error { + if streamTransport { + return streamSendAll(fd, hdr, payload) + } + return rawSendIov(fd, hdr, payload) +} + // rawSendIov sends header + payload as one SEQPACKET message using sendmsg. func rawSendIov(fd int, hdr []byte, payload []byte) error { var iov [2]syscall.Iovec iov[0].Base = unsafe.SliceData(hdr) // #nosec G103 -- sendmsg iovec needs the backing byte-slice pointer. iov[0].SetLen(len(hdr)) - iovlen := uint64(1) + // Msghdr.Iovlen is uint64, uint32 or int32 depending on the platform; + // untyped constants assign to all of them. + msg := syscall.Msghdr{Iov: &iov[0]} + msg.Iovlen = 1 if len(payload) > 0 { iov[1].Base = unsafe.SliceData(payload) // #nosec G103 -- sendmsg iovec needs the backing byte-slice pointer. iov[1].SetLen(len(payload)) - iovlen = 2 - } - - msg := syscall.Msghdr{ - Iov: &iov[0], - Iovlen: iovlen, + msg.Iovlen = 2 } - n, _, errno := syscall.Syscall( - syscall.SYS_SENDMSG, - uintptr(fd), - uintptr(unsafe.Pointer(&msg)), // #nosec G103 -- raw sendmsg syscall requires a Msghdr pointer. - uintptr(syscall.MSG_NOSIGNAL), - ) + n, errno := rawSendmsg(fd, &msg, sendFlags) if errno != 0 { return wrapErr(ErrSend, errno.Error()) } @@ -185,7 +196,9 @@ func ensureScratchBuf(buf *[]byte, needed int) []byte { // rawRecv receives one SEQPACKET message. Returns bytes received. func rawRecv(fd int, buf []byte) (int, error) { - n, _, _, _, err := syscall.Recvmsg(fd, buf, nil, 0) + // read(2) behaves as recvmsg with no flags (one datagram on SEQPACKET) + // and skips the source address that syscall.Recvmsg allocates. + n, err := syscall.Read(fd, buf) if err != nil { return 0, wrapErr(ErrRecv, err.Error()) } diff --git a/src/go/pkg/netipc/transport/posix/uds_bind_darwin.go b/src/go/pkg/netipc/transport/posix/uds_bind_darwin.go new file mode 100644 index 00000000..aa07ea20 --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/uds_bind_darwin.go @@ -0,0 +1,92 @@ +//go:build darwin + +package posix + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "syscall" + + "golang.org/x/sys/unix" +) + +// bindOwnerOnly binds fd to path as owner-only (0600, narrowed by the umask). +// macOS rejects fchmod() on an unbound socket and creates the socket file +// 0777 & ~umask. The socket is bound inside a private 0700 directory +// {serviceName}.d, narrowed to owner-only there, then renamed into place, so +// it is never reachable with a wider mode and the process-global umask is +// untouched. The caller holds the endpoint lock, so a leftover directory +// belongs to a crashed server and is removed first. fchmodat() and open() +// resolve the directory by path without following a symlink, and the owner +// check on the opened descriptor refuses a directory another account swapped +// in. After that check every step but bind() goes through the descriptor, so +// a later swap makes the listen fail instead of placing a socket someone else +// can reach. +func bindOwnerOnly(fd int, runDir, serviceName, path string) error { + dir := filepath.Join(runDir, serviceName+".d") + tmp := filepath.Join(dir, "s") + if len(tmp) >= maxSunPath { + return ErrPathTooLong + } + sockErr := func(op string, err error) error { return wrapErr(ErrSocket, op+": "+err.Error()) } + + // Anything but a real directory is unlinked without following it. + if info, err := os.Lstat(dir); err == nil { + if info.IsDir() { + // unlink, not os.Remove: a directory at tmp is foreign content. + if err := syscall.Unlink(tmp); err != nil && !errors.Is(err, fs.ErrNotExist) { + return sockErr("remove leftover socket", err) + } + } + if err := os.Remove(dir); err != nil { + return sockErr("remove leftover bind dir", err) + } + } else if !errors.Is(err, fs.ErrNotExist) { + return sockErr("stat bind dir", err) + } + + if err := os.Mkdir(dir, 0o700); err != nil { + return sockErr("mkdir bind dir", err) + } + defer func() { _ = os.Remove(dir) }() + // Fchmodat ignores the umask, so a umask without owner bits cannot make the + // directory unopenable or unusable. + if err := unix.Fchmodat(unix.AT_FDCWD, dir, 0o700, unix.AT_SYMLINK_NOFOLLOW); err != nil { + return sockErr("chmod bind dir", err) + } + dfd, err := unix.Open(dir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return sockErr("open bind dir", err) + } + defer func() { _ = unix.Close(dfd) }() + var st unix.Stat_t + if err := unix.Fstat(dfd, &st); err != nil { + return sockErr("stat bind dir", err) + } + if int(st.Uid) != os.Geteuid() { + return sockErr("bind dir", unix.EPERM) + } + // The descriptor is the directory every later step uses; keep it 0700 + // even if another one of ours replaced the path. + if err := unix.Fchmod(dfd, 0o700); err != nil { + return sockErr("chmod bind dir", err) + } + if err := syscall.Bind(fd, &syscall.SockaddrUnix{Name: tmp}); err != nil { + return sockErr("bind", err) + } + // The bound file carries 0777 & ~umask; keep the umask narrowing. + err = unix.Fstatat(dfd, "s", &st, unix.AT_SYMLINK_NOFOLLOW) + if err == nil { + err = unix.Fchmodat(dfd, "s", uint32(st.Mode)&0o600, 0) + } + if err == nil { + err = unix.Renameat(dfd, "s", unix.AT_FDCWD, path) + } + if err != nil { + _ = unix.Unlinkat(dfd, "s", 0) + return sockErr("place socket", err) + } + return nil +} diff --git a/src/go/pkg/netipc/transport/posix/uds_bind_darwin_test.go b/src/go/pkg/netipc/transport/posix/uds_bind_darwin_test.go new file mode 100644 index 00000000..6793b781 --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/uds_bind_darwin_test.go @@ -0,0 +1,97 @@ +//go:build darwin + +package posix + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// macOS binds in {service}.d/ and renames the socket into place. +func TestListenBindDir(t *testing.T) { + runDir := testRunDir(t) + service := uniqueService(t) + dir := filepath.Join(runDir, service+".d") + path := filepath.Join(runDir, service+".sock") + t.Cleanup(func() { _ = os.Remove(path) }) + gone := func(p string) bool { + _, err := os.Lstat(p) + return errors.Is(err, os.ErrNotExist) + } + + listener := startListener(t, runDir, service, defaultServerConfig()) + // Close is idempotent: an early failure below still releases the endpoint. + t.Cleanup(listener.Close) + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat socket: %v", err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Fatalf("socket mode %o, want 0600", mode) + } + if !gone(dir) { + t.Fatal("bind directory not removed") + } + listener.Close() + + // A crashed server's leftover directory is reclaimed. + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "s"), nil, 0o600); err != nil { + t.Fatal(err) + } + startListener(t, runDir, service, defaultServerConfig()).Close() + if !gone(dir) { + t.Fatal("leftover directory not removed") + } + + // A non-directory at the name is junk, as at the socket name. + if err := os.WriteFile(dir, nil, 0o600); err != nil { + t.Fatal(err) + } + startListener(t, runDir, service, defaultServerConfig()).Close() + if !gone(dir) { + t.Fatal("leftover file not removed") + } + + // Foreign content is never deleted: listen fails instead. + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + other := filepath.Join(dir, "other") + if err := os.WriteFile(other, nil, 0o600); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(other); _ = os.Remove(dir) }) + if l, err := Listen(runDir, service, defaultServerConfig()); !errors.Is(err, ErrSocket) { + if l != nil { + l.Close() + } + t.Fatalf("listen over foreign content: %v, want ErrSocket", err) + } + if _, err := os.Stat(other); err != nil { + t.Fatalf("foreign content removed: %v", err) + } + if err := os.Remove(other); err != nil { + t.Fatal(err) + } + + // A directory where the socket goes is foreign content too. + sub := filepath.Join(dir, "s") + if err := os.Mkdir(sub, 0o700); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(sub) }) + if l, err := Listen(runDir, service, defaultServerConfig()); !errors.Is(err, ErrSocket) { + if l != nil { + l.Close() + } + t.Fatalf("listen over a directory at the socket name: %v, want ErrSocket", err) + } + if info, err := os.Stat(sub); err != nil || !info.IsDir() { + t.Fatalf("foreign subdirectory removed: %v", err) + } +} diff --git a/src/go/pkg/netipc/transport/posix/uds_bind_other.go b/src/go/pkg/netipc/transport/posix/uds_bind_other.go new file mode 100644 index 00000000..c344635e --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/uds_bind_other.go @@ -0,0 +1,19 @@ +//go:build unix && !darwin + +package posix + +import "syscall" + +// bindOwnerOnly binds fd to path as owner-only (0600, narrowed by the umask). +// Linux and FreeBSD give the socket file the mode of the unbound socket, so +// fchmod() before bind() sets it without a post-bind window and without +// touching the process-global umask. +func bindOwnerOnly(fd int, _, _ string, path string) error { + if err := syscall.Fchmod(fd, 0o600); err != nil { + return wrapErr(ErrSocket, "fchmod: "+err.Error()) + } + if err := syscall.Bind(fd, &syscall.SockaddrUnix{Name: path}); err != nil { + return wrapErr(ErrSocket, "bind: "+err.Error()) + } + return nil +} diff --git a/src/go/pkg/netipc/transport/posix/uds_cloexec_accept4.go b/src/go/pkg/netipc/transport/posix/uds_cloexec_accept4.go new file mode 100644 index 00000000..cced202a --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/uds_cloexec_accept4.go @@ -0,0 +1,24 @@ +//go:build linux || freebsd + +package posix + +import "syscall" + +// Sockets must not leak into children a consumer forks and execs: an +// inherited session socket keeps the peer from ever seeing EOF, which is the +// dead-peer signal. + +func udsSocket() (int, error) { + return syscall.Socket(syscall.AF_UNIX, sockType|syscall.SOCK_CLOEXEC, 0) +} + +// prepareListener leaves the listener blocking: accept4 sets the flag +// atomically. +func prepareListener(int) error { + return nil +} + +func acceptCloexec(fd int) (int, error) { + nfd, _, err := syscall.Accept4(fd, syscall.SOCK_CLOEXEC) + return nfd, err +} diff --git a/src/go/pkg/netipc/transport/posix/uds_cloexec_other.go b/src/go/pkg/netipc/transport/posix/uds_cloexec_other.go new file mode 100644 index 00000000..5c16d6ac --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/uds_cloexec_other.go @@ -0,0 +1,70 @@ +//go:build unix && !linux && !freebsd + +package posix + +import ( + "syscall" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/syspoll" +) + +// Sockets must not leak into children a consumer forks and execs: an +// inherited session socket keeps the peer from ever seeing EOF, which is the +// dead-peer signal. Without SOCK_CLOEXEC the flag is set right after creation; +// ForkLock keeps Go's own fork+exec out of that window. + +func udsSocket() (int, error) { + syscall.ForkLock.RLock() + defer syscall.ForkLock.RUnlock() + fd, err := syscall.Socket(syscall.AF_UNIX, sockType, 0) + if err == nil { + syscall.CloseOnExec(fd) + setNoSigpipe(fd) + } + return fd, err +} + +// prepareListener makes the listener non-blocking, so acceptCloexec can hold +// ForkLock across accept and the close-on-exec flag without blocking in it. +func prepareListener(fd int) error { + return syscall.SetNonblock(fd, true) +} + +// acceptCloexec accepts under ForkLock, as Go's net package does: a fork +// between accept and the close-on-exec flag would leak the connection into +// the child. The listener is non-blocking, so the wait for a client polls +// without the lock. +func acceptCloexec(fd int) (int, error) { + for { + syscall.ForkLock.RLock() + nfd, _, err := syscall.Accept(fd) + if err == nil { + syscall.CloseOnExec(nfd) + } + syscall.ForkLock.RUnlock() + + switch { + case err == nil: + // A BSD socket inherits O_NONBLOCK from its listener; sessions + // expect a blocking descriptor. + if err := syscall.SetNonblock(nfd, false); err != nil { + _ = syscall.Close(nfd) + return -1, err + } + setNoSigpipe(nfd) + return nfd, nil + case err == syscall.EINTR: + continue + case err != syscall.EAGAIN && err != syscall.EWOULDBLOCK: + return -1, err + } + + pfd := syspoll.PollFd{Fd: int32(fd), Events: syspoll.In} // #nosec G115 -- fd is an open descriptor. + if _, errno := syspoll.Poll(&pfd, -1); errno != 0 && errno != syscall.EINTR { + return -1, errno + } + if pfd.Revents&syspoll.Nval != 0 { + return -1, syscall.EBADF + } + } +} diff --git a/src/go/pkg/netipc/transport/posix/uds_handshake.go b/src/go/pkg/netipc/transport/posix/uds_handshake.go index e678cd9c..fb870a8d 100644 --- a/src/go/pkg/netipc/transport/posix/uds_handshake.go +++ b/src/go/pkg/netipc/transport/posix/uds_handshake.go @@ -3,8 +3,10 @@ package posix import ( + "errors" "fmt" "syscall" + "time" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/internal/framing" @@ -22,21 +24,57 @@ func helloAckLayoutIncompatible(buf []byte) bool { return framing.HelloAckLayoutIncompatible(buf) } -func connectAndHandshake(fd int, path string, config *ClientConfig) (*Session, error) { +// connectSliceMs is the longest single connect wait, so an abort ends a +// connect promptly. +const connectSliceMs = 100 + +// connectBounded connects fd to path. A blocking AF_UNIX connect waits while +// the listener's backlog is full. On Linux SO_SNDTIMEO bounds that wait and +// the connect fails with EAGAIN when it expires; waiting in slices lets an +// abort end it. Other systems refuse a full backlog at once. +func connectBounded(fd int, path string, wait receiveWait) error { sa := &syscall.SockaddrUnix{Name: path} - if err := syscall.Connect(fd, sa); err != nil { - return nil, wrapErr(ErrConnect, err.Error()) + defer func() { + // Session sends block without a timeout. + _ = syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_SNDTIMEO, &syscall.Timeval{}) + }() + for { + sliceMs, err := wait.waitMs() + if err != nil { + return err + } + sliceMs = min(sliceMs, connectSliceMs) + tv := syscall.NsecToTimeval(int64(sliceMs) * int64(time.Millisecond)) + _ = syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_SNDTIMEO, &tv) + + err = syscall.Connect(fd, sa) + switch err { + case nil, syscall.EISCONN: + return nil + case syscall.EAGAIN, syscall.EINTR, syscall.EALREADY: + continue + default: + return wrapErr(ErrConnect, err.Error()) + } } +} - pktSize := config.PacketSize - if pktSize == 0 { - pktSize = detectPacketSize(fd) +func connectAndHandshake(fd int, path string, config *ClientConfig, wait receiveWait) (*Session, error) { + if err := connectBounded(fd, path, wait); err != nil { + return nil, err } + pktSize := packetSizeLimit(fd, config.PacketSize) + + // A server that never answers must not hold the client past its + // deadline; the wait's own error is the result, not a receive failure. + var waitErr error + ack, err := framing.ClientHandshake(framing.ClientHandshakeConfig{ Hello: framing.HelloConfig{ SupportedProfiles: config.SupportedProfiles, PreferredProfiles: config.PreferredProfiles, + PlatformProfiles: platformProfiles, MaxRequestPayloadBytes: applyDefault(config.MaxRequestPayloadBytes, protocol.MaxPayloadDefault), MaxRequestBatchItems: applyDefault(config.MaxRequestBatchItems, defaultBatchItems), MaxResponsePayloadBytes: applyDefault(config.MaxResponsePayloadBytes, protocol.MaxPayloadDefault), @@ -45,6 +83,9 @@ func connectAndHandshake(fd int, path string, config *ClientConfig) (*Session, e PacketSize: pktSize, }, Send: func(pkt []byte) error { + if streamTransport { + return streamSendAll(fd, pkt, nil) + } n, err := syscall.SendmsgN(fd, pkt, nil, nil, 0) if err != nil { return err @@ -54,17 +95,34 @@ func connectAndHandshake(fd int, path string, config *ClientConfig) (*Session, e } return nil }, - Recv: func(dst []byte) (int, error) { return rawRecv(fd, dst) }, + Recv: func(dst []byte) (int, error) { + if streamTransport { + // A packet cut short is a protocol error, not a receive + // failure, as on SEQPACKET. + n, err := streamRecvPacket(fd, dst, 0, &wait) + if errors.Is(err, ErrTimeout) || errors.Is(err, ErrAborted) || errors.Is(err, ErrProtocol) { + waitErr = err + } + return n, err + } + if err := pollReadableForReceive(fd, wait); err != nil { + waitErr = err + return 0, err + } + return rawRecv(fd, dst) + }, StatusError: helloAckStatusError, ErrSend: func(msg string) error { return wrapErr(ErrSend, msg) }, ErrRecv: func(msg string) error { return wrapErr(ErrRecv, msg) }, ErrProtocol: func(msg string) error { return wrapErr(ErrProtocol, msg) }, ErrIncompatible: func(msg string) error { return wrapErr(ErrIncompatible, msg) }, }) + if waitErr != nil { + return nil, waitErr + } if err != nil { return nil, err } - return &Session{ fd: fd, role: RoleClient, @@ -97,11 +155,15 @@ func helloAckStatusError(status uint16) error { } func serverHandshake(fd int, config *ServerConfig, sessionID uint64) (*Session, error) { - serverPktSize := config.PacketSize - if serverPktSize == 0 { - serverPktSize = detectPacketSize(fd) - } + return serverHandshakeWait(fd, config, sessionID, newReceiveWait(0, nil), nil) +} +func serverHandshakeWait(fd int, config *ServerConfig, sessionID uint64, wait receiveWait, prepare ProfilePrepare) (*Session, error) { + serverPktSize := packetSizeLimit(fd, config.PacketSize) + + // The wait's own timeout or abort is returned as is, as in C and Rust; + // a stream HELLO cut short is a protocol error, as on SEQPACKET. + var waitErr error sRespPay := applyDefault(config.MaxResponsePayloadBytes, protocol.MaxPayloadDefault) sReqPay := applyDefault(config.MaxRequestPayloadBytes, protocol.MaxPayloadDefault) ack, err := framing.ServerHandshake(framing.ServerHandshakeConfig{ @@ -112,9 +174,18 @@ func serverHandshake(fd int, config *ServerConfig, sessionID uint64) (*Session, SupportedProfiles: config.SupportedProfiles, PreferredProfiles: config.PreferredProfiles, AuthToken: config.AuthToken, + PlatformProfiles: platformProfiles, + }, + SessionID: sessionID, + Prepare: prepare, + Recv: func(dst []byte) (int, error) { + n, err := serverHandshakeRecv(fd, dst, &wait) + if errors.Is(err, ErrTimeout) || errors.Is(err, ErrAborted) || + (streamTransport && errors.Is(err, ErrProtocol)) { + waitErr = err + } + return n, err }, - SessionID: sessionID, - Recv: func(dst []byte) (int, error) { return rawRecv(fd, dst) }, SendAck: func(status uint16, ack protocol.HelloAck) error { return sendHelloAck(fd, status, ack) }, StatusError: helloAckStatusError, ErrRecv: func(msg string) error { return wrapErr(ErrRecv, msg) }, @@ -122,6 +193,9 @@ func serverHandshake(fd int, config *ServerConfig, sessionID uint64) (*Session, ErrProtocol: func(msg string) error { return wrapErr(ErrProtocol, msg) }, ErrIncompatible: func(msg string) error { return wrapErr(ErrIncompatible, msg) }, }) + if waitErr != nil { + return nil, waitErr + } if err != nil { return nil, err } @@ -140,8 +214,18 @@ func serverHandshake(fd int, config *ServerConfig, sessionID uint64) (*Session, }, nil } +func serverHandshakeRecv(fd int, dst []byte, wait *receiveWait) (int, error) { + if streamTransport { + return streamRecvPacket(fd, dst, 0, wait) + } + return rawRecvWithTimeout(fd, dst, *wait) +} + func sendHelloAck(fd int, status uint16, ack protocol.HelloAck) error { pkt := framing.BuildHelloAckPacket(status, ack) + if streamTransport { + return streamSendAll(fd, pkt[:], nil) + } sn, err := syscall.SendmsgN(fd, pkt[:], nil, nil, 0) if err != nil { diff --git a/src/go/pkg/netipc/transport/posix/uds_listener.go b/src/go/pkg/netipc/transport/posix/uds_listener.go index e392b69c..52f8febb 100644 --- a/src/go/pkg/netipc/transport/posix/uds_listener.go +++ b/src/go/pkg/netipc/transport/posix/uds_listener.go @@ -7,39 +7,64 @@ import ( "sync" "sync/atomic" "syscall" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" ) -// Listener is a listening UDS SEQPACKET endpoint. +// Listener is a listening UDS endpoint. type Listener struct { mu sync.Mutex + stopMu sync.Mutex fd int config ServerConfig path string nextSessionID atomic.Uint64 + // lock holds {serviceName}.lock flock'ed until Close. + lock *os.File } +// listenHook, when set by tests, runs inside Listen between bind() and +// listen(), with the socket path. +var listenHook atomic.Pointer[func(path string)] + // Listen creates a listener on {runDir}/{serviceName}.sock. -// Performs stale endpoint recovery. +// It first takes an exclusive flock on {runDir}/{serviceName}.lock, held +// until Close; a held lock means another server owns the endpoint +// (ErrAddrInUse). With the lock held, an existing socket file no live +// server accepts on is stale and is replaced. func Listen(runDir, serviceName string, config ServerConfig) (*Listener, error) { path, err := buildSocketPath(runDir, serviceName) if err != nil { return nil, err } + lock, err := acquireEndpointLock(runDir, serviceName) + if err != nil { + return nil, err + } + + // The probe still guards against a live server that takes no lock (an + // older library version). stale := checkAndRecoverStale(path) if stale == staleLiveServer { + releaseEndpointLock(lock) return nil, ErrAddrInUse } - fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_SEQPACKET, 0) + fd, err := udsSocket() if err != nil { + releaseEndpointLock(lock) return nil, wrapErr(ErrSocket, err.Error()) } - sa := &syscall.SockaddrUnix{Name: path} - if err := syscall.Bind(fd, sa); err != nil { + if err := bindOwnerOnly(fd, runDir, serviceName, path); err != nil { _ = syscall.Close(fd) - return nil, wrapErr(ErrSocket, "bind: "+err.Error()) + releaseEndpointLock(lock) + return nil, err + } + + if hook := listenHook.Load(); hook != nil { + (*hook)(path) } backlog := config.Backlog @@ -50,13 +75,21 @@ func Listen(runDir, serviceName string, config ServerConfig) (*Listener, error) if err := syscall.Listen(fd, backlog); err != nil { _ = syscall.Close(fd) _ = os.Remove(path) + releaseEndpointLock(lock) return nil, wrapErr(ErrSocket, "listen: "+err.Error()) } + if err := prepareListener(fd); err != nil { + _ = syscall.Close(fd) + _ = os.Remove(path) + releaseEndpointLock(lock) + return nil, wrapErr(ErrSocket, "listener flags: "+err.Error()) + } return &Listener{ fd: fd, config: config, path: path, + lock: lock, }, nil } @@ -86,8 +119,26 @@ func (l *Listener) Accept() (*Session, error) { } // AcceptWithConfig accepts one client connection using a caller-provided -// per-session server config and session ID. +// per-session server config and session ID. The handshake waits at most +// protocol.ServerHandshakeTimeoutMs for HELLO; servers that must not block +// their accept loop use AcceptPending and PendingConn.Handshake instead. func (l *Listener) AcceptWithConfig(sessionID uint64, config ServerConfig) (*Session, error) { + pending, err := l.AcceptPending() + if err != nil { + return nil, err + } + return pending.Handshake(sessionID, config, protocol.ServerHandshakeTimeoutMs, nil) +} + +// PendingConn is an accepted connection whose handshake has not run yet. +// Splitting accept from handshake lets a server bound the handshake and run +// it outside its accept loop. +type PendingConn struct { + fd int +} + +// AcceptPending accepts one connection without reading HELLO. +func (l *Listener) AcceptPending() (*PendingConn, error) { l.mu.Lock() fd := l.fd l.mu.Unlock() @@ -95,21 +146,90 @@ func (l *Listener) AcceptWithConfig(sessionID uint64, config ServerConfig) (*Ses return nil, wrapErr(ErrAccept, "listener closed") } - nfd, _, err := syscall.Accept(fd) + nfd, err := acceptCloexec(fd) if err != nil { return nil, wrapErr(ErrAccept, err.Error()) } + return &PendingConn{fd: nfd}, nil +} + +// Fd returns the connection's file descriptor, or -1 once consumed. +func (p *PendingConn) Fd() int { + return p.fd +} + +// Handshake reads HELLO and answers it. timeoutMs bounds the wait for HELLO +// (0 = no deadline) and abortCh, when non-nil, cancels it. The connection is +// consumed: on success the Session owns the fd, on failure it is closed. +func (p *PendingConn) Handshake( + sessionID uint64, + config ServerConfig, + timeoutMs uint32, + abortCh <-chan struct{}, +) (*Session, error) { + return p.HandshakePrepared(sessionID, config, timeoutMs, abortCh, nil) +} + +// ProfilePrepare sets up a selected profile's resources (an SHM region) +// during the server handshake, once the payload limits are agreed and +// before HELLO_ACK is sent. Returning false drops that profile and the +// handshake selects again; with no profile left the client is rejected as +// unsupported. +type ProfilePrepare func(profile, maxRequestPayloadBytes, maxResponsePayloadBytes uint32) bool + +// HandshakePrepared is Handshake with a prepare step; prepare may be nil. +func (p *PendingConn) HandshakePrepared( + sessionID uint64, + config ServerConfig, + timeoutMs uint32, + abortCh <-chan struct{}, + prepare ProfilePrepare, +) (*Session, error) { + fd := p.fd + if fd < 0 { + return nil, wrapErr(ErrAccept, "connection already consumed") + } + p.fd = -1 - session, herr := serverHandshake(nfd, &config, sessionID) - if herr != nil { - _ = syscall.Close(nfd) - return nil, herr + session, err := serverHandshakeWait(fd, &config, sessionID, newReceiveWait(timeoutMs, abortCh), prepare) + if err != nil { + _ = syscall.Close(fd) + return nil, err } return session, nil } -// Close closes the listener, stops accepting, and unlinks the socket file. +// Close drops a connection that will not be handshaken. +func (p *PendingConn) Close() { + if p.fd >= 0 { + _ = syscall.Close(p.fd) + p.fd = -1 + } +} + +// Close stops listening if it still is, then releases the endpoint lock. func (l *Listener) Close() { + l.StopListening() + + l.mu.Lock() + lock := l.lock + l.lock = nil + l.mu.Unlock() + // Release the endpoint only after its socket file is gone. + if lock != nil { + releaseEndpointLock(lock) + } +} + +// StopListening closes the listening socket and removes the socket file, +// keeping the endpoint lock until Close, so no other server takes the +// endpoint while this one still serves its sessions. Idempotent. +func (l *Listener) StopListening() { + // A concurrent caller waits for the unlink, so Close cannot release the + // endpoint while another Close is still removing the socket file. + l.stopMu.Lock() + defer l.stopMu.Unlock() + l.mu.Lock() fd := l.fd path := l.path diff --git a/src/go/pkg/netipc/transport/posix/uds_more_edge_test.go b/src/go/pkg/netipc/transport/posix/uds_more_edge_test.go index 91f471e6..a808a88c 100644 --- a/src/go/pkg/netipc/transport/posix/uds_more_edge_test.go +++ b/src/go/pkg/netipc/transport/posix/uds_more_edge_test.go @@ -7,12 +7,14 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "strings" "syscall" "testing" "time" + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/monotime" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" ) @@ -56,6 +58,15 @@ func rawSendPacket(fd int, pkt []byte) error { return nil } +// endStreamWrites: on a stream a short or over-long packet is only visible +// once the peer stops sending, so end the peer's writes there; the receiver +// then sees EOF where a SEQPACKET receiver sees the packet end. +func endStreamWrites(fd int) { + if streamTransport { + _ = syscall.Shutdown(fd, syscall.SHUT_WR) + } +} + func encodePacket(hdr protocol.Header, payload []byte) []byte { hdr.Magic = protocol.MagicMsg hdr.Version = protocol.Version @@ -112,6 +123,13 @@ func validHelloAckPacket(status uint16) []byte { }, payload) } +// helloAckSelecting is a valid HELLO_ACK whose selected_profile is profile. +func helloAckSelecting(profile uint32) []byte { + ack := validHelloAckPacket(protocol.StatusOK) + binary.NativeEndian.PutUint32(ack[protocol.HeaderSize+12:protocol.HeaderSize+16], profile) + return ack +} + func validHelloPacket() []byte { hello := protocol.Hello{ LayoutVersion: 1, @@ -167,7 +185,7 @@ func rawSeqpacketListener(t *testing.T, runDir, service string) (int, string) { } _ = os.Remove(path) - fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_SEQPACKET, 0) + fd, err := syscall.Socket(syscall.AF_UNIX, sockType, 0) if err != nil { t.Fatalf("socket failed: %v", err) } @@ -188,7 +206,7 @@ func rawSeqpacketListener(t *testing.T, runDir, service string) (int, string) { func rawSeqpacketConnect(t *testing.T, path string) int { t.Helper() - fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_SEQPACKET, 0) + fd, err := syscall.Socket(syscall.AF_UNIX, sockType, 0) if err != nil { t.Fatalf("socket failed: %v", err) } @@ -216,13 +234,13 @@ func TestUdsAcceptOnClosedListener(t *testing.T) { } func TestUdsListenFailsWhenRunDirMissing(t *testing.T) { - runDir := filepath.Join(t.TempDir(), "missing") + runDir := filepath.Join(shortTempDir(t), "missing") _, err := Listen(runDir, uniqueService(t), defaultServerConfig()) if !errors.Is(err, ErrSocket) { t.Fatalf("Listen(missing runDir) = %v, want ErrSocket", err) } - if !containsErrText(err, "bind:") { - t.Fatalf("Listen(missing runDir) = %v, want bind failure detail", err) + if !containsErrText(err, "lock open:") { + t.Fatalf("Listen(missing runDir) = %v, want lock open failure detail", err) } } @@ -252,7 +270,7 @@ func TestReceiveWaitTimeoutAndAbort(t *testing.T) { t.Fatalf("fresh waitMs should reset deadline: %v", err) } - expired := receiveWait{deadline: time.Now().Add(-time.Millisecond)} + expired := receiveWait{deadline: monotime.After(-time.Millisecond)} if _, err := expired.waitMs(); !errors.Is(err, ErrTimeout) { t.Fatalf("waitMs expired = %v, want ErrTimeout", err) } @@ -262,21 +280,24 @@ func TestReceiveWaitTimeoutAndAbort(t *testing.T) { t.Fatalf("waitMs infinite = %d/%v, want %d/nil", waitMs, err, receiveAbortPollMs) } - short := receiveWait{deadline: time.Now().Add(1500 * time.Microsecond)} + short := receiveWait{deadline: monotime.After(1500 * time.Microsecond)} if waitMs, err := short.waitMs(); err != nil || waitMs < 1 || waitMs > 2 { t.Fatalf("waitMs short deadline = %d/%v, want 1..2/nil", waitMs, err) } } func TestReceiveAndStaleDirectGuards(t *testing.T) { - if err := pollReadableForReceive(1<<31, newReceiveWait(1, nil)); !errors.Is(err, ErrBadParam) { + // Non-constant so 32-bit builds compile; there it wraps negative and still hits the guard. + var highFD64 int64 = 1 << 31 + highFD := int(highFD64) + if err := pollReadableForReceive(highFD, newReceiveWait(1, nil)); !errors.Is(err, ErrBadParam) { t.Fatalf("poll high fd error = %v, want ErrBadParam", err) } - if _, err := rawRecvWithTimeout(1<<31, make([]byte, 1), newReceiveWait(1, nil)); !errors.Is(err, ErrBadParam) { + if _, err := rawRecvWithTimeout(highFD, make([]byte, 1), newReceiveWait(1, nil)); !errors.Is(err, ErrBadParam) { t.Fatalf("recv high fd error = %v, want ErrBadParam", err) } - closedFD, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_SEQPACKET, 0) + closedFD, err := syscall.Socket(syscall.AF_UNIX, sockType, 0) if err != nil { t.Fatalf("socket for closed-fd receive guard: %v", err) } @@ -419,6 +440,7 @@ func TestUdsReceiveRejectsMalformedFirstPacket(t *testing.T) { if err := rawSendPacket(server.fd, tc.pkt); err != nil { t.Fatalf("rawSendPacket failed: %v", err) } + endStreamWrites(server.fd) buf := make([]byte, 4096) _, _, err := client.Receive(buf) @@ -501,13 +523,17 @@ func TestUdsReceiveRejectsMalformedChunkedBatchPayload(t *testing.T) { copy(payload[16:], []byte("payload!!")) return payload }(), - first: 8, + first: 12, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { client, server := udsSessionPair(t, defaultServerConfig(), defaultClientConfig()) + // The first packet is full, as a sender would make it: a stream + // receiver reads min(payload_len, packet_size - 32) bytes after + // its header. + client.PacketSize = uint32(protocol.HeaderSize + tc.first) client.inflightIDs[42] = struct{}{} firstPkt := encodePartialPacket(protocol.Header{ @@ -654,6 +680,8 @@ func TestUdsReceiveRejectsMalformedChunks(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { client, server := udsSessionPair(t, defaultServerConfig(), defaultClientConfig()) + // A full first packet carries the 4 bytes of "head". + client.PacketSize = protocol.HeaderSize + 4 client.inflightIDs[42] = struct{}{} firstPkt := encodePartialPacket(protocol.Header{ @@ -668,6 +696,7 @@ func TestUdsReceiveRejectsMalformedChunks(t *testing.T) { if err := rawSendPacket(server.fd, tc.second); err != nil { t.Fatalf("rawSendPacket(second) failed: %v", err) } + endStreamWrites(server.fd) buf := make([]byte, 4096) _, _, err := client.Receive(buf) @@ -724,7 +753,27 @@ func TestUdsClientHandshakeRejectsMalformedAck(t *testing.T) { return ack[:protocol.HeaderSize+8] }, want: ErrProtocol, - wantText: "ack payload truncated", + wantText: truncatedText("ack payload truncated"), + }, + { + name: "agreed packet size above proposal", + packetFn: func() []byte { + ack := validHelloAckPacket(protocol.StatusOK) + binary.NativeEndian.PutUint32(ack[protocol.HeaderSize+32:protocol.HeaderSize+36], ^uint32(0)) + return ack + }, + want: ErrProtocol, + wantText: "agreed packet_size", + }, + { + name: "agreed packet size too small", + packetFn: func() []byte { + ack := validHelloAckPacket(protocol.StatusOK) + binary.NativeEndian.PutUint32(ack[protocol.HeaderSize+32:protocol.HeaderSize+36], protocol.HeaderSize) + return ack + }, + want: ErrProtocol, + wantText: "agreed packet_size", }, { name: "bad ack payload layout", @@ -736,6 +785,30 @@ func TestUdsClientHandshakeRejectsMalformedAck(t *testing.T) { want: ErrIncompatible, wantText: "ack payload", }, + { + name: "selected profile not offered", + packetFn: func() []byte { + return helloAckSelecting(protocol.ProfileSHMHybrid) + }, + want: ErrProtocol, + wantText: "selected profile was not offered", + }, + { + name: "no selected profile", + packetFn: func() []byte { + return helloAckSelecting(0) + }, + want: ErrProtocol, + wantText: "selected profile was not offered", + }, + { + name: "two selected profiles", + packetFn: func() []byte { + return helloAckSelecting(protocol.ProfileBaseline | protocol.ProfileSHMHybrid) + }, + want: ErrProtocol, + wantText: "selected profile was not offered", + }, } for _, tc := range cases { @@ -851,7 +924,7 @@ func TestUdsServerHandshakeRejectsMalformedHello(t *testing.T) { return hello[:protocol.HeaderSize+8] }, want: ErrProtocol, - wantText: "hello payload", + wantText: truncatedText("hello payload"), }, { name: "bad hello payload layout", @@ -888,6 +961,7 @@ func TestUdsServerHandshakeRejectsMalformedHello(t *testing.T) { if err := rawSendPacket(cfd, tc.packetFn()); err != nil { t.Fatalf("rawSendPacket failed: %v", err) } + endStreamWrites(cfd) err := <-handshakeDone if err == nil { @@ -943,17 +1017,341 @@ func defaultServerConfigPtr() *ServerConfig { return &cfg } +// truncatedText is the error text for a packet shorter than its header +// declares: a SEQPACKET receiver sees the short packet and reports what it +// lacks, a stream receiver hits EOF inside the packet. +func truncatedText(seqpacket string) string { + if streamTransport { + return "truncated packet" + } + return seqpacket +} + func containsErrText(err error, want string) bool { return err != nil && want != "" && strings.Contains(err.Error(), want) } -func TestDetectPacketSizeFallbackAndSuccess(t *testing.T) { - if got := detectPacketSize(-1); got != defaultPacketSizeFallback { - t.Fatalf("detectPacketSize(-1) = %d, want fallback %d", got, defaultPacketSizeFallback) +// A server between bind() and listen() refuses connects just like a dead +// server's leftover socket file. A second server starting in that window +// must not take the endpoint over: it fails with ErrAddrInUse, and the +// first server stays reachable. +func TestListenExcludesStartingServer(t *testing.T) { + runDir := testRunDir(t) + service := uniqueService(t) + path := filepath.Join(runDir, service+".sock") + _ = os.Remove(path) + + bound := make(chan struct{}) + release := make(chan struct{}) + hook := func(p string) { + if p != path { + return + } + close(bound) + <-release } + listenHook.Store(&hook) - client, _ := udsSessionPair(t, defaultServerConfig(), defaultClientConfig()) - if got := detectPacketSize(client.fd); got == 0 { - t.Fatal("detectPacketSize(valid fd) returned 0") + type result struct { + l *Listener + err error + } + first := make(chan result, 1) + go func() { + l, err := Listen(runDir, service, defaultServerConfig()) + first <- result{l, err} + }() + select { + case <-bound: + case <-time.After(10 * time.Second): + t.Fatal("first server never bound") + } + listenHook.Store(nil) + + second, err := Listen(runDir, service, defaultServerConfig()) + if !errors.Is(err, ErrAddrInUse) { + if second != nil { + second.Close() + } + close(release) + <-first + t.Fatalf("second server error = %v, want ErrAddrInUse", err) + } + + close(release) + r := <-first + if r.err != nil { + t.Fatalf("first listen: %v", r.err) + } + + // A connect to the endpoint must reach the first server. + cfd, err := syscall.Socket(syscall.AF_UNIX, sockType, 0) + if err != nil { + t.Fatalf("socket: %v", err) + } + defer syscall.Close(cfd) + if err := syscall.Connect(cfd, &syscall.SockaddrUnix{Name: path}); err != nil { + t.Fatalf("connect to endpoint: %v", err) + } + if err := pollReadableForReceive(r.l.Fd(), newReceiveWait(10000, nil)); err != nil { + t.Fatalf("first server never received the connection: %v", err) + } + r.l.Close() + + // After the owner closed, the endpoint can be taken again. + again, err := Listen(runDir, service, defaultServerConfig()) + if err != nil { + t.Fatalf("endpoint not free after close: %v", err) + } + again.Close() +} + +// A lock file left with a mode missing owner read or write (a server that +// ran under such a umask) must not block a start, and the start restores it. +func TestListenOverLeftoverLockModes(t *testing.T) { + runDir := testRunDir(t) + for _, mode := range []os.FileMode{0o200, 0o400} { + service := uniqueService(t) + lock := filepath.Join(runDir, service+".lock") + _ = os.Remove(lock) + if err := os.WriteFile(lock, nil, 0o600); err != nil { + t.Fatalf("create lock: %v", err) + } + if err := os.Chmod(lock, mode); err != nil { + t.Fatalf("chmod lock: %v", err) + } + + first, err := Listen(runDir, service, defaultServerConfig()) + if err != nil { + t.Fatalf("listen over a %o lock: %v", mode, err) + } + info, err := os.Stat(lock) + if err != nil { + t.Errorf("stat lock after listen over %o: %v", mode, err) + } else if info.Mode().Perm() != 0o600 { + t.Errorf("lock mode after listen over %o = %v, want 0600", mode, info.Mode().Perm()) + } + if second, err := Listen(runDir, service, defaultServerConfig()); !errors.Is(err, ErrAddrInUse) { + if second != nil { + second.Close() + } + t.Errorf("second server over a %o lock: err = %v, want ErrAddrInUse", mode, err) + } + first.Close() + _ = os.Remove(lock) + } +} + +// An empty run_dir is a caller error: Go would otherwise put the socket in +// the working directory and the lock at "/.lock". +func TestEmptyRunDirRejected(t *testing.T) { + if l, err := Listen("", "go_empty_run_dir", defaultServerConfig()); !errors.Is(err, ErrBadParam) { + if l != nil { + l.Close() + } + t.Errorf("Listen with empty run_dir: err = %v, want ErrBadParam", err) + } + if s, err := Connect("", "go_empty_run_dir", defaultClientConfigPtr()); !errors.Is(err, ErrBadParam) { + if s != nil { + s.Close() + } + t.Errorf("Connect with empty run_dir: err = %v, want ErrBadParam", err) + } +} + +const lockUmaskHelperEnv = "NETIPC_LOCK_UMASK_HELPER" + +// The umask is process-wide, so the restrictive-umask case runs in a child +// test process instead of the parallel test runner. +func TestListenLockUnderRestrictiveUmask(t *testing.T) { + testRunDir(t) + for _, mask := range []string{"477", "277", "777"} { + cmd := exec.Command(os.Args[0], "-test.run=^TestLockUmaskHelperProcess$", "-test.v") + cmd.Env = append(os.Environ(), lockUmaskHelperEnv+"="+mask) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("endpoint lock under umask %s failed: %v\n%s", mask, err, out) + } + } +} + +// TestLockUmaskHelperProcess is the child entry point; it is inert unless +// spawned by TestListenLockUnderRestrictiveUmask. +func TestLockUmaskHelperProcess(t *testing.T) { + maskText := os.Getenv(lockUmaskHelperEnv) + if maskText == "" { + return + } + var mask int + if _, err := fmt.Sscanf(maskText, "%o", &mask); err != nil { + t.Fatalf("mask %q: %v", maskText, err) + } + runDir := testRunDir(t) + service := "go_lock_umask_" + maskText + lock := filepath.Join(runDir, service+".lock") + _ = os.Remove(lock) + _ = os.Remove(filepath.Join(runDir, service+".sock")) + + var published []string + var tmpMode os.FileMode + lockVisible := false + hook := func(tmp string) { + published = append(published, tmp) + if info, err := os.Stat(tmp); err == nil { + tmpMode = info.Mode().Perm() + } + _, err := os.Lstat(lock) + lockVisible = err == nil + } + lockPublishHook.Store(&hook) + + func() { + // Restore the umask before exit so files the process writes then + // (coverage data) keep their normal mode. + defer syscall.Umask(syscall.Umask(mask)) + first, err := Listen(runDir, service, defaultServerConfig()) + lockPublishHook.Store(nil) + if err != nil { + t.Fatalf("listen under umask %o: %v", mask, err) + } + if second, err := Listen(runDir, service, defaultServerConfig()); !errors.Is(err, ErrAddrInUse) { + if second != nil { + second.Close() + } + t.Errorf("second server under umask %o: err = %v, want ErrAddrInUse", mask, err) + } + first.Close() + again, err := Listen(runDir, service, defaultServerConfig()) + if err != nil { + t.Fatalf("restart under umask %o: %v", mask, err) + } + again.Close() + }() + + info, err := os.Stat(lock) + if err != nil { + t.Fatalf("lock stat: %v", err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Errorf("lock mode under umask %o = %o, want 600", mask, mode) + } + if len(published) != 1 || tmpMode != 0o600 || lockVisible { + t.Fatalf("publish: names=%v mode=%o lock visible=%v, want one name, 600, false", + published, tmpMode, lockVisible) + } + if _, err := os.Lstat(published[0]); err == nil { + t.Errorf("temporary name %s was left behind", published[0]) + } + _ = os.Remove(lock) +} + +// A child that shares the lock's open file description (as a child forked +// by another thread does until it execs) must not keep the endpoint once +// the listener closes. +func TestListenCloseReleasesLockHeldByChild(t *testing.T) { + runDir := testRunDir(t) + service := uniqueService(t) + l, err := Listen(runDir, service, defaultServerConfig()) + if err != nil { + t.Fatalf("listen: %v", err) + } + + child := exec.Command("/bin/sh", "-c", "read x") + child.ExtraFiles = []*os.File{l.lock} + gate, err := child.StdinPipe() + if err != nil { + t.Fatalf("stdin pipe: %v", err) + } + if err := child.Start(); err != nil { + t.Fatalf("start child: %v", err) + } + defer func() { + _ = gate.Close() + _ = child.Wait() + }() + + l.Close() + again, err := Listen(runDir, service, defaultServerConfig()) + if err != nil { + t.Fatalf("endpoint not free while the child holds the lock fd: %v", err) + } + again.Close() +} + +func TestListenSocketOwnerOnly(t *testing.T) { + runDir := testRunDir(t) + service := uniqueService(t) + l, err := Listen(runDir, service, defaultServerConfig()) + if err != nil { + t.Fatalf("listen: %v", err) + } + st, statErr := os.Stat(filepath.Join(runDir, service+".sock")) + l.Close() + if statErr != nil { + t.Fatalf("stat socket: %v", statErr) + } + if mode := st.Mode().Perm(); mode != 0o600 { + t.Errorf("listener socket mode = %o, want 600", mode) + } +} + +// A server whose client never sends HELLO reports the wait's own error, +// as C and Rust do, not a generic receive failure. +func TestServerHandshakeWaitKeepsTimeoutAndAbort(t *testing.T) { + aborted := make(chan struct{}) + close(aborted) + cases := []struct { + name string + wait receiveWait + want error + }{ + {"deadline", newReceiveWait(20, nil), ErrTimeout}, + {"abort", newReceiveWait(0, aborted), ErrAborted}, + } + for _, tc := range cases { + fds, err := syscall.Socketpair(syscall.AF_UNIX, sockType, 0) + if err != nil { + t.Fatalf("socketpair: %v", err) + } + _, err = serverHandshakeWait(fds[0], defaultServerConfigPtr(), 1, tc.wait, nil) + syscall.Close(fds[0]) + syscall.Close(fds[1]) + if !errors.Is(err, tc.want) { + t.Errorf("%s: %v, want %v", tc.name, err, tc.want) + } + } +} + +// A receive into a buffer too small for a header fails without reading a +// byte, so the message stays queued and the session stays usable. +func TestReceiveUndersizedBufferKeepsSession(t *testing.T) { + fds, err := syscall.Socketpair(syscall.AF_UNIX, sockType, 0) + if err != nil { + t.Fatalf("socketpair: %v", err) + } + newSession := func(fd int) *Session { + return &Session{ + fd: fd, + role: RoleServer, + PacketSize: 4096, + MaxRequestPayloadBytes: 1024, + MaxRequestBatchItems: 1, + } + } + rx, tx := newSession(fds[0]), newSession(fds[1]) + defer rx.Close() + defer tx.Close() + + payload := []byte{1, 2, 3, 4, 5, 6, 7, 8} + hdr := protocol.Header{Kind: protocol.KindRequest, Code: 1, ItemCount: 1, MessageID: 7} + if err := tx.Send(&hdr, payload); err != nil { + t.Fatalf("send: %v", err) + } + + if _, _, err := rx.Receive(make([]byte, protocol.HeaderSize-1)); !errors.Is(err, ErrBadParam) { + t.Fatalf("undersized buffer: expected ErrBadParam, got %v", err) + } + h, p, err := rx.ReceiveTimeout(make([]byte, 256), 1000, nil) + if err != nil || h.MessageID != 7 || string(p) != string(payload) { + t.Fatalf("request still received: hdr %+v payload %v err %v", h, p, err) } } diff --git a/src/go/pkg/netipc/transport/posix/uds_nosigpipe_other.go b/src/go/pkg/netipc/transport/posix/uds_nosigpipe_other.go new file mode 100644 index 00000000..e1b1f544 --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/uds_nosigpipe_other.go @@ -0,0 +1,7 @@ +//go:build unix && !darwin && !linux && !freebsd + +package posix + +// MSG_NOSIGNAL on every send covers SIGPIPE here; only macOS needs a +// per-socket option. +func setNoSigpipe(int) {} diff --git a/src/go/pkg/netipc/transport/posix/uds_packet_size_seqpacket_test.go b/src/go/pkg/netipc/transport/posix/uds_packet_size_seqpacket_test.go new file mode 100644 index 00000000..c0316ca1 --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/uds_packet_size_seqpacket_test.go @@ -0,0 +1,112 @@ +//go:build unix && !darwin && !freebsd + +package posix + +import ( + "errors" + "syscall" + "testing" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" +) + +func TestPacketSizeLimitFallbackAndClamp(t *testing.T) { + if got := packetSizeLimit(-1, 0); got != defaultPacketSizeFallback { + t.Fatalf("packetSizeLimit(-1, 0) = %d, want fallback %d", got, defaultPacketSizeFallback) + } + if got := packetSizeLimit(-1, 1000); got != 1000 { + t.Fatalf("packetSizeLimit(-1, 1000) = %d, want 1000", got) + } + if got := packetSizeLimit(-1, defaultPacketSizeFallback+1); got != defaultPacketSizeFallback { + t.Fatalf("packetSizeLimit(-1, fallback+1) = %d, want %d", got, defaultPacketSizeFallback) + } +} + +func TestPacketSizeLimitMatchesKernelDatagramLimit(t *testing.T) { + // 0 keeps the system default SO_SNDBUF. + for _, sndbuf := range []int{0, 4096, 65536} { + fds, err := syscall.Socketpair(syscall.AF_UNIX, sockType, 0) + if err != nil { + t.Fatalf("socketpair: %v", err) + } + if sndbuf != 0 { + if err := syscall.SetsockoptInt(fds[0], syscall.SOL_SOCKET, syscall.SO_SNDBUF, sndbuf); err != nil { + t.Fatalf("setsockopt(SO_SNDBUF): %v", err) + } + } + limit := packetSizeLimit(fds[0], 0) + if limit <= protocol.HeaderSize || limit > packetSizeMax { + t.Fatalf("sndbuf %d: limit %d out of range", sndbuf, limit) + } + if got := packetSizeLimit(fds[0], ^uint32(0)); got != limit { + t.Fatalf("sndbuf %d: configured max clamps to %d, want %d", sndbuf, got, limit) + } + if err := rawSendPacket(fds[0], make([]byte, limit)); err != nil { + t.Fatalf("sndbuf %d: send %d bytes: %v", sndbuf, limit, err) + } + buf := make([]byte, limit) + if n, err := rawRecv(fds[1], buf); err != nil || n != int(limit) { + t.Fatalf("sndbuf %d: recv = %d, %v", sndbuf, n, err) + } + if limit < packetSizeMax { + // Guards the SO_SNDBUF - 32 rule: one more byte must not fit. + if err := rawSendPacket(fds[0], make([]byte, limit+1)); err == nil { + t.Fatalf("sndbuf %d: %d + 1 bytes unexpectedly sent", sndbuf, limit) + } + } + syscall.Close(fds[0]) + syscall.Close(fds[1]) + } +} + +// A datagram longer than its header declares is a protocol error. The +// pending queue, filled while a send waits for buffer space, must keep the +// whole datagram so its receive rejects it as the direct receive does, +// instead of storing it cut to PacketSize and accepting it. +func TestPendingOversizedDatagramRejected(t *testing.T) { + fds, err := syscall.Socketpair(syscall.AF_UNIX, sockType, 0) + if err != nil { + t.Fatalf("socketpair: %v", err) + } + defer syscall.Close(fds[1]) + s := &Session{ + fd: fds[0], + role: RoleServer, + PacketSize: 2 * protocol.HeaderSize, + MaxRequestPayloadBytes: 1024, + MaxRequestBatchItems: 1, + } + s.initReceiveFns() + defer s.Close() + + // The header declares exactly PacketSize; 16 more bytes follow. + dgram := make([]byte, 2*protocol.HeaderSize+16) + hdr := protocol.Header{ + Magic: protocol.MagicMsg, Version: protocol.Version, + HeaderLen: protocol.HeaderSize, Kind: protocol.KindRequest, + Code: 1, ItemCount: 1, MessageID: 1, PayloadLen: protocol.HeaderSize, + } + hdr.Encode(dgram[:protocol.HeaderSize]) + rbuf := make([]byte, 256) + + if _, err := syscall.Write(fds[1], dgram); err != nil { + t.Fatalf("direct datagram: %v", err) + } + if _, _, err := s.Receive(rbuf); !errors.Is(err, ErrProtocol) { + t.Fatalf("direct receive: expected ErrProtocol, got %v", err) + } + + if _, err := syscall.Write(fds[1], dgram); err != nil { + t.Fatalf("queued datagram: %v", err) + } + eof := false + if err := s.pending.drain(s.fd, int(s.PacketSize), 4096, &eof); err != nil || eof || !s.pending.queued() { + t.Fatalf("drain: err %v eof %v queued %v", err, eof, s.pending.queued()) + } + if _, _, err := s.Receive(rbuf); !errors.Is(err, ErrProtocol) { + t.Fatalf("pending receive: expected ErrProtocol, got %v", err) + } + if s.pending.queued() { + t.Fatal("pending queue not consumed") + } +} diff --git a/src/go/pkg/netipc/transport/posix/uds_packet_size_stream_test.go b/src/go/pkg/netipc/transport/posix/uds_packet_size_stream_test.go new file mode 100644 index 00000000..418737a5 --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/uds_packet_size_stream_test.go @@ -0,0 +1,65 @@ +//go:build darwin || freebsd + +package posix + +import ( + "syscall" + "testing" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" +) + +func TestPacketSizeLimitIsFixedOnAStream(t *testing.T) { + for _, tc := range []struct{ configured, want uint32 }{ + {0, packetSizeMax}, + {1000, 1000}, + {^uint32(0), packetSizeMax}, + } { + if got := packetSizeLimit(-1, tc.configured); got != tc.want { + t.Fatalf("packetSizeLimit(-1, %d) = %d, want %d", tc.configured, got, tc.want) + } + } + + // A full packet crosses even though it is far above SO_SNDBUF. + fds, err := syscall.Socketpair(syscall.AF_UNIX, sockType, 0) + if err != nil { + t.Fatalf("socketpair: %v", err) + } + defer syscall.Close(fds[0]) + pkt := make([]byte, packetSizeMax) + for i := range pkt { + pkt[i] = 0xA5 + } + hdr := protocol.Header{ + Magic: protocol.MagicMsg, + Version: protocol.Version, + HeaderLen: protocol.HeaderSize, + Kind: protocol.KindRequest, + ItemCount: 1, + PayloadLen: packetSizeMax - protocol.HeaderSize, + } + hdr.Encode(pkt[:protocol.HeaderSize]) + + type result struct { + n int + last byte + err error + } + done := make(chan result, 1) + go func() { + defer syscall.Close(fds[1]) + buf := make([]byte, packetSizeMax) + n, err := streamRecvPacket(fds[1], buf, packetSizeMax, nil) + r := result{n: n, err: err} + if n > 0 { + r.last = buf[n-1] + } + done <- r + }() + if err := sendPacket(fds[0], pkt[:protocol.HeaderSize], pkt[protocol.HeaderSize:]); err != nil { + t.Fatalf("send: %v", err) + } + if r := <-done; r.err != nil || r.n != int(packetSizeMax) || r.last != 0xA5 { + t.Fatalf("recv = %d, last %#x, %v; want %d bytes", r.n, r.last, r.err, packetSizeMax) + } +} diff --git a/src/go/pkg/netipc/transport/posix/uds_pipeline_test.go b/src/go/pkg/netipc/transport/posix/uds_pipeline_test.go new file mode 100644 index 00000000..c5278991 --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/uds_pipeline_test.go @@ -0,0 +1,222 @@ +//go:build unix + +package posix + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "sync" + "syscall" + "testing" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" +) + +// Pipelining beyond the socket buffers. Small buffers make the deadlock +// reachable on every platform (macOS and FreeBSD 14 default to 8 KiB, FreeBSD +// 15 to 64 KiB, Linux to about 208 KiB). The client's send timeout only bounds a failing run: a +// passing run never waits on it, and the server has none, so a slow server +// cannot fail a passing run. +const ( + pipeBufCount = 16 + pipeBufSize = 16000 + pipeBufPkt = 1024 + // Flood mode: small responses keep the client's queue bound small. + pipeBufFloodResp = 4096 +) + +type smallBufMode int + +const ( + smallBufEcho smallBufMode = iota + 1 // receive one request, send its response + smallBufCloseAfterOne // receive one request, then close + smallBufFlood // receive one request, then send responses until the send fails +) + +func shrinkSocketBuffers(t *testing.T, fd int, boundSends bool) { + t.Helper() + for _, opt := range []int{syscall.SO_SNDBUF, syscall.SO_RCVBUF} { + if err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, opt, 4096); err != nil { + t.Fatalf("setsockopt %d: %v", opt, err) + } + } + if !boundSends { + return + } + tv := syscall.Timeval{Sec: 10} + if err := syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_SNDTIMEO, &tv); err != nil { + t.Fatalf("setsockopt SO_SNDTIMEO: %v", err) + } +} + +// smallBufferStart starts the server in mode and connects a client; both +// ends get small socket buffers. The returned wait group finishes with the +// server goroutine. +func smallBufferStart(t *testing.T, mode smallBufMode, maxResponse uint32) (*Session, *sync.WaitGroup) { + t.Helper() + runDir := testRunDir(t) + service := uniqueService(t) + t.Cleanup(func() { _ = os.Remove(filepath.Join(runDir, service+".sock")) }) + + sCfg := defaultServerConfig() + sCfg.PacketSize = pipeBufPkt + sCfg.MaxRequestPayloadBytes = 65536 + sCfg.MaxResponsePayloadBytes = maxResponse + listener := startListener(t, runDir, service, sCfg) + t.Cleanup(listener.Close) + acceptCh := acceptAsync(listener) + + cCfg := defaultClientConfig() + cCfg.PacketSize = pipeBufPkt + cCfg.MaxRequestPayloadBytes = 65536 + cCfg.MaxResponsePayloadBytes = maxResponse + client, err := Connect(runDir, service, &cCfg) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + t.Cleanup(client.Close) + + sr := <-acceptCh + if sr.err != nil { + t.Fatalf("Accept failed: %v", sr.err) + } + server := sr.session + shrinkSocketBuffers(t, server.Fd(), false) + shrinkSocketBuffers(t, client.Fd(), true) + + var wg sync.WaitGroup + wg.Go(func() { + defer server.Close() + count := 1 + if mode == smallBufEcho { + count = pipeBufCount + } + buf := make([]byte, pipeBufPkt) + for range count { + hdr, payload, err := server.Receive(buf) + if err != nil { + return + } + if mode == smallBufEcho { + resp := hdr + resp.Kind = protocol.KindResponse + if server.Send(&resp, payload) != nil { + return + } + } + } + if mode == smallBufFlood { + payload := make([]byte, pipeBufFloodResp) + for { + resp := protocol.Header{ + Kind: protocol.KindResponse, + Code: protocol.MethodIncrement, + ItemCount: 1, + MessageID: 1, + } + if server.Send(&resp, payload) != nil { + return + } + } + } + }) + return client, &wg +} + +func pipelinedPayload(i int) []byte { + p := make([]byte, pipeBufSize) + for j := range p { + p[j] = byte((i*7 + j) & 0xFF) + } + return p +} + +func sendPipelinedRequest(s *Session, i int) error { + hdr := protocol.Header{ + Kind: protocol.KindRequest, + Code: protocol.MethodIncrement, + ItemCount: 1, + MessageID: uint64(i + 1), + } + return s.Send(&hdr, pipelinedPayload(i)) +} + +func sendAllPipelined(s *Session) error { + for i := range pipeBufCount { + if err := sendPipelinedRequest(s, i); err != nil { + return err + } + } + return nil +} + +// A client that sends every request before reading any response must not +// deadlock with the server when the bytes in flight exceed the buffers. +func TestPipelineExceedsSocketBuffers(t *testing.T) { + client, wg := smallBufferStart(t, smallBufEcho, 65536) + + if client.HasPending() { + t.Fatal("pending data before sending") + } + if err := sendAllPipelined(client); err != nil { + t.Fatalf("pipelined send: %v", err) + } + // The buffers hold a fraction of the responses, so finishing the sends + // required reading responses into the queue. + if !client.HasPending() { + t.Fatal("no responses queued during the sends") + } + buf := make([]byte, pipeBufPkt) + for i := range pipeBufCount { + hdr, payload, err := client.Receive(buf) + if err != nil { + t.Fatalf("Receive[%d]: %v", i, err) + } + if hdr.MessageID != uint64(i+1) || !bytes.Equal(payload, pipelinedPayload(i)) { + t.Fatalf("response %d: id %d, %d bytes", i, hdr.MessageID, len(payload)) + } + } + if client.HasPending() { + t.Fatal("pending queue not drained") + } + // Closing (not only the shutdown a failed session does) wakes a server + // blocked sending to it on macOS. + client.Close() + wg.Wait() +} + +// The server closes while the pipelining client waits for buffer space: the +// send fails instead of waiting forever. +func TestPipelinePeerCloseDuringSend(t *testing.T) { + client, wg := smallBufferStart(t, smallBufCloseAfterOne, 65536) + + if err := sendAllPipelined(client); !errors.Is(err, ErrSend) { + t.Fatalf("expected ErrSend, got %v", err) + } + if len(client.inflightIDs) != 0 { + t.Fatalf("in-flight set not cleared: %d", len(client.inflightIDs)) + } + // Closing (not only the shutdown a failed session does) wakes a server + // blocked sending to it on macOS. + client.Close() + wg.Wait() +} + +// A server that sends more than the in-flight responses can occupy fails the +// client session instead of growing its queue without limit. +func TestPipelinePendingBound(t *testing.T) { + client, wg := smallBufferStart(t, smallBufFlood, pipeBufFloodResp) + + if err := sendAllPipelined(client); !errors.Is(err, ErrProtocol) { + t.Fatalf("expected ErrProtocol, got %v", err) + } + if len(client.inflightIDs) != 0 { + t.Fatalf("in-flight set not cleared: %d", len(client.inflightIDs)) + } + // Closing (not only the shutdown a failed session does) wakes a server + // blocked sending to it on macOS. + client.Close() + wg.Wait() +} diff --git a/src/go/pkg/netipc/transport/posix/uds_platform_darwin.go b/src/go/pkg/netipc/transport/posix/uds_platform_darwin.go new file mode 100644 index 00000000..80bb0571 --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/uds_platform_darwin.go @@ -0,0 +1,14 @@ +//go:build darwin + +package posix + +import "syscall" + +// darwin's syscall package has no MSG_NOSIGNAL; setNoSigpipe covers it. +const sendFlags = 0 + +// setNoSigpipe keeps a write to a closed peer from raising SIGPIPE in an +// embedding process (SO_NOSIGPIPE is macOS's documented way). +func setNoSigpipe(fd int) { + _ = syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_NOSIGPIPE, 1) +} diff --git a/src/go/pkg/netipc/transport/posix/uds_platform_other.go b/src/go/pkg/netipc/transport/posix/uds_platform_other.go new file mode 100644 index 00000000..7c2e6fb3 --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/uds_platform_other.go @@ -0,0 +1,7 @@ +//go:build unix && !darwin + +package posix + +import "syscall" + +const sendFlags = syscall.MSG_NOSIGNAL diff --git a/src/go/pkg/netipc/transport/posix/uds_receive.go b/src/go/pkg/netipc/transport/posix/uds_receive.go index 0ce645ee..e903cd1b 100644 --- a/src/go/pkg/netipc/transport/posix/uds_receive.go +++ b/src/go/pkg/netipc/transport/posix/uds_receive.go @@ -6,40 +6,28 @@ import ( "errors" "syscall" "time" - "unsafe" + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/monotime" + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/syspoll" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/internal/framing" ) const receiveAbortPollMs = 100 -const ( - _pollIn = 0x0001 - _pollErr = 0x0008 - _pollHup = 0x0010 - _pollNval = 0x0020 -) - type receiveWait struct { infinite bool - deadline time.Time + deadline int64 // monotime nanoseconds abortCh <-chan struct{} } -type receivePollFd struct { - fd int32 - events int16 - revents int16 -} - func newReceiveWait(timeoutMs uint32, abortCh <-chan struct{}) receiveWait { w := receiveWait{ infinite: timeoutMs == 0, abortCh: abortCh, } if !w.infinite { - w.deadline = time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) + w.deadline = monotime.After(time.Duration(timeoutMs) * time.Millisecond) } return w } @@ -58,7 +46,7 @@ func (w receiveWait) waitMs() (int, error) { return waitMs, nil } - remaining := time.Until(w.deadline) + remaining := time.Duration(w.deadline - monotime.Now()) if remaining <= 0 { return 0, ErrTimeout } @@ -83,17 +71,11 @@ func pollReadableForReceive(fd int, wait receiveWait) error { return err } - pfd := receivePollFd{ - fd: int32(fd), // #nosec G115 -- fd is checked against int32 range above. - events: _pollIn, + pfd := syspoll.PollFd{ + Fd: int32(fd), // #nosec G115 -- fd is checked against int32 range above. + Events: syspoll.In, } - r, _, errno := syscall.Syscall( - syscall.SYS_POLL, - uintptr(unsafe.Pointer(&pfd)), // #nosec G103 -- raw poll syscall requires a pollfd pointer. - 1, - uintptr(waitMs), - ) - n := int(r) + n, errno := syspoll.Poll(&pfd, waitMs) if n < 0 { if errno == syscall.EINTR { continue @@ -103,10 +85,10 @@ func pollReadableForReceive(fd int, wait receiveWait) error { if n == 0 { continue } - if pfd.revents&_pollIn != 0 { + if pfd.Revents&syspoll.In != 0 { return nil } - if pfd.revents&(_pollErr|_pollHup|_pollNval) != 0 { + if pfd.Revents&(syspoll.Err|syspoll.Hup|syspoll.Nval) != 0 { return nil } } @@ -131,13 +113,103 @@ func (s *Session) ReceiveTimeout(buf []byte, timeoutMs uint32, abortCh <-chan st if s.fd < 0 { return protocol.Header{}, nil, wrapErr(ErrBadParam, "session closed") } + // Rejected before any byte is read, so the session stays usable. + if len(buf) < protocol.HeaderSize { + return protocol.Header{}, nil, wrapErr(ErrBadParam, "receive buffer too small") + } + + s.initReceiveFns() + s.recvControl = timeoutMs != 0 || abortCh != nil + s.recvWait = newReceiveWait(timeoutMs, abortCh) + if streamTransport { + return s.receiveStream(buf) + } + return s.receiveFraming(buf, s.recvPacketFn, false) +} + +// initReceiveFns builds the session's receive closures once. They read the +// call's state from the session (recvControl, recvWait, streamFirst). +func (s *Session) initReceiveFns() { + if s.recvPacketFn != nil { + return + } + s.recvPacketFn = func(dst []byte) (int, error) { + if s.pending.queued() { + return s.pending.popPacket(dst), nil + } + if s.recvControl { + return rawRecvWithTimeout(s.fd, dst, s.recvWait) + } + return rawRecv(s.fd, dst) + } + s.recvStreamFn = func(dst []byte) (int, error) { + var waitp *receiveWait + if s.recvControl { + waitp = &s.recvWait + } + maxPayload, maxItems := s.MaxResponsePayloadBytes, s.MaxResponseBatchItems + if s.role == RoleServer { + maxPayload, maxItems = s.MaxRequestPayloadBytes, s.MaxRequestBatchItems + } + n, err := streamRecvSessionPacket(s.fd, &s.pending, dst, s.streamFirst, s.PacketSize, maxPayload, maxItems, waitp, &s.streamConsumed) + s.streamFirst = false + return n, err + } + s.failInflights = s.failAllInflight +} + +// errRecvFailed is what every receive returns after a stream receive failed +// inside a message. +var errRecvFailed = wrapErr(ErrRecv, "an earlier receive failed inside a message") + +// receiveStream receives on a stream socket. A failure before the first byte +// (a timeout or abort, also one that fires after the wait below) leaves the +// stream on a packet boundary. A failure after it does not: the rest of the +// message is still queued, so every later receive fails. +// The session records that instead of shutting the read side, whose effect +// differs by OS (FreeBSD 15 fails the peer's sends, FreeBSD 14 still returns +// queued bytes). Sends stay valid; they always write whole messages. +func (s *Session) receiveStream(buf []byte) (protocol.Header, []byte, error) { + if s.recvFailed { + return protocol.Header{}, nil, errRecvFailed + } + if s.recvControl && !s.pending.queued() { + if err := pollReadableForReceive(s.fd, s.recvWait); err != nil { + return protocol.Header{}, nil, err + } + } + s.streamFirst = true + s.streamConsumed = false + hdr, payload, err := s.receiveFraming(buf, s.recvStreamFn, true) + if err != nil && s.streamConsumed { + s.recvFailed = true + // Queued bytes are part of the abandoned stream too. + s.pending = pendingQueue{} + } + return hdr, payload, err +} + +func propagateWaitError(err error) bool { + return errors.Is(err, ErrTimeout) || errors.Is(err, ErrAborted) +} - recv := func(dst []byte) (int, error) { return rawRecv(s.fd, dst) } - if timeoutMs != 0 || abortCh != nil { - wait := newReceiveWait(timeoutMs, abortCh) - recv = func(dst []byte) (int, error) { return rawRecvWithTimeout(s.fd, dst, wait) } +func propagateStreamError(err error) bool { + return propagateWaitError(err) || errors.Is(err, ErrChunk) +} + +// propagateRecvError picks a top-level function, so a receive allocates no +// closure for it. +func propagateRecvError(stream bool) func(error) bool { + if stream { + return propagateStreamError } + return propagateWaitError +} +// receiveFraming runs the shared message framing over recv. A stream's +// continuation errors keep their class (a truncated chunk is ErrChunk), as +// a SEQPACKET continuation's framing errors do. +func (s *Session) receiveFraming(buf []byte, recv func([]byte) (int, error), stream bool) (protocol.Header, []byte, error) { return framing.SessionReceive(framing.SessionReceiveConfig{ RoleServer: s.role == RoleServer, PacketSize: s.PacketSize, @@ -151,14 +223,12 @@ func (s *Session) ReceiveTimeout(buf []byte, timeoutMs uint32, abortCh <-chan st Recv: recv, EnsurePacketScratch: ensureScratchBuf, IsRecvDisconnect: func(err error) bool { return errors.Is(err, ErrRecv) }, - PropagateRecvError: func(err error) bool { - return errors.Is(err, ErrTimeout) || errors.Is(err, ErrAborted) - }, - FailAllInflight: s.failAllInflight, - ErrLimitExceeded: func(msg string) error { return wrapErr(ErrLimitExceeded, msg) }, - ErrProtocol: func(msg string) error { return wrapErr(ErrProtocol, msg) }, - ErrChunk: func(msg string) error { return wrapErr(ErrChunk, msg) }, - ErrUnknownMsgID: func(msg string) error { return wrapErr(ErrUnknownMsgID, msg) }, - ErrRecv: func(msg string) error { return wrapErr(ErrRecv, msg) }, + PropagateRecvError: propagateRecvError(stream), + FailAllInflight: s.failInflights, + ErrLimitExceeded: func(msg string) error { return wrapErr(ErrLimitExceeded, msg) }, + ErrProtocol: func(msg string) error { return wrapErr(ErrProtocol, msg) }, + ErrChunk: func(msg string) error { return wrapErr(ErrChunk, msg) }, + ErrUnknownMsgID: func(msg string) error { return wrapErr(ErrUnknownMsgID, msg) }, + ErrRecv: func(msg string) error { return wrapErr(ErrRecv, msg) }, }, buf) } diff --git a/src/go/pkg/netipc/transport/posix/uds_send.go b/src/go/pkg/netipc/transport/posix/uds_send.go index 31edc9a2..77dfa9ca 100644 --- a/src/go/pkg/netipc/transport/posix/uds_send.go +++ b/src/go/pkg/netipc/transport/posix/uds_send.go @@ -4,6 +4,12 @@ package posix import ( "errors" + "fmt" + "math" + "syscall" + "unsafe" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/syspoll" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/internal/framing" @@ -14,10 +20,39 @@ import ( // HeaderLen/PayloadLen. If the total message exceeds PacketSize, // it is chunked transparently. func (s *Session) Send(hdr *protocol.Header, payload []byte) error { + return s.SendTimeout(hdr, payload, 0, nil) +} + +// SendTimeout is Send with an optional deadline and abort channel for a +// client session. timeoutMs == 0 waits without a deadline. While the socket +// lacks buffer space the send reads the peer's bytes as a pipelined send +// does. If the deadline passes or abortCh is closed before the message is +// sent, it returns ErrTimeout or ErrAborted and shuts the session down, +// since part of the message may be on the wire. +func (s *Session) SendTimeout(hdr *protocol.Header, payload []byte, timeoutMs uint32, abortCh <-chan struct{}) error { if s.fd < 0 { return wrapErr(ErrBadParam, "session closed") } + // A client send with a deadline or abort, or with another request in + // flight (a pipelining client can deadlock with a server blocked sending + // to it), waits in the progress loop; anything else keeps the plain + // blocking send. + s.sendBounded = s.role == RoleClient && (timeoutMs != 0 || abortCh != nil) + if s.sendBounded { + s.sendWait = newReceiveWait(timeoutMs, abortCh) + } + inflight := len(s.inflightIDs) + if s.role == RoleClient && hdr.Kind == protocol.KindRequest { + inflight++ + } + s.progressBound = 0 + if s.role == RoleClient && (inflight > 1 || s.sendBounded) { + // A bounded send of an untracked message can run with nothing in + // flight; it still gets one response's room. + s.progressBound = pendingBound(s.MaxResponsePayloadBytes, s.PacketSize, max(inflight, 1)) + } + return framing.SessionSend(framing.SessionSendConfig{ RoleClient: s.role == RoleClient, PacketSize: s.PacketSize, @@ -27,15 +62,161 @@ func (s *Session) Send(hdr *protocol.Header, payload []byte) error { SendFirstPacket: func(packetHdr *protocol.Header, packetPayload []byte, _ int) error { var hdrBuf [protocol.HeaderSize]byte packetHdr.Encode(hdrBuf[:]) - return rawSendIov(s.fd, hdrBuf[:], packetPayload) + return s.sendSessionPacket(hdrBuf[:], packetPayload) }, SendChunk: func(chk protocol.ChunkHeader, chunkPayload []byte) error { var chkBuf [protocol.HeaderSize]byte chk.Encode(chkBuf[:]) - return rawSendIov(s.fd, chkBuf[:], chunkPayload) + return s.sendSessionPacket(chkBuf[:], chunkPayload) }, ErrLimitExceeded: func(msg string) error { return wrapErr(ErrLimitExceeded, msg) }, ErrDuplicateMsgID: func(msg string) error { return wrapErr(ErrDuplicateMsgID, msg) }, ErrBadParam: func(msg string) error { return wrapErr(ErrBadParam, msg) }, }, hdr, payload) } + +// pendingBound is the wire bytes inflight responses can occupy: the pending +// queue bound. +// It saturates at math.MaxInt (32-bit targets), as C and Rust do. +func pendingBound(maxResponsePayload, packetSize uint32, inflight int) int { + payload := uint64(maxResponsePayload) + budget := max(uint64(packetSize), protocol.HeaderSize+1) - protocol.HeaderSize + packets := uint64(1) + if payload > 0 { + packets = 1 + (payload-1)/budget + } + // payload and packets are below 2^32, so the per-response size fits. + perResponse := payload + packets*uint64(protocol.HeaderSize+pendingRecordLen()) + if perResponse > uint64(math.MaxInt)/uint64(inflight) { + return math.MaxInt + } + return int(perResponse * uint64(inflight)) // #nosec G115 -- checked against math.MaxInt above. +} + +func (s *Session) sendSessionPacket(hdr, payload []byte) error { + if s.progressBound == 0 { + return sendPacket(s.fd, hdr, payload) + } + // macOS ignores MSG_DONTWAIT on a stream send that lacks buffer space + // and blocks, so on the stream baseline (macOS, FreeBSD) the descriptor + // is made non-blocking for the send instead. Sessions are blocking + // otherwise. + if streamTransport { + if err := syscall.SetNonblock(s.fd, true); err != nil { + return wrapErr(ErrSend, err.Error()) + } + } + err := s.sendWithProgress(hdr, payload) + if streamTransport { + // A descriptor left non-blocking would break later blocking calls; + // ErrSend also fails every in-flight request. + if rerr := syscall.SetNonblock(s.fd, false); rerr != nil { + _ = syscall.Shutdown(s.fd, syscall.SHUT_RDWR) + return wrapErr(ErrSend, rerr.Error()) + } + } + if errors.Is(err, ErrProtocol) || errors.Is(err, ErrTimeout) || errors.Is(err, ErrAborted) { + // Inbound bytes were dropped, or the message stopped part way: the + // session cannot continue. + _ = syscall.Shutdown(s.fd, syscall.SHUT_RDWR) + s.failAllInflight() + } + return err +} + +// sendWithProgress sends one packet without blocking; while the socket is +// full, it reads what the peer sent into the pending queue, so both peers +// keep making progress. Without sendWait it waits as long as a blocking send +// would; with one it ends at the deadline or abort. +func (s *Session) sendWithProgress(hdr, payload []byte) error { + const maxInt32 = 1<<31 - 1 + if s.fd < 0 || s.fd > maxInt32 { + return wrapErr(ErrBadParam, "fd out of range") + } + parts := [2][]byte{hdr, payload} + first := 0 + // After a failed stream receive the inbound bytes belong to an abandoned + // message: never queue them. + eof := s.recvFailed + for { + var iov [2]syscall.Iovec + msg := syscall.Msghdr{Iov: &iov[0]} + count := 0 + for _, part := range parts[first:] { + if len(part) > 0 { + iov[count].Base = unsafe.SliceData(part) // #nosec G103 -- sendmsg iovec needs the backing byte-slice pointer. + iov[count].SetLen(len(part)) + count++ + } + } + // Msghdr.Iovlen differs in type across platforms. + msg.Iovlen = 1 + if count == 2 { + msg.Iovlen = 2 + } + + n, errno := rawSendmsg(s.fd, &msg, sendFlags|syscall.MSG_DONTWAIT) + if errno == 0 { + if !streamTransport { + // A SEQPACKET send moves the whole packet or nothing. + if int(n) != len(hdr)+len(payload) { + return wrapErr(ErrSend, fmt.Sprintf("short write: %d/%d", n, len(hdr)+len(payload))) + } + return nil + } + if n == 0 { + return wrapErr(ErrSend, "stream write moved no data") + } + left := int(n) + for first < len(parts) && left >= len(parts[first]) { + left -= len(parts[first]) + first++ + } + if first == len(parts) { + return nil + } + parts[first] = parts[first][left:] + continue + } + if errno == syscall.EINTR { + continue + } + if errno != syscall.EAGAIN && errno != syscall.EWOULDBLOCK { + return wrapErr(ErrSend, errno.Error()) + } + + // After EOF only writability matters; the retried send reports a + // closed peer. + pfd := syspoll.PollFd{ + Fd: int32(s.fd), // #nosec G115 -- fd is checked against int32 range above. + Events: syspoll.Out, + } + if !eof { + pfd.Events |= syspoll.In + } + waitMs := -1 + if s.sendBounded { + ms, werr := s.sendWait.waitMs() + if werr != nil { + return werr + } + waitMs = ms + } + rc, perr := syspoll.Poll(&pfd, waitMs) + if rc < 0 { + if perr == syscall.EINTR { + continue + } + return wrapErr(ErrSend, perr.Error()) + } + if rc == 0 { + // waitMs() reports the deadline and the abort channel. + continue + } + if !eof && pfd.Revents&(syspoll.In|syspoll.Hup|syspoll.Err) != 0 { + if err := s.pending.drain(s.fd, int(s.PacketSize), s.progressBound, &eof); err != nil { + return err + } + } + } +} diff --git a/src/go/pkg/netipc/transport/posix/uds_sendmsg.go b/src/go/pkg/netipc/transport/posix/uds_sendmsg.go new file mode 100644 index 00000000..9195fd15 --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/uds_sendmsg.go @@ -0,0 +1,20 @@ +//go:build unix && !(linux && 386) + +package posix + +import ( + "syscall" + "unsafe" +) + +// rawSendmsg issues sendmsg(2) directly so header and payload go out as one +// packet without copying them into a single buffer. +func rawSendmsg(fd int, msg *syscall.Msghdr, flags int) (uintptr, syscall.Errno) { + n, _, errno := syscall.Syscall( + syscall.SYS_SENDMSG, + uintptr(fd), + uintptr(unsafe.Pointer(msg)), // #nosec G103 -- raw sendmsg syscall requires a Msghdr pointer. + uintptr(flags), + ) + return n, errno +} diff --git a/src/go/pkg/netipc/transport/posix/uds_sendmsg_linux_386.go b/src/go/pkg/netipc/transport/posix/uds_sendmsg_linux_386.go new file mode 100644 index 00000000..ff61ad58 --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/uds_sendmsg_linux_386.go @@ -0,0 +1,29 @@ +//go:build linux && 386 + +package posix + +import ( + "syscall" + "unsafe" +) + +// socketcall(2) multiplexer number for sendmsg; linux/386 has no direct +// sendmsg system call. Mirrors syscall_linux_386.go in the Go runtime. +const socketcallSendmsg = 16 + +// rawSendmsg issues sendmsg(2) through socketcall(2) so header and payload +// go out as one packet without copying them into a single buffer. +func rawSendmsg(fd int, msg *syscall.Msghdr, flags int) (uintptr, syscall.Errno) { + args := [3]uintptr{ + uintptr(fd), + uintptr(unsafe.Pointer(msg)), // #nosec G103 -- socketcall sendmsg requires a Msghdr pointer. + uintptr(flags), + } + n, _, errno := syscall.Syscall( + syscall.SYS_SOCKETCALL, + socketcallSendmsg, + uintptr(unsafe.Pointer(&args)), // #nosec G103 -- socketcall takes a pointer to its argument array. + 0, + ) + return n, errno +} diff --git a/src/go/pkg/netipc/transport/posix/uds_session.go b/src/go/pkg/netipc/transport/posix/uds_session.go index 9c71c87e..2e5c3938 100644 --- a/src/go/pkg/netipc/transport/posix/uds_session.go +++ b/src/go/pkg/netipc/transport/posix/uds_session.go @@ -2,7 +2,10 @@ package posix -import "syscall" +import ( + "encoding/binary" + "syscall" +) // Role distinguishes client vs server sessions. type Role int @@ -21,7 +24,7 @@ type ClientConfig struct { MaxResponsePayloadBytes uint32 MaxResponseBatchItems uint32 AuthToken uint64 - PacketSize uint32 // 0 = auto-detect from SO_SNDBUF + PacketSize uint32 // 0 = transport limit (SEQPACKET: SO_SNDBUF-32; stream: 256 KiB); capped at 256 KiB } // ServerConfig configures a listener and its accepted sessions. @@ -33,11 +36,12 @@ type ServerConfig struct { MaxResponsePayloadBytes uint32 MaxResponseBatchItems uint32 AuthToken uint64 - PacketSize uint32 // 0 = auto-detect from SO_SNDBUF + PacketSize uint32 // 0 = transport limit (SEQPACKET: SO_SNDBUF-32; stream: 256 KiB); capped at 256 KiB Backlog int // 0 = default (16) } -// Session is a connected UDS SEQPACKET session (client or server side). +// Session is a connected UDS session (client or server side): SEQPACKET, or +// the stream baseline on macOS and FreeBSD. type Session struct { fd int role Role @@ -54,6 +58,139 @@ type Session struct { pktBuf []byte inflightIDs map[uint64]struct{} + + // Inbound packets read while a pipelined send waited for buffer space. + pending pendingQueue + // Queue bound of the send in progress; 0 sends with a blocking call. + progressBound int + // sendWait bounds the send in progress when sendBounded is set; + // otherwise the send waits without a deadline. + sendWait receiveWait + sendBounded bool + // Stream baseline: a receive failed after reading part of a message, so + // the stream is off a packet boundary and every later receive fails. + recvFailed bool + + // Receive state of the call in progress, read by closures built once + // per session (receiveFns), so a receive allocates nothing. + recvWait receiveWait + recvControl bool + streamFirst bool + // Stream baseline: a byte of the message in progress has been taken. + streamConsumed bool + recvPacketFn func([]byte) (int, error) + recvStreamFn func([]byte) (int, error) + failInflights func() +} + +// pendingQueue holds inbound bytes a pipelined client send read while it +// waited for buffer space; receive consumes them before reading the socket. +// SEQPACKET stores each datagram as a 4-byte length record followed by its +// bytes; the stream stores raw bytes. +type pendingQueue struct { + buf []byte + head int +} + +func pendingRecordLen() int { + if streamTransport { + return 0 + } + return 4 +} + +func (q *pendingQueue) queued() bool { + return q != nil && q.head < len(q.buf) +} + +func (q *pendingQueue) consumed() { + if q.head == len(q.buf) { + q.buf = q.buf[:0] + q.head = 0 + } +} + +// drain reads without blocking until the socket has nothing more to read, +// EOF or an error (both set *eof). Queued bytes beyond bound fail with +// ErrProtocol. Each datagram is stored whole, so its later receive sees what +// a direct receive sees: one longer than readLen, a protocol violation, is +// rejected there instead of being stored cut short. +func (q *pendingQueue) drain(fd, readLen, bound int, eof *bool) error { + if q.head > 0 { + n := copy(q.buf, q.buf[q.head:]) + q.buf = q.buf[:n] + q.head = 0 + } + rec := pendingRecordLen() + for { + size := readLen + if rec > 0 { + // MSG_TRUNC reports the full length on Linux 3.4 and later; + // older kernels report at most the probe length. + var probe [1]byte + full, _, _, _, err := syscall.Recvmsg(fd, probe[:], nil, + syscall.MSG_DONTWAIT|syscall.MSG_PEEK|syscall.MSG_TRUNC) + if err == syscall.EINTR { + continue + } + if err == syscall.EAGAIN || err == syscall.EWOULDBLOCK { + return nil + } + if err != nil || full <= 0 { + // EOF or a socket error: the retried send reports it. + *eof = true + return nil + } + if full > bound { + return wrapErr(ErrProtocol, "inbound bytes exceed the in-flight responses") + } + size = max(size, full) + } + start := len(q.buf) + q.buf = append(q.buf, make([]byte, rec+size)...) + n, _, _, _, err := syscall.Recvmsg(fd, q.buf[start+rec:], nil, syscall.MSG_DONTWAIT) + if err != nil || n <= 0 { + q.buf = q.buf[:start] + switch { + case err == syscall.EINTR: + continue + case err == syscall.EAGAIN || err == syscall.EWOULDBLOCK: + default: + // EOF or a socket error: the retried send reports it. + *eof = true + } + return nil + } + if rec > 0 { + binary.NativeEndian.PutUint32(q.buf[start:], uint32(n)) // #nosec G115 -- n <= bound, the in-flight response bytes. + } + q.buf = q.buf[:start+rec+n] + if len(q.buf) > bound { + return wrapErr(ErrProtocol, "inbound bytes exceed the in-flight responses") + } + } +} + +// popPacket pops one queued SEQPACKET datagram into dst, truncating it to +// len(dst) as recv does. It returns the bytes copied. +func (q *pendingQueue) popPacket(dst []byte) int { + rec := int(binary.NativeEndian.Uint32(q.buf[q.head:])) + start := q.head + 4 + n := copy(dst, q.buf[start:start+rec]) + q.head = start + rec + q.consumed() + return n +} + +// take copies up to len(dst) queued stream bytes and returns the count. +func (q *pendingQueue) take(dst []byte) int { + if !q.queued() { + return 0 + } + n := copy(dst, q.buf[q.head:]) + q.head += n + q.consumed() + return n } func (s *Session) failAllInflight() { @@ -63,11 +200,19 @@ func (s *Session) failAllInflight() { clear(s.inflightIDs) } -// Fd returns the raw file descriptor for poll/epoll integration. +// Fd returns the raw file descriptor for poll/epoll integration. A +// pipelined send can read responses into the session's pending queue, where +// the fd no longer shows them: check HasPending before waiting on the fd. func (s *Session) Fd() int { return s.fd } +// HasPending reports whether the next Receive can return data read earlier +// by a pipelined send, without the fd becoming readable. +func (s *Session) HasPending() bool { + return !s.recvFailed && s.pending.queued() +} + // Role returns the session role. func (s *Session) Role() Role { return s.role @@ -81,23 +226,32 @@ func (s *Session) Close() { } s.recvBuf = nil s.pktBuf = nil + s.pending = pendingQueue{} s.failAllInflight() } // Connect establishes a session to a server at {runDir}/{serviceName}.sock. // Performs the full handshake. Blocks until connected + handshake done. func Connect(runDir, serviceName string, config *ClientConfig) (*Session, error) { + return ConnectTimeout(runDir, serviceName, config, 0, nil) +} + +// ConnectTimeout is Connect bounded by timeoutMs: connect and the wait for +// HELLO_ACK together end with ErrTimeout after timeoutMs (0 = no deadline), +// and with ErrAborted once abortCh is closed (nil = no abort). +func ConnectTimeout(runDir, serviceName string, config *ClientConfig, timeoutMs uint32, abortCh <-chan struct{}) (*Session, error) { + wait := newReceiveWait(timeoutMs, abortCh) path, err := buildSocketPath(runDir, serviceName) if err != nil { return nil, err } - fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_SEQPACKET, 0) + fd, err := udsSocket() if err != nil { return nil, wrapErr(ErrSocket, err.Error()) } - session, herr := connectAndHandshake(fd, path, config) + session, herr := connectAndHandshake(fd, path, config, wait) if herr != nil { _ = syscall.Close(fd) return nil, herr diff --git a/src/go/pkg/netipc/transport/posix/uds_stale.go b/src/go/pkg/netipc/transport/posix/uds_stale.go index c86eb1ec..25b95bb0 100644 --- a/src/go/pkg/netipc/transport/posix/uds_stale.go +++ b/src/go/pkg/netipc/transport/posix/uds_stale.go @@ -6,6 +6,8 @@ import ( "errors" "net" "os" + "strconv" + "sync/atomic" "syscall" "time" ) @@ -18,29 +20,102 @@ const ( staleLiveServer staleResult = 2 ) -const ( - staleDialAttempts = 3 - staleDialRetryDelay = 50 * time.Millisecond - staleDialTimeout = 1 * time.Second -) +const staleDialTimeout = 1 * time.Second +// dialStaleCandidate reports whether a live server accepts on path. The +// endpoint lock, not repeated dials, keeps a starting server's endpoint +// safe (see acquireEndpointLock). func dialStaleCandidate(path string) error { - var err error - for attempt := range staleDialAttempts { - var conn net.Conn - conn, err = net.DialTimeout("unixpacket", path, staleDialTimeout) - if err == nil { - _ = conn.Close() - return nil + conn, err := net.DialTimeout(staleDialNetwork, path, staleDialTimeout) + if err == nil { + _ = conn.Close() + } + return err +} + +const lockOpenFlags = syscall.O_CLOEXEC | syscall.O_NOFOLLOW + +var ( + lockPublishSeq atomic.Uint64 + // lockPublishHook, when set by tests, runs just before a new endpoint + // lock file is linked to its final name. + lockPublishHook atomic.Pointer[func(tmp string)] +) + +// publishEndpointLock gives a missing lock its name only once it is 0600: a +// file created under a umask without owner bits stays unopenable if the +// process dies before Fchmod. Best effort: the caller's creating open covers +// every failure, such as a filesystem without hard links, and a losing Link +// means another starter published the file first. +func publishEndpointLock(lockPath string) { + tmp := lockPath + "." + strconv.Itoa(os.Getpid()) + "." + + strconv.FormatUint(lockPublishSeq.Add(1), 10) + ".tmp" + fd, err := syscall.Open(tmp, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|lockOpenFlags, 0o600) + if err != nil { + return + } + err = syscall.Fchmod(fd, 0o600) + _ = syscall.Close(fd) + if err == nil { + if hook := lockPublishHook.Load(); hook != nil { + (*hook)(tmp) } - if !errors.Is(err, syscall.ECONNREFUSED) { - return err + _ = syscall.Link(tmp, lockPath) + } + _ = syscall.Unlink(tmp) +} + +// acquireEndpointLock takes the endpoint lock {runDir}/{serviceName}.lock. +// A server's socket file exists from bind() until listen(), and connects in +// that window are refused just as for a dead server's leftover file, so a +// connect probe alone cannot tell a starting server from a stale one. The +// lock can: its holder owns the endpoint, and the kernel drops it when that +// process dies. The file stays on disk; removing it would let a starter +// that already opened it lock an orphaned inode. +func acquireEndpointLock(runDir, serviceName string) (*os.File, error) { + lockPath := runDir + "/" + serviceName + ".lock" + // A file left with mode bits missing, by an older build or a fallback + // create under a umask without owner read or write, may not reopen + // read-write, so a narrower open follows (flock works on any access mode) + // and Fchmod restores 0600 for the next starter. Fchmod fails only for a + // file another account owns, which the lock still works on. + fd, err := syscall.Open(lockPath, syscall.O_RDWR|lockOpenFlags, 0) + if errors.Is(err, syscall.ENOENT) { + publishEndpointLock(lockPath) + fd, err = syscall.Open(lockPath, syscall.O_RDWR|syscall.O_CREAT|lockOpenFlags, 0o600) + } + if errors.Is(err, syscall.EACCES) { + fd, err = syscall.Open(lockPath, syscall.O_WRONLY|lockOpenFlags, 0) + } + if errors.Is(err, syscall.EACCES) { + fd, err = syscall.Open(lockPath, syscall.O_RDONLY|lockOpenFlags, 0) + } + if err != nil { + return nil, wrapErr(ErrSocket, "lock open: "+err.Error()) + } + _ = syscall.Fchmod(fd, 0o600) + for { + err = syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB) + if err != syscall.EINTR { + break } - if attempt+1 < staleDialAttempts { - time.Sleep(staleDialRetryDelay) + } + if err != nil { + _ = syscall.Close(fd) + if errors.Is(err, syscall.EWOULDBLOCK) { + return nil, ErrAddrInUse } + return nil, wrapErr(ErrSocket, "lock: "+err.Error()) } - return err + return os.NewFile(uintptr(fd), lockPath), nil +} + +// releaseEndpointLock unlocks before closing: a child forked by another +// thread shares the open file description until it execs, and its copy +// would keep the lock held. +func releaseEndpointLock(lock *os.File) { + _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + _ = lock.Close() } func checkAndRecoverStale(path string) staleResult { diff --git a/src/go/pkg/netipc/transport/posix/uds_stream.go b/src/go/pkg/netipc/transport/posix/uds_stream.go new file mode 100644 index 00000000..e4f0e5ca --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/uds_stream.go @@ -0,0 +1,237 @@ +//go:build unix + +package posix + +import ( + "encoding/binary" + "errors" + "sync/atomic" + "syscall" + "unsafe" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" +) + +// Stream-socket packet I/O, the macOS and FreeBSD baseline (macOS has no +// AF_UNIX SOCK_SEQPACKET; FreeBSD 15 SEQPACKET keeps record boundaries only +// with MSG_EOR). A packet is the 32-byte header plus the bytes it declares, +// so the bytes on the wire match the SEQPACKET transport. Built on every +// POSIX system so its tests run everywhere; only macOS and FreeBSD sessions +// use it. + +// streamTestIO, when set by tests, runs before each stream read or write of +// n bytes: it returns how many bytes the call may move and whether it fails +// with EINTR first, as a stream socket and a signal may make it. +var streamTestIO atomic.Pointer[func(write bool, n int) (int, bool)] + +func streamTestHook(write bool, n int) (int, bool) { + if hook := streamTestIO.Load(); hook != nil { + return (*hook)(write, n) + } + return n, false +} + +func headerU32(hdr []byte, offset int) uint32 { + return binary.NativeEndian.Uint32(hdr[offset : offset+4]) +} + +// streamBodyLen returns the bytes that follow a received 32-byte header: +// min(payload_len, packetSize - 32) after an outer header, chunk_payload_len +// after a continuation header, none after any other magic. packetSize 0 +// (handshake) caps an outer header at bufLen. Bytes beyond bufLen are not +// counted, as a SEQPACKET recv into a short buffer drops them; the caller's +// length checks then fail. +func streamBodyLen(hdr []byte, bufLen int, packetSize uint32) int { + // Bounded as uint32 first: a header field above MaxInt32 must not turn + // negative as an int on 32-bit systems. + room := uint32(bufLen - protocol.HeaderSize) // #nosec G115 -- callers pass buffers of at least HeaderSize bytes, far below 4 GiB. + declared := uint32(0) + switch headerU32(hdr, 0) { + case protocol.MagicMsg: + limit := room + if packetSize > protocol.HeaderSize { + limit = packetSize - protocol.HeaderSize + } + declared = min(headerU32(hdr, 16), limit) + case protocol.MagicChunk: + declared = headerU32(hdr, 28) + } + return int(min(declared, room)) +} + +// streamReadExact fills dst, waiting before each read when wait is set (nil +// blocks). EOF before the first byte is a disconnect (ErrRecv); EOF inside a +// packet (any byte read, or midPacket) is a truncated packet (ErrProtocol). +func streamReadExact(fd int, dst []byte, wait *receiveWait, midPacket bool) error { + var got int + return streamReadExactCounted(fd, dst, wait, midPacket, &got) +} + +// streamReadExactCounted is streamReadExact that adds to *got the bytes it +// reads, also on failure. +func streamReadExactCounted(fd int, dst []byte, wait *receiveWait, midPacket bool, got *int) error { + start := *got + for done := 0; done < len(dst); done = *got - start { + if wait != nil { + if err := pollReadableForReceive(fd, *wait); err != nil { + return err + } + } + limit, eintr := streamTestHook(false, len(dst)-done) + n := 0 + var err error = syscall.EINTR + if !eintr { + n, _, _, _, err = syscall.Recvmsg(fd, dst[done:done+limit], nil, 0) + } + if errors.Is(err, syscall.EINTR) { + continue + } + if err != nil { + return wrapErr(ErrRecv, err.Error()) + } + if n == 0 { + if midPacket || done > 0 { + return wrapErr(ErrProtocol, "truncated packet") + } + return wrapErr(ErrRecv, "peer disconnected") + } + *got += n + } + return nil +} + +// streamRecvPacket reads one packet (header, then its body). +func streamRecvPacket(fd int, buf []byte, packetSize uint32, wait *receiveWait) (int, error) { + if len(buf) < protocol.HeaderSize { + return 0, wrapErr(ErrBadParam, "packet buffer too small") + } + if err := streamReadExact(fd, buf[:protocol.HeaderSize], wait, false); err != nil { + return 0, err + } + body := streamBodyLen(buf[:protocol.HeaderSize], len(buf), packetSize) + end := protocol.HeaderSize + body + if err := streamReadExact(fd, buf[protocol.HeaderSize:end], wait, true); err != nil { + return 0, err + } + return end, nil +} + +// streamReadExactPending is streamReadExact that takes the session's +// pending bytes first; a nil pending reads only the socket. +// It sets *consumed when it takes any byte, also on failure. +func streamReadExactPending(fd int, pending *pendingQueue, dst []byte, wait *receiveWait, midPacket bool, consumed *bool) error { + got := 0 + if pending != nil { + got = pending.take(dst) + } + if got > 0 { + *consumed = true + } + if got == len(dst) { + return nil + } + read := 0 + err := streamReadExactCounted(fd, dst[got:], wait, midPacket || got > 0, &read) + if read > 0 { + *consumed = true + } + return err +} + +// streamRecvSessionPacket reads one packet of a session's message, from its +// pending bytes first (nil reads only the socket). The first +// packet's header is checked before its body is read: one that is not an +// outer header, or declares more than this direction's limits, comes back +// alone so the framing rejects it without waiting for bytes a stream peer +// may never send. A truncated continuation is a chunk error, as on +// SEQPACKET. *consumed is set once any byte of the message has been taken. +func streamRecvSessionPacket(fd int, pending *pendingQueue, buf []byte, first bool, packetSize, maxPayload, maxItems uint32, wait *receiveWait, consumed *bool) (int, error) { + if len(buf) < protocol.HeaderSize { + return 0, wrapErr(ErrProtocol, "packet too short for header") + } + n, err := func() (int, error) { + if err := streamReadExactPending(fd, pending, buf[:protocol.HeaderSize], wait, false, consumed); err != nil { + return 0, err + } + hdr := buf[:protocol.HeaderSize] + if first { + h, err := protocol.DecodeHeader(hdr) + if err != nil || h.PayloadLen > maxPayload || h.ItemCount > maxItems { + return protocol.HeaderSize, nil + } + } + end := protocol.HeaderSize + streamBodyLen(hdr, len(buf), packetSize) + if err := streamReadExactPending(fd, pending, buf[protocol.HeaderSize:end], wait, true, consumed); err != nil { + return 0, err + } + return end, nil + }() + if err != nil && !first && errors.Is(err, ErrProtocol) { + return 0, wrapErr(ErrChunk, err.Error()) + } + return n, err +} + +// streamSendAll sends hdr then payload completely, resuming after partial +// writes. A stream needs no single-call send: the receiver delimits packets +// by their headers. +func streamSendAll(fd int, hdr []byte, payload []byte) error { + parts := [2][]byte{hdr, payload} + first := 0 + for first < len(parts) { + if len(parts[first]) == 0 { + first++ + continue + } + var iov [2]syscall.Iovec + msg := syscall.Msghdr{Iov: &iov[0]} + count, total := 0, 0 + for _, part := range parts[first:] { + if len(part) > 0 { + iov[count].Base = unsafe.SliceData(part) // #nosec G103 -- sendmsg iovec needs the backing byte-slice pointer. + iov[count].SetLen(len(part)) + count++ + total += len(part) + } + } + // Tests may cut the write short (to limit bytes) or fail it. + limit, eintr := streamTestHook(true, total) + if limit < len(parts[first]) { + iov[0].SetLen(limit) + count = 1 + } else if limit < total { + iov[1].SetLen(limit - len(parts[first])) + } + // Msghdr.Iovlen differs in type across platforms. + msg.Iovlen = 1 + if count == 2 { + msg.Iovlen = 2 + } + + // A stream write may move part of the data; EINTR means none moved. + var n uintptr + errno := syscall.EINTR + if !eintr { + n, errno = rawSendmsg(fd, &msg, sendFlags) + } + if errno == syscall.EINTR { + continue + } + if errno != 0 { + return wrapErr(ErrSend, errno.Error()) + } + if n == 0 { + return wrapErr(ErrSend, "stream write moved no data") + } + + left := int(n) + for first < len(parts) && left >= len(parts[first]) { + left -= len(parts[first]) + first++ + } + if first < len(parts) { + parts[first] = parts[first][left:] + } + } + return nil +} diff --git a/src/go/pkg/netipc/transport/posix/uds_stream_baseline_test.go b/src/go/pkg/netipc/transport/posix/uds_stream_baseline_test.go new file mode 100644 index 00000000..27b2e75c --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/uds_stream_baseline_test.go @@ -0,0 +1,105 @@ +//go:build darwin || freebsd + +package posix + +import ( + "errors" + "syscall" + "testing" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" +) + +// A receive that consumed part of a message cannot leave the session +// usable: the rest is still queued and would be read as a header. +func TestStreamPartialReceiveEndsReads(t *testing.T) { + fds, err := syscall.Socketpair(syscall.AF_UNIX, sockType, 0) + if err != nil { + t.Fatalf("socketpair: %v", err) + } + defer syscall.Close(fds[1]) + session := &Session{ + fd: fds[0], + role: RoleServer, + MaxRequestPayloadBytes: 4096, + MaxRequestBatchItems: 16, + MaxResponsePayloadBytes: 4096, + MaxResponseBatchItems: 16, + PacketSize: 1024, + SelectedProfile: protocol.ProfileBaseline, + SessionID: 1, + inflightIDs: make(map[uint64]struct{}), + } + defer session.Close() + buf := make([]byte, 2048) + + if _, _, err := session.ReceiveTimeout(buf, 1, nil); !errors.Is(err, ErrTimeout) { + t.Fatalf("timeout before the first byte: %v, want ErrTimeout", err) + } + + pkt := streamOuter(5, []byte("hello")) + if err := sendPacket(fds[1], pkt, nil); err != nil { + t.Fatalf("send: %v", err) + } + if _, payload, err := session.Receive(buf); err != nil || string(payload) != "hello" { + t.Fatalf("intact after a clean timeout: %q, %v", payload, err) + } + + if err := sendPacket(fds[1], pkt[:10], nil); err != nil { + t.Fatalf("send: %v", err) + } + if _, _, err := session.ReceiveTimeout(buf, 1, nil); !errors.Is(err, ErrTimeout) { + t.Fatalf("timeout inside a message: %v, want ErrTimeout", err) + } + if err := sendPacket(fds[1], pkt[10:], nil); err != nil { + t.Fatalf("send: %v", err) + } + if _, _, err := session.Receive(buf); !errors.Is(err, ErrRecv) { + t.Fatalf("later receives: %v, want ErrRecv", err) + } + + // Sends always write whole messages, so they stay valid. + resp := protocol.Header{Kind: protocol.KindResponse, Code: 1, ItemCount: 1, MessageID: 9} + if err := session.Send(&resp, []byte("ok")); err != nil { + t.Fatalf("sends still work: %v", err) + } + if n, err := streamRecvPacket(fds[1], buf, 1024, nil); err != nil || n != protocol.HeaderSize+2 { + t.Fatalf("peer gets the response: %d, %v", n, err) + } +} + +// Stream baseline: a receive that fails mid-stream marks the receive side failed, and +// every later receive must fail. Bytes left in the pending queue are part of +// that abandoned stream: here a forged response for an in-flight id follows +// an over-limit header, and must not be delivered. +func TestStreamFailedReceiveDropsPending(t *testing.T) { + client, wg := smallBufferStart(t, smallBufCloseAfterOne, 65536) + if err := sendPipelinedRequest(client, 0); err != nil { + t.Fatalf("request 1: %v", err) + } + + over := protocol.Header{ + Magic: protocol.MagicMsg, + Version: protocol.Version, + HeaderLen: protocol.HeaderSize, + Kind: protocol.KindResponse, + ItemCount: 1, + MessageID: 1, + PayloadLen: 65537, + } + buf := make([]byte, 2*protocol.HeaderSize) + over.Encode(buf[:protocol.HeaderSize]) + over.PayloadLen = 0 + over.Encode(buf[protocol.HeaderSize:]) + client.pending = pendingQueue{buf: buf} + + rbuf := make([]byte, pipeBufPkt) + if _, _, err := client.Receive(rbuf); !errors.Is(err, ErrLimitExceeded) { + t.Fatalf("expected ErrLimitExceeded, got %v", err) + } + if _, _, err := client.Receive(rbuf); err == nil { + t.Fatal("later receive delivered a queued message") + } + client.Close() + wg.Wait() +} diff --git a/src/go/pkg/netipc/transport/posix/uds_stream_test.go b/src/go/pkg/netipc/transport/posix/uds_stream_test.go new file mode 100644 index 00000000..3ffcafc7 --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/uds_stream_test.go @@ -0,0 +1,224 @@ +//go:build unix + +package posix + +import ( + "bytes" + "encoding/binary" + "errors" + "syscall" + "testing" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" +) + +func streamPair(t *testing.T) (int, int) { + t.Helper() + fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + if err != nil { + t.Fatalf("socketpair: %v", err) + } + return fds[0], fds[1] +} + +func streamOuter(declared uint32, body []byte) []byte { + hdr := protocol.Header{ + Magic: protocol.MagicMsg, + Version: protocol.Version, + HeaderLen: protocol.HeaderSize, + Kind: protocol.KindRequest, + Code: 1, + ItemCount: 1, + MessageID: 9, + PayloadLen: declared, + } + pkt := make([]byte, protocol.HeaderSize, protocol.HeaderSize+len(body)) + hdr.Encode(pkt) + return append(pkt, body...) +} + +func streamContinuation(declared uint32, body []byte) []byte { + chk := protocol.ChunkHeader{ + Magic: protocol.MagicChunk, + Version: protocol.Version, + MessageID: 9, + ChunkIndex: 1, + ChunkCount: 2, + ChunkPayloadLen: declared, + } + pkt := make([]byte, protocol.HeaderSize, protocol.HeaderSize+len(body)) + chk.Encode(pkt) + return append(pkt, body...) +} + +// streamInject installs the stream test hook: the next shortReads reads and +// shortWrites writes of more than one byte move one byte, after eintrReads +// and eintrWrites calls failed with EINTR. It returns the injections not +// consumed yet. +func streamInject(t *testing.T, shortReads, shortWrites, eintrReads, eintrWrites int) func() int { + t.Helper() + hook := func(write bool, n int) (int, bool) { + eintr, short := &eintrReads, &shortReads + if write { + eintr, short = &eintrWrites, &shortWrites + } + if *eintr > 0 { + *eintr-- + return n, true + } + if *short > 0 && n > 1 { + *short-- + return 1, false + } + return n, false + } + streamTestIO.Store(&hook) + t.Cleanup(func() { streamTestIO.Store(nil) }) + return func() int { return shortReads + shortWrites + eintrReads + eintrWrites } +} + +func TestStreamBodyLenComesFromTheHeader(t *testing.T) { + pkt := streamOuter(100, nil) + cases := []struct { + name string + hdr []byte + bufLen int + packetSize uint32 + want int + }{ + {"whole payload fits", pkt, 1024, 1024, 100}, + {"first chunk fills the packet", pkt, 1024, 48, 16}, + {"handshake: capped by the buffer", pkt, 80, 0, 48}, + {"bytes past the buffer", pkt, 64, 1024, 32}, + {"continuation", streamContinuation(7, nil), 1024, 48, 7}, + {"unknown magic", make([]byte, protocol.HeaderSize), 1024, 1024, 0}, + } + for _, tc := range cases { + if got := streamBodyLen(tc.hdr, tc.bufLen, tc.packetSize); got != tc.want { + t.Errorf("%s: streamBodyLen = %d, want %d", tc.name, got, tc.want) + } + } +} + +func TestStreamPacketsRoundTripAndEOFMapping(t *testing.T) { + a, b := streamPair(t) + pkt1 := streamOuter(5, []byte("hello")) + pkt2 := streamContinuation(3, []byte("abc")) + if err := streamSendAll(a, pkt1[:protocol.HeaderSize], pkt1[protocol.HeaderSize:]); err != nil { + t.Fatalf("send pkt1: %v", err) + } + if err := streamSendAll(a, pkt2, nil); err != nil { + t.Fatalf("send pkt2: %v", err) + } + + buf := make([]byte, 64) + for _, want := range [][]byte{pkt1, pkt2} { + n, err := streamRecvPacket(b, buf, 64, nil) + if err != nil || !bytes.Equal(buf[:n], want) { + t.Fatalf("streamRecvPacket = %d, %v; want %d bytes", n, err, len(want)) + } + } + + wait := newReceiveWait(1, nil) + if _, err := streamRecvPacket(b, buf, 64, &wait); !errors.Is(err, ErrTimeout) { + t.Fatalf("idle stream: %v, want ErrTimeout", err) + } + + truncated := streamOuter(5, []byte("he")) + if err := streamSendAll(a, truncated, nil); err != nil { + t.Fatalf("send truncated: %v", err) + } + _ = syscall.Shutdown(a, syscall.SHUT_WR) + if _, err := streamRecvPacket(b, buf, 64, nil); !errors.Is(err, ErrProtocol) { + t.Fatalf("EOF inside the body: %v, want ErrProtocol", err) + } + syscall.Close(a) + syscall.Close(b) + + a, b = streamPair(t) + if err := streamSendAll(a, truncated[:10], nil); err != nil { + t.Fatalf("send header part: %v", err) + } + _ = syscall.Shutdown(a, syscall.SHUT_WR) + if _, err := streamRecvPacket(b, buf, 64, nil); !errors.Is(err, ErrProtocol) { + t.Fatalf("EOF inside the header: %v, want ErrProtocol", err) + } + syscall.Close(a) + syscall.Close(b) + + a, b = streamPair(t) + syscall.Close(a) + if _, err := streamRecvPacket(b, buf, 64, nil); !errors.Is(err, ErrRecv) { + t.Fatalf("EOF between packets: %v, want ErrRecv (disconnect)", err) + } + syscall.Close(b) +} + +func TestStreamSessionPacketChecksTheFirstHeaderFirst(t *testing.T) { + a, b := streamPair(t) + defer syscall.Close(b) + buf := make([]byte, 256) + + // Over the payload limit: the header comes back alone, the body is + // never waited for. + if err := streamSendAll(a, streamOuter(200, nil), nil); err != nil { + t.Fatalf("send: %v", err) + } + if n, err := streamRecvSessionPacket(b, nil, buf, true, 256, 100, 1, nil, new(bool)); n != protocol.HeaderSize || err != nil { + t.Fatalf("over-limit header = %d, %v; want header only", n, err) + } + + // Any header that does not decode, here a wrong version: header only. + badVersion := streamOuter(50, nil) + binary.NativeEndian.PutUint16(badVersion[4:6], protocol.Version+1) + if err := streamSendAll(a, badVersion, nil); err != nil { + t.Fatalf("send: %v", err) + } + if n, err := streamRecvSessionPacket(b, nil, buf, true, 256, 100, 1, nil, new(bool)); n != protocol.HeaderSize || err != nil { + t.Fatalf("undecodable header = %d, %v; want header only", n, err) + } + + // A continuation header where a message starts: header only. + if err := streamSendAll(a, streamContinuation(3, []byte("abc")), nil); err != nil { + t.Fatalf("send: %v", err) + } + if n, err := streamRecvSessionPacket(b, nil, buf, true, 256, 100, 1, nil, new(bool)); n != protocol.HeaderSize || err != nil { + t.Fatalf("continuation first = %d, %v; want header only", n, err) + } + // Its body is still queued; read it off as a raw packet body. + if err := streamReadExact(b, buf[:3], nil, true); err != nil { + t.Fatalf("read body: %v", err) + } + + // A truncated continuation is a chunk error. + if err := streamSendAll(a, streamContinuation(5, []byte("ab")), nil); err != nil { + t.Fatalf("send: %v", err) + } + _ = syscall.Shutdown(a, syscall.SHUT_WR) + if _, err := streamRecvSessionPacket(b, nil, buf, false, 256, 100, 1, nil, new(bool)); !errors.Is(err, ErrChunk) { + t.Fatalf("truncated continuation: %v, want ErrChunk", err) + } + syscall.Close(a) +} + +func TestStreamOneByteTransfersAndEINTRYieldWholePackets(t *testing.T) { + a, b := streamPair(t) + defer syscall.Close(a) + defer syscall.Close(b) + pkt := streamOuter(5, []byte("hello")) + + // len-1 one-byte writes leave one byte for the last call; reads of more + // than one byte are cut to one, 31 in the header and 4 in the body. + pending := streamInject(t, len(pkt)-2, len(pkt)-1, 2, 2) + if err := streamSendAll(a, pkt[:protocol.HeaderSize], pkt[protocol.HeaderSize:]); err != nil { + t.Fatalf("send: %v", err) + } + buf := make([]byte, 64) + n, err := streamRecvPacket(b, buf, 64, nil) + if err != nil || !bytes.Equal(buf[:n], pkt) { + t.Fatalf("streamRecvPacket = %d, %v; want the whole packet", n, err) + } + if left := pending(); left != 0 { + t.Fatalf("%d injections not consumed", left) + } +} diff --git a/src/go/pkg/netipc/transport/posix/uds_test.go b/src/go/pkg/netipc/transport/posix/uds_test.go index 267f5a87..eaaa8e4e 100644 --- a/src/go/pkg/netipc/transport/posix/uds_test.go +++ b/src/go/pkg/netipc/transport/posix/uds_test.go @@ -11,6 +11,7 @@ import ( "path/filepath" "sync" "sync/atomic" + "syscall" "testing" "time" @@ -27,7 +28,9 @@ var testServiceCounter atomic.Uint64 func uniqueService(t *testing.T) string { t.Helper() n := testServiceCounter.Add(1) - return fmt.Sprintf("gotest_%d_%d", os.Getpid(), n) + service := fmt.Sprintf("gotest_%d_%d", os.Getpid(), n) + trackTestLock(filepath.Join(os.TempDir(), "nipc_go_test"), service) + return service } func testRunDir(t *testing.T) string { @@ -39,6 +42,19 @@ func testRunDir(t *testing.T) string { return dir } +// shortTempDir is a per-test directory for socket paths. t.TempDir() follows +// $TMPDIR, which on macOS is long enough to push a socket path past the +// 104-byte sun_path limit. +func shortTempDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("/tmp", "nipc_go_") + if err != nil { + t.Fatalf("create temp dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir +} + func defaultServerConfig() ServerConfig { return ServerConfig{ SupportedProfiles: protocol.ProfileBaseline, @@ -654,7 +670,7 @@ func TestStaleRecovery(t *testing.T) { func TestStaleRecoveryInGroupWritableRunDir(t *testing.T) { // Crash recovery must work regardless of run-dir permissions // (netdata's systemd unit ships RuntimeDirectoryMode=0775). - runDir := t.TempDir() + runDir := shortTempDir(t) if err := os.Chmod(runDir, 0o775); err != nil { t.Fatalf("chmod run dir: %v", err) } @@ -673,6 +689,30 @@ func TestStaleRecoveryInGroupWritableRunDir(t *testing.T) { listener.Close() } +// An empty directory at the socket path is reclaimed on every platform, as +// in C and Rust. +func TestStaleRecoveryReclaimsEmptyDirectory(t *testing.T) { + runDir := testRunDir(t) + service := uniqueService(t) + sockPath := filepath.Join(runDir, service+".sock") + defer os.Remove(sockPath) + + if err := os.Mkdir(sockPath, 0o700); err != nil { + t.Fatalf("cannot create directory at socket path: %v", err) + } + + listener, err := Listen(runDir, service, defaultServerConfig()) + if err != nil { + t.Fatalf("Listen must reclaim an empty directory: %v", err) + } + defer listener.Close() + + info, err := os.Lstat(sockPath) + if err != nil || info.Mode()&os.ModeSocket == 0 { + t.Fatalf("socket must replace the directory: info=%v err=%v", info, err) + } +} + // --------------------------------------------------------------------------- // Test: Disconnect detection // --------------------------------------------------------------------------- @@ -880,9 +920,79 @@ func TestProfileSelection(t *testing.T) { server := sr.session defer server.Close() - // Preferred intersection = SHMFutex (0x04), highest bit = 0x04 - if client.SelectedProfile != protocol.ProfileSHMFutex { - t.Errorf("selected = 0x%x, want 0x%x (SHMFutex)", client.SelectedProfile, protocol.ProfileSHMFutex) + // Preferred intersection = SHMFutex (0x04), highest bit = 0x04. Without + // POSIX SHM the platform limit leaves baseline on both sides. + want := uint32(protocol.ProfileSHMFutex) + if platformProfiles == protocol.ProfileBaseline { + want = protocol.ProfileBaseline + } + if client.SelectedProfile != want || server.SelectedProfile != want { + t.Errorf("selected = 0x%x/0x%x, want 0x%x", client.SelectedProfile, server.SelectedProfile, want) + } +} + +// An unassigned profile bit offered and preferred by both peers is never +// selected: they would otherwise run baseline under that label. +func TestUnassignedProfileBitNotSelected(t *testing.T) { + runDir := testRunDir(t) + service := uniqueService(t) + defer os.Remove(filepath.Join(runDir, service+".sock")) + + const unassigned = 0x10 + sCfg := defaultServerConfig() + sCfg.SupportedProfiles = protocol.ProfileBaseline | unassigned + sCfg.PreferredProfiles = unassigned + listener := startListener(t, runDir, service, sCfg) + defer listener.Close() + + acceptCh := acceptAsync(listener) + + cCfg := defaultClientConfig() + cCfg.SupportedProfiles = protocol.ProfileBaseline | unassigned + cCfg.PreferredProfiles = unassigned + client, err := Connect(runDir, service, &cCfg) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + defer client.Close() + + sr := <-acceptCh + if sr.err != nil { + t.Fatalf("Accept failed: %v", sr.err) + } + defer sr.session.Close() + + if client.SelectedProfile != protocol.ProfileBaseline || sr.session.SelectedProfile != protocol.ProfileBaseline { + t.Errorf("selected = 0x%x/0x%x, want baseline", client.SelectedProfile, sr.session.SelectedProfile) + } +} + +// A deadline-bounded send of an untracked message with nothing in flight +// still has room for one response in its pending queue. +func TestBoundedControlSendIdle(t *testing.T) { + runDir := testRunDir(t) + service := uniqueService(t) + defer os.Remove(filepath.Join(runDir, service+".sock")) + + listener := startListener(t, runDir, service, defaultServerConfig()) + defer listener.Close() + acceptCh := acceptAsync(listener) + + cCfg := defaultClientConfig() + client, err := Connect(runDir, service, &cCfg) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + defer client.Close() + sr := <-acceptCh + if sr.err != nil { + t.Fatalf("Accept failed: %v", sr.err) + } + defer sr.session.Close() + + hdr := protocol.Header{Kind: protocol.KindControl, MessageID: 1} + if err := client.SendTimeout(&hdr, nil, 5000, nil); err != nil { + t.Fatalf("bounded control send: %v", err) } } @@ -1311,8 +1421,7 @@ func TestAcceptWithTimeout(t *testing.T) { } }() - // Short delay then connect - time.Sleep(50 * time.Millisecond) + // The listener is bound, so the connect queues until the accept runs. cCfg := defaultClientConfig() client, err := Connect(runDir, service, &cCfg) if err != nil { @@ -1885,3 +1994,57 @@ func TestUnknownMessageID(t *testing.T) { t.Errorf("error = %v, want ErrUnknownMsgID", err) } } + +// --------------------------------------------------------------------------- +// Test: close-on-exec +// --------------------------------------------------------------------------- + +// Sockets must not leak into children a consumer forks and execs: an +// inherited session socket keeps the peer from ever seeing EOF, which is the +// dead-peer signal. +func TestListenerAndSessionSocketsAreCloexec(t *testing.T) { + runDir := testRunDir(t) + service := uniqueService(t) + defer os.Remove(filepath.Join(runDir, service+".sock")) + + listener := startListener(t, runDir, service, defaultServerConfig()) + defer listener.Close() + acceptCh := acceptAsync(listener) + + cCfg := defaultClientConfig() + client, err := Connect(runDir, service, &cCfg) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + defer client.Close() + res := <-acceptCh + if res.err != nil { + t.Fatalf("Accept failed: %v", res.err) + } + defer res.session.Close() + + for name, fd := range map[string]int{ + "listener": listener.Fd(), + "accepted": res.session.Fd(), + "client": client.Fd(), + } { + flags, err := fcntlGetFd(fd) + if err != nil || flags&syscall.FD_CLOEXEC == 0 { + t.Errorf("%s socket fd %d must be close-on-exec (flags=%#x err=%v)", name, fd, flags, err) + } + } + // Where the listener is non-blocking (accept under ForkLock), the + // accepted socket inherits O_NONBLOCK on BSD; sessions must block. + status, _, errno := syscall.Syscall(syscall.SYS_FCNTL, uintptr(res.session.Fd()), syscall.F_GETFL, 0) + if errno != 0 || status&syscall.O_NONBLOCK != 0 { + t.Errorf("accepted socket must be blocking (status=%#x err=%v)", status, errno) + } +} + +func fcntlGetFd(fd int) (uintptr, error) { + flags, _, errno := syscall.Syscall(syscall.SYS_FCNTL, uintptr(fd), syscall.F_GETFD, 0) + if errno != 0 { + return 0, errno + } + return flags, nil +} diff --git a/src/go/pkg/netipc/transport/posix/uds_transport_seqpacket.go b/src/go/pkg/netipc/transport/posix/uds_transport_seqpacket.go new file mode 100644 index 00000000..17aff43c --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/uds_transport_seqpacket.go @@ -0,0 +1,28 @@ +//go:build unix && !darwin && !freebsd + +package posix + +import "syscall" + +const ( + sockType = syscall.SOCK_SEQPACKET + streamTransport = false + // staleDialNetwork is the net package's name for sockType. + staleDialNetwork = "unixpacket" + // sndbufOverhead is what Linux keeps from SO_SNDBUF for a SEQPACKET + // datagram: it accepts SO_SNDBUF - 32. + sndbufOverhead = 32 +) + +// kernelPacketLimit is the largest datagram the kernel accepts. Linux +// rejects a datagram larger than SO_SNDBUF minus 32 bytes of accounting +// overhead with EMSGSIZE; very large buffers also hit ENOBUFS near 1 MiB, +// hence the fixed cap in packetSizeLimit. +func kernelPacketLimit(fd int) uint32 { + limit := defaultPacketSizeFallback + val, err := syscall.GetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_SNDBUF) + if err == nil && val > sndbufOverhead { + limit = uint32(val - sndbufOverhead) // #nosec G115 -- val is a positive int from the kernel. + } + return limit +} diff --git a/src/go/pkg/netipc/transport/posix/uds_transport_stream.go b/src/go/pkg/netipc/transport/posix/uds_transport_stream.go new file mode 100644 index 00000000..ed9fbdab --- /dev/null +++ b/src/go/pkg/netipc/transport/posix/uds_transport_stream.go @@ -0,0 +1,23 @@ +//go:build darwin || freebsd + +package posix + +import "syscall" + +// macOS has no AF_UNIX SOCK_SEQPACKET, and FreeBSD 15 SEQPACKET keeps record +// boundaries only where the sender sets MSG_EOR and can write part of a +// record. Both use a stream socket that carries the same packets, each +// delimited by the length its header declares (docs/level1-posix-uds.md, +// "Stream baseline"). +const ( + sockType = syscall.SOCK_STREAM + streamTransport = true + // staleDialNetwork is the net package's name for sockType. + staleDialNetwork = "unix" +) + +// kernelPacketLimit: a stream has no per-message size cap; the fixed cap +// keeps the first packet's buffer bounded and the chunking code shared. +func kernelPacketLimit(int) uint32 { + return packetSizeMax +} diff --git a/src/go/pkg/netipc/transport/windows/pipe.go b/src/go/pkg/netipc/transport/windows/pipe.go index 2bffc743..a50b906b 100644 --- a/src/go/pkg/netipc/transport/windows/pipe.go +++ b/src/go/pkg/netipc/transport/windows/pipe.go @@ -52,6 +52,7 @@ const ( _ERROR_PIPE_NOT_CONNECTED = 233 _ERROR_ACCESS_DENIED = 5 _ERROR_PIPE_BUSY = 231 + _ERROR_SEM_TIMEOUT = 121 ) // --------------------------------------------------------------------------- @@ -79,6 +80,9 @@ var ( ErrDisconnected = errors.New("peer disconnected") ErrTimeout = errors.New("receive deadline expired") ErrAborted = errors.New("receive aborted") + // ErrUntrustedServer: the pipe owner is not this account, SYSTEM or + // Administrators. + ErrUntrustedServer = errors.New("pipe owned by an untrusted account") ) func wrapErr(sentinel error, detail string) error { @@ -95,13 +99,23 @@ var ( procCreateNamedPipeW = modkernel32.NewProc("CreateNamedPipeW") procConnectNamedPipe = modkernel32.NewProc("ConnectNamedPipe") procDisconnectNamedPipe = modkernel32.NewProc("DisconnectNamedPipe") - procFlushFileBuffers = modkernel32.NewProc("FlushFileBuffers") procPeekNamedPipe = modkernel32.NewProc("PeekNamedPipe") procSetNamedPipeHandleState = modkernel32.NewProc("SetNamedPipeHandleState") procSwitchToThread = modkernel32.NewProc("SwitchToThread") + procWaitNamedPipeW = modkernel32.NewProc("WaitNamedPipeW") ) -func createNamedPipe(name *uint16, openMode, pipeMode, maxInstances, outBufSize, inBufSize, defaultTimeout uint32) (syscall.Handle, error) { +// waitNamedPipe waits up to timeoutMs for a free instance of pipe name. It +// fails with ERROR_SEM_TIMEOUT when none became free in time. +func waitNamedPipe(name *uint16, timeoutMs uint32) error { + r, _, err := procWaitNamedPipeW.Call(uintptr(unsafe.Pointer(name)), uintptr(timeoutMs)) + if r != 0 { + return nil + } + return err +} + +func createNamedPipe(name *uint16, openMode, pipeMode, maxInstances, outBufSize, inBufSize, defaultTimeout uint32, attrs uintptr) (syscall.Handle, error) { r, _, err := procCreateNamedPipeW.Call( uintptr(unsafe.Pointer(name)), uintptr(openMode), @@ -110,7 +124,7 @@ func createNamedPipe(name *uint16, openMode, pipeMode, maxInstances, outBufSize, uintptr(outBufSize), uintptr(inBufSize), uintptr(defaultTimeout), - 0, // NULL security attributes + attrs, ) handle := syscall.Handle(r) if handle == syscall.InvalidHandle { @@ -131,13 +145,11 @@ func disconnectNamedPipe(handle syscall.Handle) { procDisconnectNamedPipe.Call(uintptr(handle)) } -func flushFileBuffers(handle syscall.Handle) { - procFlushFileBuffers.Call(uintptr(handle)) -} - +// peekNamedPipeAvailable runs in the receive poll loop. SyscallN, unlike +// LazyProc.Call, keeps its arguments and &available on the stack. func peekNamedPipeAvailable(handle syscall.Handle) (uint32, error) { var available uint32 - r, _, err := procPeekNamedPipe.Call( + r, _, err := syscall.SyscallN(procPeekNamedPipe.Addr(), uintptr(handle), 0, 0, @@ -300,17 +312,8 @@ func rawRecv(handle syscall.Handle, buf []byte) (int, error) { var read uint32 err := syscall.ReadFile(handle, buf, &read, nil) if err != nil { - // ERROR_MORE_DATA (234): message mode pipe message is larger - // than the buffer. The data read so far is valid; the - // remaining data can be read with another ReadFile call. - // For our protocol this should not happen if the buffer is - // sized correctly, but treat it as a successful partial read - // rather than a fatal error. - if err == syscall.Errno(234) { - if read > 0 { - return int(read), nil - } - } + // A message larger than buf (ERROR_MORE_DATA) fails, as in C and + // Rust: returning the part read would make its rest the next message. if isDisconnectError(err) { return 0, ErrDisconnected } diff --git a/src/go/pkg/netipc/transport/windows/pipe_edge_test.go b/src/go/pkg/netipc/transport/windows/pipe_edge_test.go index fd0a9431..1db146fa 100644 --- a/src/go/pkg/netipc/transport/windows/pipe_edge_test.go +++ b/src/go/pkg/netipc/transport/windows/pipe_edge_test.go @@ -7,6 +7,7 @@ import ( "encoding/binary" "errors" "strings" + "sync/atomic" "syscall" "testing" "time" @@ -16,6 +17,16 @@ import ( func rawPipePair(t *testing.T) (syscall.Handle, syscall.Handle) { t.Helper() + clientHandle, serverHandle, _, _ := rawPipePairWithClosers(t) + return clientHandle, serverHandle +} + +// rawPipePairWithClosers also returns closers for tests that close an end +// early. Closing a handle again in cleanup could close a value Windows has +// since reused, such as a Go runtime event, and crash the test binary. +func rawPipePairWithClosers(t *testing.T) (syscall.Handle, syscall.Handle, func() error, func() error) { + t.Helper() + var clientClosed, serverClosed atomic.Bool service := uniquePipeService(t) pipeName, err := BuildPipeName(testPipeRunDir, service) @@ -28,7 +39,7 @@ func rawPipePair(t *testing.T) (syscall.Handle, syscall.Handle) { t.Fatalf("createPipeInstance failed: %v", err) } t.Cleanup(func() { - if serverHandle != syscall.InvalidHandle && serverHandle != 0 { + if !serverClosed.Load() && serverHandle != syscall.InvalidHandle && serverHandle != 0 { disconnectNamedPipe(serverHandle) syscall.CloseHandle(serverHandle) } @@ -52,7 +63,7 @@ func rawPipePair(t *testing.T) (syscall.Handle, syscall.Handle) { t.Fatalf("CreateFile failed: %v", err) } t.Cleanup(func() { - if clientHandle != syscall.InvalidHandle && clientHandle != 0 { + if !clientClosed.Load() && clientHandle != syscall.InvalidHandle && clientHandle != 0 { syscall.CloseHandle(clientHandle) } }) @@ -66,7 +77,15 @@ func rawPipePair(t *testing.T) (syscall.Handle, syscall.Handle) { t.Fatalf("connectNamedPipe failed: %v", err) } - return clientHandle, serverHandle + closeClient := func() error { + clientClosed.Store(true) + return syscall.CloseHandle(clientHandle) + } + closeServer := func() error { + serverClosed.Store(true) + return syscall.CloseHandle(serverHandle) + } + return clientHandle, serverHandle, closeClient, closeServer } func sessionPair(t *testing.T, sCfg ServerConfig, cCfg ClientConfig) (*Session, *Session) { @@ -195,49 +214,30 @@ func TestPipeAcceptUnblocksWhenListenerClosed(t *testing.T) { } } -func TestRawRecvMoreData(t *testing.T) { +// A message larger than the receive buffer fails, as in C and Rust, instead +// of returning its first part as a whole message. +func TestRawRecvMoreDataFails(t *testing.T) { clientHandle, serverHandle := rawPipePair(t) - msg := make([]byte, 96) - for i := range msg { - msg[i] = byte(i) - } - if err := rawWrite(clientHandle, msg); err != nil { + if err := rawWrite(clientHandle, make([]byte, 96)); err != nil { t.Fatalf("rawWrite failed: %v", err) } - - small := make([]byte, 16) - n, err := rawRecv(serverHandle, small) - if err != nil { - t.Fatalf("rawRecv(small) failed: %v", err) - } - if n != len(small) { - t.Fatalf("rawRecv(small) len=%d, want %d", n, len(small)) - } - - rest := make([]byte, 96) - n, err = rawRecv(serverHandle, rest) - if err != nil { - t.Fatalf("rawRecv(rest) failed: %v", err) - } - if n != len(msg)-len(small) { - t.Fatalf("rawRecv(rest) len=%d, want %d", n, len(msg)-len(small)) + if _, err := rawRecv(serverHandle, make([]byte, 16)); !errors.Is(err, ErrRecv) { + t.Fatalf("rawRecv into a short buffer = %v, want ErrRecv", err) } } func TestRawPipeDisconnectErrors(t *testing.T) { - clientHandle, serverHandle := rawPipePair(t) + clientHandle, _, _, closeServer := rawPipePairWithClosers(t) - syscall.CloseHandle(serverHandle) - serverHandle = syscall.InvalidHandle + closeServer() if err := rawWrite(clientHandle, []byte("x")); !errors.Is(err, ErrDisconnected) { t.Fatalf("rawWrite after peer close = %v, want ErrDisconnected", err) } - clientHandle2, serverHandle2 := rawPipePair(t) - syscall.CloseHandle(clientHandle2) - clientHandle2 = syscall.InvalidHandle + _, serverHandle2, closeClient2, _ := rawPipePairWithClosers(t) + closeClient2() buf := make([]byte, 32) if _, err := rawRecv(serverHandle2, buf); !errors.Is(err, ErrDisconnected) { @@ -375,7 +375,7 @@ func TestClientHandshakeRejectsMalformedAck(t *testing.T) { t.Fatalf("setNamedPipeHandleState failed: %v", err) } - session, err := clientHandshake(clientHandle, defaultClientConfigPtr()) + session, err := clientHandshake(clientHandle, nil, defaultClientConfigPtr(), newReceiveWait(0, nil)) if err == nil { session.Close() t.Fatal("clientHandshake should fail") @@ -396,13 +396,13 @@ func TestClientHandshakeRejectsMalformedAck(t *testing.T) { func TestClientHandshakeTransportErrors(t *testing.T) { t.Run("hello send disconnected", func(t *testing.T) { - clientHandle, serverHandle := rawPipePair(t) + clientHandle, _, _, closeServer := rawPipePairWithClosers(t) - if err := syscall.CloseHandle(serverHandle); err != nil { + if err := closeServer(); err != nil { t.Fatalf("CloseHandle(server) failed: %v", err) } - _, err := clientHandshake(clientHandle, defaultClientConfigPtr()) + _, err := clientHandshake(clientHandle, nil, defaultClientConfigPtr(), newReceiveWait(0, nil)) if err == nil { t.Fatal("clientHandshake should fail") } @@ -415,19 +415,19 @@ func TestClientHandshakeTransportErrors(t *testing.T) { }) t.Run("hello ack recv disconnected", func(t *testing.T) { - clientHandle, serverHandle := rawPipePair(t) + clientHandle, serverHandle, _, closeServer := rawPipePairWithClosers(t) serverDone := make(chan error, 1) go func() { var helloBuf [128]byte _, err := rawRecv(serverHandle, helloBuf[:]) if err == nil { - err = syscall.CloseHandle(serverHandle) + err = closeServer() } serverDone <- err }() - _, err := clientHandshake(clientHandle, defaultClientConfigPtr()) + _, err := clientHandshake(clientHandle, nil, defaultClientConfigPtr(), newReceiveWait(0, nil)) if err == nil { t.Fatal("clientHandshake should fail") } @@ -510,12 +510,12 @@ func TestServerHandshakeRejectsMalformedHello(t *testing.T) { } func TestServerHandshakeHelloAckSendFailure(t *testing.T) { - clientHandle, serverHandle := rawPipePair(t) + clientHandle, serverHandle, closeClient, _ := rawPipePairWithClosers(t) if err := rawWrite(clientHandle, validHelloPacket()); err != nil { t.Fatalf("rawWrite(hello) failed: %v", err) } - if err := syscall.CloseHandle(clientHandle); err != nil { + if err := closeClient(); err != nil { t.Fatalf("CloseHandle(client) failed: %v", err) } @@ -1133,3 +1133,25 @@ func TestSessionSendRejectsTooSmallPacketSize(t *testing.T) { t.Fatalf("server expected ErrIncompatible, got %v", sr.err) } } + +// A server whose client never sends HELLO reports the wait's own error, +// as C and Rust do, not a generic receive failure. +func TestServerHandshakeWaitKeepsTimeoutAndAbort(t *testing.T) { + aborted := make(chan struct{}) + close(aborted) + cases := []struct { + name string + wait receiveWait + want error + }{ + {"deadline", newReceiveWait(20, nil), ErrTimeout}, + {"abort", newReceiveWait(0, aborted), ErrAborted}, + } + for _, tc := range cases { + _, serverHandle := rawPipePair(t) + cfg := defaultServerConfig() + if _, err := serverHandshakeWait(serverHandle, &cfg, 1, tc.wait, nil); !errors.Is(err, tc.want) { + t.Errorf("%s: %v, want %v", tc.name, err, tc.want) + } + } +} diff --git a/src/go/pkg/netipc/transport/windows/pipe_handshake.go b/src/go/pkg/netipc/transport/windows/pipe_handshake.go index f01125ba..3d38abbe 100644 --- a/src/go/pkg/netipc/transport/windows/pipe_handshake.go +++ b/src/go/pkg/netipc/transport/windows/pipe_handshake.go @@ -3,6 +3,7 @@ package windows import ( + "errors" "fmt" "syscall" @@ -22,8 +23,13 @@ func helloAckLayoutIncompatible(buf []byte) bool { return framing.HelloAckLayoutIncompatible(buf) } -func clientHandshake(handle syscall.Handle, config *ClientConfig) (*Session, error) { +func clientHandshake(handle syscall.Handle, io *pipeIO, config *ClientConfig, wait receiveWait) (*Session, error) { pktSize := applyDefault(config.PacketSize, defaultPacketSize) + pending := &Session{handle: handle, role: RoleClient, io: io} + + // A server that never answers must not hold the client past its + // deadline; the wait's own error is the result, not a receive failure. + var waitErr error ack, err := framing.ClientHandshake(framing.ClientHandshakeConfig{ Hello: framing.HelloConfig{ @@ -36,14 +42,23 @@ func clientHandshake(handle syscall.Handle, config *ClientConfig) (*Session, err AuthToken: config.AuthToken, PacketSize: pktSize, }, - Send: func(pkt []byte) error { return rawWrite(handle, pkt) }, - Recv: func(dst []byte) (int, error) { return rawRecv(handle, dst) }, + Send: pending.writePacket, + Recv: func(dst []byte) (int, error) { + if err := waitHelloAck(pending, wait); err != nil { + waitErr = err + return 0, err + } + return pending.readPacket(dst) + }, StatusError: helloAckStatusError, ErrSend: func(msg string) error { return wrapErr(ErrSend, msg) }, ErrRecv: func(msg string) error { return wrapErr(ErrRecv, msg) }, ErrProtocol: func(msg string) error { return wrapErr(ErrProtocol, msg) }, ErrIncompatible: func(msg string) error { return wrapErr(ErrIncompatible, msg) }, }) + if waitErr != nil { + return nil, waitErr + } if err != nil { return nil, err } @@ -51,6 +66,7 @@ func clientHandshake(handle syscall.Handle, config *ClientConfig) (*Session, err return &Session{ handle: handle, role: RoleClient, + io: io, MaxRequestPayloadBytes: ack.AgreedMaxRequestPayloadBytes, MaxRequestBatchItems: ack.AgreedMaxRequestBatchItems, MaxResponsePayloadBytes: ack.AgreedMaxResponsePayloadBytes, @@ -62,6 +78,25 @@ func clientHandshake(handle syscall.Handle, config *ClientConfig) (*Session, err }, nil } +// waitHelloAck waits until HELLO_ACK can be read. A server that rejects the +// client writes its reply and closes, so a disconnected pipe may still hold +// that reply: the read reports what is there. +func waitHelloAck(pending *Session, wait receiveWait) error { + for { + waitMs, err := wait.waitMs() + if err != nil { + return err + } + ready, err := pending.WaitReadable(waitMs) + if ready || errors.Is(err, ErrDisconnected) { + return nil + } + if err != nil { + return err + } + } +} + func helloAckStatusError(status uint16) error { switch status { case protocol.StatusOK: @@ -80,10 +115,17 @@ func helloAckStatusError(status uint16) error { } func serverHandshake(handle syscall.Handle, config *ServerConfig, sessionID uint64) (*Session, error) { + return serverHandshakeWait(handle, config, sessionID, newReceiveWait(0, nil), nil) +} + +func serverHandshakeWait(handle syscall.Handle, config *ServerConfig, sessionID uint64, wait receiveWait, prepare ProfilePrepare) (*Session, error) { + pending := &Session{handle: handle, role: RoleServer} serverPktSize := applyDefault(config.PacketSize, defaultPacketSize) sRespPay := applyDefault(config.MaxResponsePayloadBytes, protocol.MaxPayloadDefault) sReqPay := applyDefault(config.MaxRequestPayloadBytes, protocol.MaxPayloadDefault) + // The wait's own timeout or abort is returned as is, as in C and Rust. + var waitErr error ack, err := framing.ServerHandshake(framing.ServerHandshakeConfig{ ServerHelloConfig: framing.ServerHelloConfig{ PacketSize: serverPktSize, @@ -93,8 +135,15 @@ func serverHandshake(handle syscall.Handle, config *ServerConfig, sessionID uint PreferredProfiles: config.PreferredProfiles, AuthToken: config.AuthToken, }, - SessionID: sessionID, - Recv: func(dst []byte) (int, error) { return rawRecv(handle, dst) }, + SessionID: sessionID, + Prepare: prepare, + Recv: func(dst []byte) (int, error) { + n, err := pending.rawRecvWithTimeout(dst, wait) + if errors.Is(err, ErrTimeout) || errors.Is(err, ErrAborted) { + waitErr = err + } + return n, err + }, SendAck: func(status uint16, ack protocol.HelloAck) error { return sendHelloAck(handle, status, ack) }, StatusError: helloAckStatusError, ErrRecv: func(msg string) error { return wrapErr(ErrRecv, msg) }, @@ -102,6 +151,9 @@ func serverHandshake(handle syscall.Handle, config *ServerConfig, sessionID uint ErrProtocol: func(msg string) error { return wrapErr(ErrProtocol, msg) }, ErrIncompatible: func(msg string) error { return wrapErr(ErrIncompatible, msg) }, }) + if waitErr != nil { + return nil, waitErr + } if err != nil { return nil, err } diff --git a/src/go/pkg/netipc/transport/windows/pipe_integration_test.go b/src/go/pkg/netipc/transport/windows/pipe_integration_test.go index 52c51df6..39ddf8ee 100644 --- a/src/go/pkg/netipc/transport/windows/pipe_integration_test.go +++ b/src/go/pkg/netipc/transport/windows/pipe_integration_test.go @@ -139,6 +139,218 @@ func TestPipeSingleClientPingPong(t *testing.T) { } } +func TestPipeConnectWaitsForBusyPipe(t *testing.T) { + service := uniquePipeService(t) + listener := startListener(t, testPipeRunDir, service, defaultServerConfig()) + defer listener.Close() + + // The first client takes the only instance; the server has not accepted + // it, so no next instance exists yet. + pipeName, err := BuildPipeName(testPipeRunDir, service) + if err != nil { + t.Fatalf("BuildPipeName: %v", err) + } + holder, err := createClientPipe(&pipeName[0]) + if err != nil { + t.Fatalf("first client: %v", err) + } + defer syscall.CloseHandle(holder) + + busy := make(chan struct{}) + var busyOnce sync.Once + connectBusyHook = func() { busyOnce.Do(func() { close(busy) }) } + defer func() { connectBusyHook = nil }() + + // Accept the holder once the second client found the pipe busy, then + // serve the second client's handshake. + done := make(chan struct{}) + accepted := make(chan *Session, 1) + go func() { + defer close(accepted) + select { + case <-busy: + case <-done: + return + } + first, err := listener.AcceptPending() + if err != nil { + return + } + defer first.Close() + second, err := listener.AcceptPending() + if err != nil { + return + } + if session, err := second.Handshake(2, defaultServerConfig(), 10000, nil); err == nil { + accepted <- session + } + }() + + client, err := Connect(testPipeRunDir, service, defaultClientConfigPtr()) + if err != nil { + close(done) + t.Fatalf("busy pipe connect: %v", err) + } + defer client.Close() + + server, ok := <-accepted + if !ok { + t.Fatal("server did not accept the waiting client") + } + server.Close() +} + +func TestPipeConnectRetriesStolenInstance(t *testing.T) { + service := uniquePipeService(t) + listener := startListener(t, testPipeRunDir, service, defaultServerConfig()) + defer listener.Close() + + pipeName, err := BuildPipeName(testPipeRunDir, service) + if err != nil { + t.Fatalf("BuildPipeName: %v", err) + } + holder, err := createClientPipe(&pipeName[0]) + if err != nil { + t.Fatalf("holder: %v", err) + } + defer syscall.CloseHandle(holder) + + busy := make(chan struct{}) + var busyOnce sync.Once + connectBusyHook = func() { busyOnce.Do(func() { close(busy) }) } + defer func() { connectBusyHook = nil }() + // Once waitNamedPipe reports the instance the server created on accept, + // a third client takes it first, once. + thief := syscall.InvalidHandle + var thiefErr error + stolen := make(chan struct{}) + var stealOnce sync.Once + connectWaitedHook = func() { + stealOnce.Do(func() { + thief, thiefErr = createClientPipe(&pipeName[0]) + close(stolen) + }) + } + defer func() { connectWaitedHook = nil }() + + // Accept the holder, then the thief, then the retrying client. + done := make(chan struct{}) + accepted := make(chan *Session, 1) + go func() { + defer close(accepted) + select { + case <-busy: + case <-done: + return + } + first, err := listener.AcceptPending() + if err != nil { + return + } + defer first.Close() + select { + case <-stolen: + case <-done: + return + } + second, err := listener.AcceptPending() + if err != nil { + return + } + defer second.Close() + third, err := listener.AcceptPending() + if err != nil { + return + } + if session, err := third.Handshake(3, defaultServerConfig(), 10000, nil); err == nil { + accepted <- session + } + }() + + client, err := Connect(testPipeRunDir, service, defaultClientConfigPtr()) + if err != nil { + close(done) + t.Fatalf("connect after the instance was taken: %v", err) + } + defer client.Close() + if thiefErr != nil { + t.Fatalf("thief: %v", thiefErr) + } + defer syscall.CloseHandle(thief) + + server, ok := <-accepted + if !ok { + t.Fatal("server did not accept the retrying client") + } + server.Close() +} + +// A failed replacement instance drops the accepted client and keeps the +// listener's instance, so the next client is still accepted. +func TestPipeAcceptSurvivesFailedInstance(t *testing.T) { + service := uniquePipeService(t) + listener := startListener(t, testPipeRunDir, service, defaultServerConfig()) + defer listener.Close() + + pipeName, err := BuildPipeName(testPipeRunDir, service) + if err != nil { + t.Fatalf("BuildPipeName: %v", err) + } + dropped, err := createClientPipe(&pipeName[0]) + if err != nil { + t.Fatalf("first client: %v", err) + } + defer syscall.CloseHandle(dropped) + + failNextInstances.Store(1) + _, err = listener.AcceptPending() + failNextInstances.Store(0) + if !errors.Is(err, ErrCreatePipe) { + t.Fatalf("AcceptPending = %v, want ErrCreatePipe", err) + } + if _, err := peekNamedPipeAvailable(dropped); err == nil { + t.Fatal("first client is still connected") + } + + // The disconnected instance takes no client until accept listens on it + // again, so the next client finds the pipe busy first. + busy := make(chan struct{}) + var busyOnce sync.Once + connectBusyHook = func() { busyOnce.Do(func() { close(busy) }) } + defer func() { connectBusyHook = nil }() + + done := make(chan struct{}) + accepted := make(chan *Session, 1) + go func() { + defer close(accepted) + select { + case <-busy: + case <-done: + return + } + pending, err := listener.AcceptPending() + if err != nil { + return + } + if session, err := pending.Handshake(1, defaultServerConfig(), 10000, nil); err == nil { + accepted <- session + } + }() + + client, err := ConnectTimeout(testPipeRunDir, service, defaultClientConfigPtr(), 10000, nil) + if err != nil { + close(done) + t.Fatalf("next client connect: %v", err) + } + defer client.Close() + + server, ok := <-accepted + if !ok { + t.Fatal("server did not accept the next client") + } + server.Close() +} + func TestPipeWaitReadable(t *testing.T) { client, server := sessionPair(t, defaultServerConfig(), defaultClientConfig()) diff --git a/src/go/pkg/netipc/transport/windows/pipe_listener.go b/src/go/pkg/netipc/transport/windows/pipe_listener.go index 1e9e4fd2..5cc84d28 100644 --- a/src/go/pkg/netipc/transport/windows/pipe_listener.go +++ b/src/go/pkg/netipc/transport/windows/pipe_listener.go @@ -6,6 +6,8 @@ import ( "sync" "sync/atomic" "syscall" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" ) // Listener is a listening Named Pipe endpoint. @@ -64,8 +66,31 @@ func (l *Listener) Accept() (*Session, error) { } // AcceptWithConfig accepts one client connection using a caller-provided -// per-session server config and session ID. +// per-session server config and session ID. The handshake waits at most +// protocol.ServerHandshakeTimeoutMs for HELLO; servers that must not block +// their accept loop use AcceptPending and PendingConn.Handshake instead. func (l *Listener) AcceptWithConfig(sessionID uint64, config ServerConfig) (*Session, error) { + pending, err := l.AcceptPending() + if err != nil { + return nil, err + } + return pending.Handshake(sessionID, config, protocol.ServerHandshakeTimeoutMs, nil) +} + +// PendingConn is a connected pipe instance whose handshake has not run yet. +// Splitting accept from handshake lets a server bound the handshake and run +// it outside its accept loop. +type PendingConn struct { + handle syscall.Handle +} + +// failNextInstances, when set by tests, fails that many creations of a +// replacement pipe instance in AcceptPending, as CreateNamedPipeW can under +// resource exhaustion. +var failNextInstances atomic.Int32 + +// AcceptPending waits for one client connection without reading HELLO. +func (l *Listener) AcceptPending() (*PendingConn, error) { l.mu.Lock() if l.handle == syscall.InvalidHandle { l.mu.Unlock() @@ -92,34 +117,111 @@ func (l *Listener) AcceptWithConfig(sessionID uint64, config ServerConfig) (*Ses l.handle = syscall.InvalidHandle } l.mu.Unlock() - disconnectNamedPipe(sessionHandle) syscall.CloseHandle(sessionHandle) return nil, wrapErr(ErrAccept, "listener closed") } bufSize := pipeBufferSize(l.config.PacketSize) - next, perr := createPipeInstance(l.pipeName, bufSize, false) + var next syscall.Handle + var perr error + if failNextInstances.Load() > 0 && failNextInstances.Add(-1) >= 0 { + perr = wrapErr(ErrCreatePipe, "test: replacement instance failed") + } else { + next, perr = createPipeInstance(l.pipeName, bufSize, false) + } if perr != nil { - if l.handle == sessionHandle { - l.handle = syscall.InvalidHandle - } - l.mu.Unlock() + // Without a next instance, drop this client and keep its instance as + // the listener's, so a later accept listens on it again. disconnectNamedPipe(sessionHandle) - syscall.CloseHandle(sessionHandle) + l.mu.Unlock() return nil, perr } l.handle = next l.mu.Unlock() - session, herr := serverHandshake(sessionHandle, &config, sessionID) - if herr != nil { - disconnectNamedPipe(sessionHandle) - syscall.CloseHandle(sessionHandle) - return nil, herr + return &PendingConn{handle: sessionHandle}, nil +} + +// Handle returns the pipe handle, or InvalidHandle once consumed. +func (p *PendingConn) Handle() syscall.Handle { + return p.handle +} + +// Handshake reads HELLO and answers it. timeoutMs bounds the wait for HELLO +// (0 = no deadline) and abortCh, when non-nil, cancels it. The connection is +// consumed: on success the Session owns the handle, on failure it is closed. +func (p *PendingConn) Handshake( + sessionID uint64, + config ServerConfig, + timeoutMs uint32, + abortCh <-chan struct{}, +) (*Session, error) { + return p.HandshakePrepared(sessionID, config, timeoutMs, abortCh, nil) +} + +// ProfilePrepare sets up a selected profile's resources (an SHM region) +// during the server handshake, once the payload limits are agreed and +// before HELLO_ACK is sent. Returning false drops that profile and the +// handshake selects again; with no profile left the client is rejected as +// unsupported. +type ProfilePrepare func(profile, maxRequestPayloadBytes, maxResponsePayloadBytes uint32) bool + +// HandshakePrepared is Handshake with a prepare step; prepare may be nil. +func (p *PendingConn) HandshakePrepared( + sessionID uint64, + config ServerConfig, + timeoutMs uint32, + abortCh <-chan struct{}, + prepare ProfilePrepare, +) (*Session, error) { + handle := p.handle + if handle == syscall.InvalidHandle { + return nil, wrapErr(ErrAccept, "connection already consumed") + } + p.handle = syscall.InvalidHandle + + session, err := serverHandshakeWait(handle, &config, sessionID, newReceiveWait(timeoutMs, abortCh), prepare) + if err != nil { + // Close only: DisconnectNamedPipe would discard a rejection reply + // the client has not read yet. + syscall.CloseHandle(handle) + return nil, err } return session, nil } +// Close drops a connection that will not be handshaken. +func (p *PendingConn) Close() { + if p.handle != syscall.InvalidHandle { + syscall.CloseHandle(p.handle) + p.handle = syscall.InvalidHandle + } +} + +// Wake connects to the listener once and disconnects, so an accept blocked +// in ConnectNamedPipe returns. It never closes the listener's handle. +func (l *Listener) Wake() { + l.mu.Lock() + pipeName := l.pipeName + l.mu.Unlock() + if len(pipeName) == 0 || pipeName[0] == 0 { + return + } + + wake, err := syscall.CreateFile( + &pipeName[0], + _GENERIC_READ|_GENERIC_WRITE, + 0, + nil, + _OPEN_EXISTING, + _SECURITY_SQOS_PRESENT|_SECURITY_IDENTIFICATION, + 0, + ) + if err == nil && wake != syscall.InvalidHandle && wake != 0 { + syscall.CloseHandle(wake) + } +} + // Close closes the listener. func (l *Listener) Close() { l.mu.Lock() @@ -143,7 +245,7 @@ func (l *Listener) Close() { 0, nil, _OPEN_EXISTING, - 0, + _SECURITY_SQOS_PRESENT|_SECURITY_IDENTIFICATION, 0, ) if err == nil && wake != syscall.InvalidHandle && wake != 0 { @@ -166,14 +268,21 @@ func createPipeInstance(pipeName []uint16, bufSize uint32, firstInstance bool) ( openMode |= _FILE_FLAG_FIRST_PIPE_INSTANCE } + attrs, err := newOwnerOnlyAttributes() + if err != nil { + return syscall.InvalidHandle, wrapErr(ErrCreatePipe, err.Error()) + } + defer attrs.free() + handle, err := createNamedPipe( &pipeName[0], openMode, - _PIPE_TYPE_MESSAGE|_PIPE_READMODE_MESSAGE|_PIPE_WAIT, + _PIPE_TYPE_MESSAGE|_PIPE_READMODE_MESSAGE|_PIPE_WAIT|_PIPE_REJECT_REMOTE_CLIENTS, _PIPE_UNLIMITED_INSTANCES, bufSize, bufSize, 0, + attrs.ptr(), ) if err != nil { errno, ok := err.(syscall.Errno) diff --git a/src/go/pkg/netipc/transport/windows/pipe_overlapped.go b/src/go/pkg/netipc/transport/windows/pipe_overlapped.go new file mode 100644 index 00000000..777dd00d --- /dev/null +++ b/src/go/pkg/netipc/transport/windows/pipe_overlapped.go @@ -0,0 +1,410 @@ +//go:build windows + +package windows + +import ( + "encoding/binary" + "fmt" + "math" + "syscall" + "time" + "unsafe" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/monotime" + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" +) + +// Client pipe handles are overlapped (FILE_FLAG_OVERLAPPED), so a +// pipelining send can read responses while its write waits for buffer +// space. Every read and write on such a handle passes an OVERLAPPED and is +// waited to completion before the call returns: no I/O stays posted between +// calls. Server handles stay synchronous. + +// progressYield is how long a stalled pipelined send keeps yielding before +// it sleeps. +const progressYield = time.Millisecond + +const ( + _FILE_FLAG_OVERLAPPED = 0x40000000 + _ERROR_IO_PENDING = 997 + _ERROR_IO_INCOMPLETE = 996 + _WAIT_OBJECT_0 = 0 +) + +var procGetOverlappedResult = modkernel32.NewProc("GetOverlappedResult") + +// getOverlappedResult runs per message; SyscallN keeps its arguments on the +// stack. +func getOverlappedResult(handle syscall.Handle, ov *syscall.Overlapped, done *uint32, wait bool) error { + var w uintptr + if wait { + w = 1 + } + r, _, err := syscall.SyscallN(procGetOverlappedResult.Addr(), + uintptr(handle), uintptr(unsafe.Pointer(ov)), uintptr(unsafe.Pointer(done)), w) + if r == 0 { + return err + } + return nil +} + +// createManualEvent creates an unnamed, manual-reset, non-signalled event. +func createManualEvent() (syscall.Handle, error) { + r, _, err := procCreateEventW.Call(0, 1, 0, 0) + if r == 0 { + return syscall.InvalidHandle, err + } + return syscall.Handle(r), nil +} + +// pipeIO is the overlapped state of a client handle. It lives on the heap: +// the kernel writes to an OVERLAPPED and its buffer while the I/O is posted, +// and goroutine stacks can move in that time. Holding the buffers here makes +// every buffer passed to the kernel escape to the heap, where it stays put. +type pipeIO struct { + read syscall.Overlapped + write syscall.Overlapped + readBuf []byte + writeBuf []byte +} + +func newPipeIO() (*pipeIO, error) { + io := &pipeIO{} + readEvent, err := createManualEvent() + if err != nil { + return nil, wrapErr(ErrConnect, "CreateEvent: "+err.Error()) + } + writeEvent, err := createManualEvent() + if err != nil { + syscall.CloseHandle(readEvent) + return nil, wrapErr(ErrConnect, "CreateEvent: "+err.Error()) + } + io.read.HEvent = readEvent + io.write.HEvent = writeEvent + return io, nil +} + +func (io *pipeIO) close() { + if io == nil { + return + } + syscall.CloseHandle(io.read.HEvent) + syscall.CloseHandle(io.write.HEvent) +} + +func sendError(err error) error { + if isDisconnectError(err) { + return ErrDisconnected + } + return wrapErr(ErrSend, err.Error()) +} + +func recvError(err error) error { + if isDisconnectError(err) { + return ErrDisconnected + } + return wrapErr(ErrRecv, err.Error()) +} + +// overlappedRead reads one message and waits for it. A message larger than +// buf (ERROR_MORE_DATA) fails, as on a synchronous handle. +func overlappedRead(handle syscall.Handle, io *pipeIO, buf []byte) (int, error) { + ov := &io.read + io.readBuf = buf + defer func() { io.readBuf = nil }() + var read uint32 + // Overlapped I/O takes no byte-count pointer (Win32 docs); the count + // comes from GetOverlappedResult. + err := syscall.ReadFile(handle, buf, nil, ov) + if err != nil && err != syscall.Errno(_ERROR_IO_PENDING) { + return 0, recvError(err) + } + if err := getOverlappedResult(handle, ov, &read, true); err != nil { + return 0, recvError(err) + } + if read == 0 { + return 0, ErrDisconnected + } + return int(read), nil +} + +// startOverlappedWrite posts one message. Its completion is collected with +// finishOverlappedWrite, also when WriteFile completed at once. +func startOverlappedWrite(handle syscall.Handle, io *pipeIO, data []byte) error { + io.writeBuf = data + // No byte-count pointer: the write may still be pending when this + // returns; finishOverlappedWrite gets the count. + err := syscall.WriteFile(handle, data, nil, &io.write) + if err != nil && err != syscall.Errno(_ERROR_IO_PENDING) { + io.writeBuf = nil + return sendError(err) + } + return nil +} + +func finishOverlappedWrite(handle syscall.Handle, io *pipeIO, data []byte) error { + var written uint32 + err := getOverlappedResult(handle, &io.write, &written, true) + io.writeBuf = nil + if err != nil { + return sendError(err) + } + if written != uint32(len(data)) { // #nosec G115 -- messages are bounded by the uint32 packet size. + return wrapErr(ErrSend, fmt.Sprintf("short write: %d/%d", written, len(data))) + } + return nil +} + +func overlappedWrite(handle syscall.Handle, io *pipeIO, data []byte) error { + if err := startOverlappedWrite(handle, io, data); err != nil { + return err + } + return finishOverlappedWrite(handle, io, data) +} + +// writePacket writes one wire packet on the session's handle. +func (s *Session) writePacket(msg []byte) error { + if s.io == nil { + return rawWrite(s.handle, msg) + } + if s.progressBound == 0 { + if s.sendBounded { + return s.overlappedWriteWait(msg) + } + return overlappedWrite(s.handle, s.io, msg) + } + return s.writeWithProgress(msg) +} + +// overlappedWriteWait posts the write and waits until it completes, the +// deadline passes or the abort channel is closed. A write that ends early is +// cancelled and the session broken, since part of the message may be on the +// wire. +func (s *Session) overlappedWriteWait(msg []byte) error { + if err := startOverlappedWrite(s.handle, s.io, msg); err != nil { + return err + } + ov := &s.io.write + for { + var written uint32 + err := getOverlappedResult(s.handle, ov, &written, false) + if err == nil { + s.io.writeBuf = nil + if written != uint32(len(msg)) { // #nosec G115 -- messages are bounded by the uint32 packet size. + return wrapErr(ErrSend, fmt.Sprintf("short write: %d/%d", written, len(msg))) + } + return nil + } + if err != syscall.Errno(_ERROR_IO_INCOMPLETE) { + s.io.writeBuf = nil + return sendError(err) + } + ms, werr := s.sendWait.waitMs() + if werr != nil { + s.cancelWrite() + s.breakSession() + return werr + } + if _, err := syscall.WaitForSingleObject(ov.HEvent, ms); err != nil { + s.cancelWrite() + s.breakSession() + return wrapErr(ErrSend, fmt.Sprintf("WaitForSingleObject: %v", err)) + } + } +} + +// readPacket reads one wire packet from the session's handle. +func (s *Session) readPacket(dst []byte) (int, error) { + if s.io == nil { + return rawRecv(s.handle, dst) + } + return overlappedRead(s.handle, s.io, dst) +} + +// writeWithProgress posts the write and, until it completes, reads what the +// server sent into the pending queue, so both sides keep making progress. +// Without sendWait it waits as long as a blocking write would; with one it +// ends at the deadline or abort. +func (s *Session) writeWithProgress(msg []byte) error { + ov := &s.io.write + if err := startOverlappedWrite(s.handle, s.io, msg); err != nil { + return err + } + eof := false + yieldUntil := monotime.After(progressYield) + for { + ev, werr := syscall.WaitForSingleObject(ov.HEvent, 0) + if ev == _WAIT_OBJECT_0 { + return finishOverlappedWrite(s.handle, s.io, msg) + } + if ev != _WAIT_TIMEOUT { + s.cancelWrite() + s.breakSession() + return wrapErr(ErrSend, fmt.Sprintf("WaitForSingleObject: %v", werr)) + } + if s.sendBounded { + if _, err := s.sendWait.waitMs(); err != nil { + s.cancelWrite() + s.breakSession() + return err + } + } + if !eof { + drained, derr := s.drainPending(&eof) + if derr != nil { + s.cancelWrite() + s.breakSession() + return derr + } + if drained { + yieldUntil = monotime.After(progressYield) + continue + } + } + // The server is busy or its reply is still being written: yield for + // up to progressYield after the last progress, then wait for the + // write in short slices. A slice lasts a system timer tick and only + // the write completing ends it early, so slicing at once stalls a + // server blocked on its reply to us. + if monotime.Now() < yieldUntil { + procSwitchToThread.Call() + continue + } + _, _ = syscall.WaitForSingleObject(ov.HEvent, 1) + } +} + +// cancelWrite cancels the posted write and waits until the kernel no longer +// uses its OVERLAPPED and buffer. +func (s *Session) cancelWrite() { + ov := &s.io.write + _ = syscall.CancelIoEx(s.handle, ov) + var written uint32 + _ = getOverlappedResult(s.handle, ov, &written, true) + s.io.writeBuf = nil +} + +// drainPending reads every message available now into the pending queue. +// It reports whether it read any. A disconnected pipe sets *eof: the posted +// write reports it. Queued bytes beyond progressBound fail with +// ErrProtocol. +func (s *Session) drainPending(eof *bool) (bool, error) { + drained := false + readLen := int(s.PacketSize) + for { + available, err := peekNamedPipeAvailable(s.handle) + if err != nil { + if isDisconnectError(err) { + *eof = true + return drained, nil + } + // Stopping the drain on a live pipe would bring the deadlock back. + return drained, wrapErr(ErrProtocol, "pending peek: "+err.Error()) + } + if available == 0 { + return drained, nil + } + q := &s.pending + q.compact() + start := len(q.buf) + q.grow(pendingRecordLen + readLen) + n, rerr := overlappedRead(s.handle, s.io, q.buf[start+pendingRecordLen:]) + if rerr != nil { + q.buf = q.buf[:start] + if rerr == ErrDisconnected { + *eof = true + return drained, nil + } + // A message larger than a packet, or a read failure. + return drained, wrapErr(ErrProtocol, "pending read: "+rerr.Error()) + } + binary.NativeEndian.PutUint32(q.buf[start:], uint32(n)) // #nosec G115 -- n <= readLen, a uint32 packet size. + q.buf = q.buf[:start+pendingRecordLen+n] + if len(q.buf)-q.head > s.progressBound { + return drained, wrapErr(ErrProtocol, "inbound bytes exceed the in-flight responses") + } + drained = true + } +} + +// breakSession closes the pipe: a cancelled write leaves the message stream +// in an unknown state, and a drain failure drops inbound messages. Later +// calls fail with ErrBadParam and Close still releases the rest. +func (s *Session) breakSession() { + if s.handle != syscall.InvalidHandle { + syscall.CloseHandle(s.handle) + s.handle = syscall.InvalidHandle + } + s.pending = pendingQueue{} + s.failAllInflight() +} + +// pendingRecordLen is the length prefix of each queued message. +const pendingRecordLen = 4 + +// pendingQueue holds messages a pipelining client send read while its write +// waited; receive returns them before reading the pipe. +type pendingQueue struct { + buf []byte + head int +} + +func (q *pendingQueue) queued() bool { + return q.head < len(q.buf) +} + +// grow extends buf by n bytes. It zeroes only newly allocated storage, not +// the bytes a read will overwrite. +func (q *pendingQueue) grow(n int) { + need := len(q.buf) + n + if need > cap(q.buf) { + nb := make([]byte, len(q.buf), max(need, 2*cap(q.buf))) + copy(nb, q.buf) + q.buf = nb + } + q.buf = q.buf[:need] +} + +func (q *pendingQueue) compact() { + if q.head > 0 { + n := copy(q.buf, q.buf[q.head:]) + q.buf = q.buf[:n] + q.head = 0 + } +} + +// pop copies the oldest queued message into dst. A message larger than dst +// fails, as a pipe read would with ERROR_MORE_DATA. +func (q *pendingQueue) pop(dst []byte) (int, error) { + rec := int(binary.NativeEndian.Uint32(q.buf[q.head:])) + start := q.head + pendingRecordLen + var n int + var err error + if rec > len(dst) { + err = wrapErr(ErrRecv, fmt.Sprintf("queued message of %d bytes exceeds the %d-byte buffer", rec, len(dst))) + } else { + n = copy(dst, q.buf[start:start+rec]) + } + q.head = start + rec + if q.head == len(q.buf) { + q.buf = q.buf[:0] + q.head = 0 + } + return n, err +} + +// pendingBound is the wire bytes inflight responses can occupy: the pending +// queue bound. It saturates at math.MaxInt (32-bit targets), as on POSIX. +func pendingBound(maxResponsePayload, packetSize uint32, inflight int) int { + payload := uint64(maxResponsePayload) + budget := max(uint64(packetSize), protocol.HeaderSize+1) - protocol.HeaderSize + packets := uint64(1) + if payload > 0 { + packets = 1 + (payload-1)/budget + } + perResponse := payload + packets*uint64(protocol.HeaderSize+pendingRecordLen) + if perResponse > uint64(math.MaxInt)/uint64(inflight) { + return math.MaxInt + } + return int(perResponse * uint64(inflight)) // #nosec G115 -- checked against math.MaxInt above. +} diff --git a/src/go/pkg/netipc/transport/windows/pipe_progress_test.go b/src/go/pkg/netipc/transport/windows/pipe_progress_test.go new file mode 100644 index 00000000..0f001eb3 --- /dev/null +++ b/src/go/pkg/netipc/transport/windows/pipe_progress_test.go @@ -0,0 +1,180 @@ +//go:build windows + +package windows + +import ( + "bytes" + "encoding/binary" + "errors" + "testing" + "time" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" +) + +const ( + progressPayload = 16 * 1024 + progressRequests = 32 // 512 KiB each way, 8x the 64 KiB pipe buffers. +) + +func progressPayloadFor(id uint64) []byte { + p := make([]byte, progressPayload) + for i := range p { + p[i] = byte(id) + byte(i) + } + return p +} + +// connectProgressPair connects a client to a server that answers every +// request with its payload, one at a time, with blocking writes: the +// server of the deadlock this guards against. +func connectProgressPair(t *testing.T) (*Session, <-chan error) { + t.Helper() + service := uniquePipeService(t) + scfg := defaultServerConfig() + scfg.MaxRequestPayloadBytes = progressPayload + scfg.MaxResponsePayloadBytes = progressPayload + listener := startListener(t, testPipeRunDir, service, scfg) + t.Cleanup(listener.Close) + acceptCh := acceptAsync(listener) + + ccfg := defaultClientConfig() + ccfg.MaxRequestPayloadBytes = progressPayload + ccfg.MaxResponsePayloadBytes = progressPayload + client, err := Connect(testPipeRunDir, service, &ccfg) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + t.Cleanup(client.Close) + sr := <-acceptCh + if sr.err != nil { + t.Fatalf("Accept failed: %v", sr.err) + } + server := sr.session + + served := make(chan error, 1) + go func() { + defer server.Close() + buf := make([]byte, 64*1024) + for i := 0; i < progressRequests; i++ { + hdr, payload, err := server.Receive(buf) + if err != nil { + served <- err + return + } + resp := protocol.Header{Kind: protocol.KindResponse, Code: hdr.Code, ItemCount: 1, MessageID: hdr.MessageID} + if err := server.Send(&resp, payload); err != nil { + served <- err + return + } + } + served <- nil + }() + return client, served +} + +// A client pipelining more than the pipe buffers before reading must not +// deadlock with a server blocked writing responses to it. +func TestPipePipelineBeyondPipeBuffers(t *testing.T) { + client, served := connectProgressPair(t) + + done := make(chan error, 1) + go func() { + for id := uint64(1); id <= progressRequests; id++ { + hdr := protocol.Header{Kind: protocol.KindRequest, Code: protocol.MethodIncrement, ItemCount: 1, MessageID: id} + if err := client.Send(&hdr, progressPayloadFor(id)); err != nil { + done <- err + return + } + } + // The server could read every request only because the sends + // drained responses it had written. + if !client.HasPending() { + done <- errors.New("no response was queued while sending") + return + } + if ready, err := client.WaitReadable(0); !ready || err != nil { + done <- errors.New("WaitReadable does not report queued responses") + return + } + buf := make([]byte, 64*1024) + for id := uint64(1); id <= progressRequests; id++ { + hdr, payload, err := client.Receive(buf) + if err != nil { + done <- err + return + } + if hdr.MessageID != id || !bytes.Equal(payload, progressPayloadFor(id)) { + done <- errors.New("response out of order or corrupted") + return + } + } + if client.HasPending() { + done <- errors.New("responses left queued after receiving all") + return + } + done <- nil + }() + + // Watchdog only: a deadlock never completes. + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(30 * time.Second): + t.Fatal("pipelining beyond the pipe buffers deadlocked") + } + if err := <-served; err != nil { + t.Fatalf("server: %v", err) + } +} + +// A queued message larger than the receive buffer fails like a pipe read +// with ERROR_MORE_DATA, and the queue moves past it. +func TestPendingQueuePopTooSmall(t *testing.T) { + var q pendingQueue + for _, msg := range [][]byte{[]byte("0123456789"), []byte("ab")} { + rec := make([]byte, pendingRecordLen) + binary.NativeEndian.PutUint32(rec, uint32(len(msg))) + q.buf = append(q.buf, rec...) + q.buf = append(q.buf, msg...) + } + if _, err := q.pop(make([]byte, 4)); !errors.Is(err, ErrRecv) { + t.Fatalf("pop into a short buffer = %v, want ErrRecv", err) + } + dst := make([]byte, 4) + n, err := q.pop(dst) + if err != nil || string(dst[:n]) != "ab" { + t.Fatalf("second pop = %q, %v", dst[:n], err) + } + if q.queued() { + t.Fatal("queue not empty") + } +} + +// A message larger than the receive buffer fails on the overlapped client +// handle too, instead of returning its first part as a message. +func TestPipeClientReceiveMoreDataFails(t *testing.T) { + service := uniquePipeService(t) + listener := startListener(t, testPipeRunDir, service, defaultServerConfig()) + defer listener.Close() + acceptCh := acceptAsync(listener) + client, err := Connect(testPipeRunDir, service, defaultClientConfigPtr()) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + defer client.Close() + sr := <-acceptCh + if sr.err != nil { + t.Fatalf("Accept failed: %v", sr.err) + } + defer sr.session.Close() + + if err := rawWrite(sr.session.handle, make([]byte, 96)); err != nil { + t.Fatalf("rawWrite: %v", err) + } + if _, err := client.readPacket(make([]byte, 16)); !errors.Is(err, ErrRecv) { + t.Fatalf("readPacket into a short buffer = %v, want ErrRecv", err) + } +} diff --git a/src/go/pkg/netipc/transport/windows/pipe_receive.go b/src/go/pkg/netipc/transport/windows/pipe_receive.go index 2af28f8c..b370fff0 100644 --- a/src/go/pkg/netipc/transport/windows/pipe_receive.go +++ b/src/go/pkg/netipc/transport/windows/pipe_receive.go @@ -7,6 +7,7 @@ import ( "syscall" "time" + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/monotime" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/internal/framing" ) @@ -23,11 +24,9 @@ func (s *Session) ReceiveTimeout(buf []byte, timeoutMs uint32, abortCh <-chan st return protocol.Header{}, nil, wrapErr(ErrBadParam, "session closed") } - recv := func(dst []byte) (int, error) { return rawRecv(s.handle, dst) } - if timeoutMs != 0 || abortCh != nil { - wait := newReceiveWait(timeoutMs, abortCh) - recv = func(dst []byte) (int, error) { return s.rawRecvWithTimeout(dst, wait) } - } + s.initReceiveFns() + s.recvControl = timeoutMs != 0 || abortCh != nil + s.recvWait = newReceiveWait(timeoutMs, abortCh) return framing.SessionReceive(framing.SessionReceiveConfig{ RoleServer: s.role == RoleServer, @@ -39,13 +38,13 @@ func (s *Session) ReceiveTimeout(buf []byte, timeoutMs uint32, abortCh <-chan st InflightIDs: s.inflightIDs, RecvBuf: &s.recvBuf, PacketBuf: &s.pktBuf, - Recv: recv, + Recv: s.recvPacketFn, EnsurePacketScratch: ensurePipeScratchBuf, IsRecvDisconnect: func(err error) bool { return errors.Is(err, ErrDisconnected) }, PropagateRecvError: func(err error) bool { return errors.Is(err, ErrTimeout) || errors.Is(err, ErrAborted) }, - FailAllInflight: s.failAllInflight, + FailAllInflight: s.failInflights, ErrLimitExceeded: func(msg string) error { return wrapErr(ErrLimitExceeded, msg) }, ErrProtocol: func(msg string) error { return wrapErr(ErrProtocol, msg) }, ErrChunk: func(msg string) error { return wrapErr(ErrChunk, msg) }, @@ -54,9 +53,27 @@ func (s *Session) ReceiveTimeout(buf []byte, timeoutMs uint32, abortCh <-chan st }, buf) } +// initReceiveFns builds the session's receive closures once. They read the +// call's state from the session (recvControl, recvWait). +func (s *Session) initReceiveFns() { + if s.recvPacketFn != nil { + return + } + s.recvPacketFn = func(dst []byte) (int, error) { + if s.pending.queued() { + return s.pending.pop(dst) + } + if s.recvControl { + return s.rawRecvWithTimeout(dst, s.recvWait) + } + return s.readPacket(dst) + } + s.failInflights = s.failAllInflight +} + type receiveWait struct { infinite bool - deadline time.Time + deadline int64 // monotime nanoseconds abortCh <-chan struct{} } @@ -66,7 +83,7 @@ func newReceiveWait(timeoutMs uint32, abortCh <-chan struct{}) receiveWait { abortCh: abortCh, } if !w.infinite { - w.deadline = time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) + w.deadline = monotime.After(time.Duration(timeoutMs) * time.Millisecond) } return w } @@ -85,7 +102,7 @@ func (w receiveWait) waitMs() (uint32, error) { return waitMs, nil } - remaining := time.Until(w.deadline) + remaining := time.Duration(w.deadline - monotime.Now()) if remaining <= 0 { return 0, ErrTimeout } @@ -120,6 +137,6 @@ func (s *Session) rawRecvWithTimeout(buf []byte, wait receiveWait) (int, error) if !ready { continue } - return rawRecv(s.handle, buf) + return s.readPacket(buf) } } diff --git a/src/go/pkg/netipc/transport/windows/pipe_receive_test.go b/src/go/pkg/netipc/transport/windows/pipe_receive_test.go index 8d03aa1a..ed08ea73 100644 --- a/src/go/pkg/netipc/transport/windows/pipe_receive_test.go +++ b/src/go/pkg/netipc/transport/windows/pipe_receive_test.go @@ -7,6 +7,8 @@ import ( "syscall" "testing" "time" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/monotime" ) func TestReceiveClosedSession(t *testing.T) { @@ -40,7 +42,7 @@ func TestReceiveWaitHelpers(t *testing.T) { t.Fatalf("infinite wait = %d/%v, want %d/nil", waitMs, err, receiveAbortPollMs) } - expired := receiveWait{deadline: time.Now().Add(-time.Millisecond)} + expired := receiveWait{deadline: monotime.After(-time.Millisecond)} if _, err := expired.waitMs(); !errors.Is(err, ErrTimeout) { t.Fatalf("expired wait error = %v, want ErrTimeout", err) } @@ -53,7 +55,7 @@ func TestReceiveWaitHelpers(t *testing.T) { } timed := newReceiveWait(50, nil) - if timed.infinite || timed.deadline.IsZero() { + if timed.infinite || timed.deadline == 0 { t.Fatalf("timed wait = %+v, want finite wait with deadline", timed) } } @@ -63,7 +65,7 @@ func TestRawRecvWithTimeoutClosedSession(t *testing.T) { if _, err := session.rawRecvWithTimeout(make([]byte, 1), receiveWait{infinite: true}); !errors.Is(err, ErrBadParam) { t.Fatalf("rawRecvWithTimeout on closed session = %v, want ErrBadParam", err) } - if _, err := session.rawRecvWithTimeout(make([]byte, 1), receiveWait{deadline: time.Now().Add(-time.Millisecond)}); !errors.Is(err, ErrTimeout) { + if _, err := session.rawRecvWithTimeout(make([]byte, 1), receiveWait{deadline: monotime.After(-time.Millisecond)}); !errors.Is(err, ErrTimeout) { t.Fatalf("rawRecvWithTimeout expired wait = %v, want ErrTimeout", err) } } diff --git a/src/go/pkg/netipc/transport/windows/pipe_send.go b/src/go/pkg/netipc/transport/windows/pipe_send.go index ed7383c0..02cffa69 100644 --- a/src/go/pkg/netipc/transport/windows/pipe_send.go +++ b/src/go/pkg/netipc/transport/windows/pipe_send.go @@ -12,9 +12,35 @@ import ( // Send sends one logical message. Fills magic/version/header_len/payload_len. func (s *Session) Send(hdr *protocol.Header, payload []byte) error { + return s.SendTimeout(hdr, payload, 0, nil) +} + +// SendTimeout is Send with an optional deadline and abort channel for a +// client session. timeoutMs == 0 waits without a deadline. If the deadline +// passes or abortCh is closed before a write completes, the write is +// cancelled, the session is closed (later calls fail with ErrBadParam) and +// it returns ErrTimeout or ErrAborted, since part of the message may be on +// the wire. +func (s *Session) SendTimeout(hdr *protocol.Header, payload []byte, timeoutMs uint32, abortCh <-chan struct{}) error { if s.handle == syscall.InvalidHandle { return wrapErr(ErrBadParam, "session closed") } + s.sendBounded = s.io != nil && (timeoutMs != 0 || abortCh != nil) + if s.sendBounded { + s.sendWait = newReceiveWait(timeoutMs, abortCh) + } + + // One request in flight (every typed call) writes without draining; + // only a pipelining client can deadlock with a server blocked writing + // to it. + inflight := len(s.inflightIDs) + if s.role == RoleClient && hdr.Kind == protocol.KindRequest { + inflight++ + } + s.progressBound = 0 + if s.io != nil && inflight > 1 { + s.progressBound = pendingBound(s.MaxResponsePayloadBytes, s.PacketSize, inflight) + } return framing.SessionSend(framing.SessionSendConfig{ RoleClient: s.role == RoleClient, @@ -26,14 +52,14 @@ func (s *Session) Send(hdr *protocol.Header, payload []byte) error { msg := ensurePipeScratchBuf(&s.sendBuf, packetLen) packetHdr.Encode(msg[:protocol.HeaderSize]) copy(msg[protocol.HeaderSize:], packetPayload) - return rawSendMsg(s.handle, msg[:packetLen]) + return s.writePacket(msg[:packetLen]) }, SendChunk: func(chk protocol.ChunkHeader, chunkPayload []byte) error { pktLen := protocol.HeaderSize + len(chunkPayload) msg := ensurePipeScratchBuf(&s.sendBuf, pktLen) chk.Encode(msg[:protocol.HeaderSize]) copy(msg[protocol.HeaderSize:], chunkPayload) - return rawSendMsg(s.handle, msg[:pktLen]) + return s.writePacket(msg[:pktLen]) }, ErrLimitExceeded: func(msg string) error { return wrapErr(ErrLimitExceeded, msg) }, ErrDuplicateMsgID: func(msg string) error { return wrapErr(ErrDuplicateMsgID, msg) }, diff --git a/src/go/pkg/netipc/transport/windows/pipe_session.go b/src/go/pkg/netipc/transport/windows/pipe_session.go index 6d74a985..dce75360 100644 --- a/src/go/pkg/netipc/transport/windows/pipe_session.go +++ b/src/go/pkg/netipc/transport/windows/pipe_session.go @@ -3,8 +3,11 @@ package windows import ( + "errors" "syscall" "time" + + "github.com/netdata/plugin-ipc/go/pkg/netipc/internal/monotime" ) // Role distinguishes client vs server sessions. @@ -60,6 +63,25 @@ type Session struct { pktBuf []byte inflightIDs map[uint64]struct{} + + // Overlapped state of a client handle; nil on server sessions, whose + // handles are synchronous. + io *pipeIO + // Messages read while a pipelined send waited for its write. + pending pendingQueue + // Queue bound of the send in progress; 0 writes without draining. + progressBound int + // sendWait bounds the send in progress when sendBounded is set; + // otherwise the send waits without a deadline. + sendWait receiveWait + sendBounded bool + + // Receive state of the call in progress, read by closures built once + // per session (initReceiveFns), so a receive allocates nothing. + recvWait receiveWait + recvControl bool + recvPacketFn func([]byte) (int, error) + failInflights func() } func (s *Session) failAllInflight() { @@ -69,11 +91,20 @@ func (s *Session) failAllInflight() { clear(s.inflightIDs) } -// Handle returns the raw HANDLE for WaitForSingleObject integration. +// Handle returns the raw pipe HANDLE. It is not a readability signal: +// a pipe handle is signalled by I/O completion, not by arriving data. Use +// WaitReadable, and check HasPending first: a pipelined send can read +// responses into the session, where the pipe no longer shows them. func (s *Session) Handle() syscall.Handle { return s.handle } +// HasPending reports whether the next Receive can return a message read +// earlier by a pipelined send, without the pipe becoming readable. +func (s *Session) HasPending() bool { + return s.pending.queued() +} + // Role returns the session role. func (s *Session) Role() Role { return s.role @@ -90,8 +121,11 @@ func (s *Session) WaitReadable(timeoutMs uint32) (bool, error) { if s.handle == syscall.InvalidHandle { return false, wrapErr(ErrBadParam, "session closed") } + if s.pending.queued() { + return true, nil + } - deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) + deadline := monotime.After(time.Duration(timeoutMs) * time.Millisecond) yielded := false for { available, err := peekNamedPipeAvailable(s.handle) @@ -105,7 +139,7 @@ func (s *Session) WaitReadable(timeoutMs uint32) (bool, error) { if available > 0 { return true, nil } - if !time.Now().Before(deadline) { + if monotime.Now() >= deadline { return false, nil } if !yielded { @@ -123,7 +157,7 @@ func (s *Session) WaitReadable(timeoutMs uint32) (bool, error) { if available > 0 { return true, nil } - if !time.Now().Before(deadline) { + if monotime.Now() >= deadline { return false, nil } } @@ -136,37 +170,132 @@ func (s *Session) WaitReadable(timeoutMs uint32) (bool, error) { // Close closes the session and releases resources. func (s *Session) Close() { if s.handle != syscall.InvalidHandle { - if s.role == RoleServer { - flushFileBuffers(s.handle) - disconnectNamedPipe(s.handle) - } + // Close only. The peer still reads every message written before the + // close, then sees ERROR_BROKEN_PIPE. FlushFileBuffers would block on + // a peer that stopped reading, and neither CancelSynchronousIo nor + // DisconnectNamedPipe from another thread releases it. syscall.CloseHandle(s.handle) s.handle = syscall.InvalidHandle } + // No I/O stays posted between calls, so the events can go. + s.io.close() + s.io = nil + s.pending = pendingQueue{} s.recvBuf = nil s.sendBuf = nil s.pktBuf = nil s.failAllInflight() } +// ConnectBusyWaitMs bounds how long Connect waits for a free pipe instance +// when every instance is busy (ERROR_PIPE_BUSY). +const ConnectBusyWaitMs = 1000 + +// connectBusyHook, when set by tests, runs whenever a connect finds every +// pipe instance busy, before it waits. +var connectBusyHook func() + +// connectWaitedHook, when set by tests, runs whenever waitNamedPipe reports a +// free instance, before the connect retries, so a test can take it first. +var connectWaitedHook func() + +// createClientPipe opens with SECURITY_IDENTIFICATION, so the server can +// identify the client but never act as it, and overlapped, so a pipelining +// send can read while its write waits. +func createClientPipe(name *uint16) (syscall.Handle, error) { + return syscall.CreateFile(name, _GENERIC_READ|_GENERIC_WRITE, 0, nil, _OPEN_EXISTING, + _SECURITY_SQOS_PRESENT|_SECURITY_IDENTIFICATION|_FILE_FLAG_OVERLAPPED, 0) +} + +// connectBusySliceMs is the longest single busy-pipe wait, so an abort ends +// it promptly. +const connectBusySliceMs = 100 + +// openClientPipe opens the client end of a pipe. A server has no free pipe +// instance between accepting one client and creating the next instance, and +// connects in that window fail with ERROR_PIPE_BUSY. Wait, bounded by +// ConnectBusyWaitMs and the caller's deadline, for an instance and retry: +// waitNamedPipe does not reserve it, so another client may take it first. +func openClientPipe(name *uint16, wait receiveWait) (syscall.Handle, error) { + if _, werr := wait.waitMs(); werr != nil { + return syscall.InvalidHandle, werr + } + handle, err := createClientPipe(name) + if errno, ok := err.(syscall.Errno); !ok || errno != _ERROR_PIPE_BUSY { + if err != nil { + return handle, wrapErr(ErrConnect, err.Error()) + } + return handle, nil + } + if connectBusyHook != nil { + connectBusyHook() + } + + busyDeadline := monotime.After(ConnectBusyWaitMs * time.Millisecond) + if !wait.infinite && wait.deadline < busyDeadline { + busyDeadline = wait.deadline + } + for { + if _, werr := wait.waitMs(); errors.Is(werr, ErrAborted) { + return syscall.InvalidHandle, werr + } + now := monotime.Now() + left := time.Duration(busyDeadline - now) + if left <= 0 { + if !wait.infinite && now >= wait.deadline { + return syscall.InvalidHandle, ErrTimeout + } + return syscall.InvalidHandle, wrapErr(ErrConnect, err.Error()) + } + slice := boundedReceiveWaitMs(left, connectBusySliceMs) + werr := waitNamedPipe(name, slice) + if werr == nil { + if connectWaitedHook != nil { + connectWaitedHook() + } + handle, err = createClientPipe(name) + if err == nil { + return handle, nil + } + if errno, ok := err.(syscall.Errno); !ok || errno != _ERROR_PIPE_BUSY { + return handle, wrapErr(ErrConnect, err.Error()) + } + // Another client took the instance first. + continue + } + if errno, ok := werr.(syscall.Errno); !ok || errno != _ERROR_SEM_TIMEOUT { + return syscall.InvalidHandle, wrapErr(ErrConnect, werr.Error()) + } + } +} + // Connect establishes a session to a server pipe derived from runDir + serviceName. +// When every pipe instance is busy it waits up to ConnectBusyWaitMs for one. func Connect(runDir, serviceName string, config *ClientConfig) (*Session, error) { + return ConnectTimeout(runDir, serviceName, config, 0, nil) +} + +// ConnectTimeout is Connect bounded by timeoutMs: the busy-pipe wait and the +// wait for HELLO_ACK together end with ErrTimeout after timeoutMs (0 = no +// deadline), and with ErrAborted once abortCh is closed (nil = no abort). +func ConnectTimeout(runDir, serviceName string, config *ClientConfig, timeoutMs uint32, abortCh <-chan struct{}) (*Session, error) { + wait := newReceiveWait(timeoutMs, abortCh) pipeName, err := BuildPipeName(runDir, serviceName) if err != nil { return nil, err } - handle, err := syscall.CreateFile( - &pipeName[0], - _GENERIC_READ|_GENERIC_WRITE, - 0, - nil, - _OPEN_EXISTING, - 0, - 0, - ) + handle, err := openClientPipe(&pipeName[0], wait) if err != nil { - return nil, wrapErr(ErrConnect, err.Error()) + return nil, err + } + + // Whoever created the pipe name first owns it. Send HELLO and the auth + // token only to a server running as this account, SYSTEM or + // Administrators; other accounts cannot take those as owner. + if !ownerTrusted(handle) { + syscall.CloseHandle(handle) + return nil, ErrUntrustedServer } mode := uint32(_PIPE_READMODE_MESSAGE) @@ -175,9 +304,15 @@ func Connect(runDir, serviceName string, config *ClientConfig) (*Session, error) return nil, wrapErr(ErrConnect, "SetNamedPipeHandleState: "+err.Error()) } - session, herr := clientHandshake(handle, config) + io, err := newPipeIO() + if err != nil { + syscall.CloseHandle(handle) + return nil, err + } + session, herr := clientHandshake(handle, io, config, wait) if herr != nil { syscall.CloseHandle(handle) + io.close() return nil, herr } return session, nil diff --git a/src/go/pkg/netipc/transport/windows/security.go b/src/go/pkg/netipc/transport/windows/security.go new file mode 100644 index 00000000..82ea3f09 --- /dev/null +++ b/src/go/pkg/netipc/transport/windows/security.go @@ -0,0 +1,119 @@ +//go:build windows + +package windows + +import ( + "syscall" + "unsafe" +) + +// Access policy shared by the Windows transports: every NetIPC pipe, SHM +// mapping and event grants access only to SYSTEM, Administrators and the +// account of the process that creates it. Clients accept a server only when +// the pipe owner is one of those same principals. + +const ( + _PIPE_REJECT_REMOTE_CLIENTS = 0x00000008 + _SECURITY_SQOS_PRESENT = 0x00100000 + _SECURITY_IDENTIFICATION = 0x00010000 + _SE_KERNEL_OBJECT = 6 + _OWNER_SECURITY_INFORMATION = 0x1 + _DACL_SECURITY_INFORMATION = 0x4 + _SDDL_REVISION_1 = 1 + systemSID = "S-1-5-18" + administratorsSID = "S-1-5-32-544" +) + +var ( + modadvapi32 = syscall.NewLazyDLL("advapi32.dll") + + procConvertStringSDToSD = modadvapi32.NewProc("ConvertStringSecurityDescriptorToSecurityDescriptorW") + procGetSecurityInfo = modadvapi32.NewProc("GetSecurityInfo") +) + +// trustedOwnerForTests, when set by tests, replaces the trusted owners with +// this one SID string. +var trustedOwnerForTests string + +// processUserSID returns the SID string of this process's account. +func processUserSID() (string, error) { + token, err := syscall.OpenCurrentProcessToken() + if err != nil { + return "", err + } + defer token.Close() + user, err := token.GetTokenUser() + if err != nil { + return "", err + } + return user.User.Sid.String() +} + +// ownerOnlyAttributes holds security attributes with the owner-only DACL. +// Call free once the object is created. +type ownerOnlyAttributes struct { + sa syscall.SecurityAttributes +} + +func newOwnerOnlyAttributes() (*ownerOnlyAttributes, error) { + sid, err := processUserSID() + if err != nil { + return nil, err + } + // Protected DACL: nothing is inherited, and no ACE for Everyone or + // Anonymous, so other accounts cannot even open an instance read-only. + sddl, err := syscall.UTF16PtrFromString("D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;" + sid + ")") + if err != nil { + return nil, err + } + var sd uintptr + r, _, callErr := procConvertStringSDToSD.Call( + uintptr(unsafe.Pointer(sddl)), _SDDL_REVISION_1, uintptr(unsafe.Pointer(&sd)), 0) + if r == 0 { + return nil, callErr + } + a := &ownerOnlyAttributes{} + a.sa.Length = uint32(unsafe.Sizeof(a.sa)) + a.sa.SecurityDescriptor = sd + return a, nil +} + +func (a *ownerOnlyAttributes) ptr() uintptr { return uintptr(unsafe.Pointer(&a.sa)) } + +func (a *ownerOnlyAttributes) free() { + if a.sa.SecurityDescriptor != 0 { + syscall.LocalFree(syscall.Handle(a.sa.SecurityDescriptor)) + a.sa.SecurityDescriptor = 0 + } +} + +// objectOwnerSID returns the object's owner as a SID string. +func objectOwnerSID(handle syscall.Handle) (string, error) { + var owner *syscall.SID + var sd uintptr + r, _, _ := procGetSecurityInfo.Call(uintptr(handle), _SE_KERNEL_OBJECT, + _OWNER_SECURITY_INFORMATION, uintptr(unsafe.Pointer(&owner)), 0, 0, 0, + uintptr(unsafe.Pointer(&sd))) + if r != 0 { + return "", syscall.Errno(r) + } + defer syscall.LocalFree(syscall.Handle(sd)) + return owner.String() +} + +// ownerTrusted reports whether the object's owner is this process's account, +// SYSTEM or Administrators. +func ownerTrusted(handle syscall.Handle) bool { + owner, err := objectOwnerSID(handle) + if err != nil { + return false + } + if trustedOwnerForTests != "" { + return owner == trustedOwnerForTests + } + if owner == systemSID || owner == administratorsSID { + return true + } + me, err := processUserSID() + return err == nil && owner == me +} diff --git a/src/go/pkg/netipc/transport/windows/security_test.go b/src/go/pkg/netipc/transport/windows/security_test.go new file mode 100644 index 00000000..b41c8a70 --- /dev/null +++ b/src/go/pkg/netipc/transport/windows/security_test.go @@ -0,0 +1,220 @@ +//go:build windows + +package windows + +import ( + "errors" + "runtime" + "syscall" + "testing" + "unsafe" +) + +var ( + procConvertSDToStringSD = modadvapi32.NewProc("ConvertSecurityDescriptorToStringSecurityDescriptorW") + procImpersonateNamedPipeClient = modadvapi32.NewProc("ImpersonateNamedPipeClient") + procRevertToSelf = modadvapi32.NewProc("RevertToSelf") + procOpenThreadToken = modadvapi32.NewProc("OpenThreadToken") + procGetCurrentThread = modkernel32.NewProc("GetCurrentThread") +) + +// securityIdentificationLevel is SecurityIdentification in +// SECURITY_IMPERSONATION_LEVEL. +const securityIdentificationLevel = 1 + +// objectDACLSDDL returns the object's DACL in SDDL form. +func objectDACLSDDL(t *testing.T, handle syscall.Handle) string { + t.Helper() + var sd uintptr + r, _, _ := procGetSecurityInfo.Call(uintptr(handle), _SE_KERNEL_OBJECT, + _DACL_SECURITY_INFORMATION, 0, 0, 0, 0, uintptr(unsafe.Pointer(&sd))) + if r != 0 { + t.Fatalf("GetSecurityInfo: %v", syscall.Errno(r)) + } + defer syscall.LocalFree(syscall.Handle(sd)) + return descriptorDACLSDDL(t, sd) +} + +func descriptorDACLSDDL(t *testing.T, sd uintptr) string { + t.Helper() + var str *uint16 + r, _, err := procConvertSDToStringSD.Call(sd, _SDDL_REVISION_1, + _DACL_SECURITY_INFORMATION, uintptr(unsafe.Pointer(&str)), 0) + if r == 0 { + t.Fatalf("ConvertSecurityDescriptorToStringSecurityDescriptor: %v", err) + } + defer syscall.LocalFree(syscall.Handle(unsafe.Pointer(str))) + return syscall.UTF16ToString(unsafe.Slice(str, 512)) +} + +// ownerOnlyDACL returns the expected DACL as Windows prints it. The round +// trip applies Windows' own SID aliases, such as LA for the built-in +// Administrator account that CI runners use. +func ownerOnlyDACL(t *testing.T, mask string) string { + t.Helper() + me, err := processUserSID() + if err != nil { + t.Fatalf("processUserSID: %v", err) + } + sddl, err := syscall.UTF16PtrFromString( + "D:P(A;;" + mask + ";;;SY)(A;;" + mask + ";;;BA)(A;;" + mask + ";;;" + me + ")") + if err != nil { + t.Fatalf("UTF16PtrFromString: %v", err) + } + var sd uintptr + r, _, callErr := procConvertStringSDToSD.Call( + uintptr(unsafe.Pointer(sddl)), _SDDL_REVISION_1, uintptr(unsafe.Pointer(&sd)), 0) + if r == 0 { + t.Fatalf("ConvertStringSecurityDescriptorToSecurityDescriptor: %v", callErr) + } + defer syscall.LocalFree(syscall.Handle(sd)) + return descriptorDACLSDDL(t, sd) +} + +// clientImpersonationLevel returns the impersonation level a pipe client +// granted this server. Impersonation is per thread, so the goroutine stays on +// its thread meanwhile. +func clientImpersonationLevel(pipe syscall.Handle) (uint32, bool) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if r, _, _ := procImpersonateNamedPipeClient.Call(uintptr(pipe)); r == 0 { + return 0, false + } + defer procRevertToSelf.Call() + thread, _, _ := procGetCurrentThread.Call() + var token syscall.Token + if r, _, _ := procOpenThreadToken.Call(thread, syscall.TOKEN_QUERY, 1, + uintptr(unsafe.Pointer(&token))); r == 0 { + return 0, false + } + defer token.Close() + var level, n uint32 + if err := syscall.GetTokenInformation(token, syscall.TokenImpersonationLevel, + (*byte)(unsafe.Pointer(&level)), 4, &n); err != nil { + return 0, false + } + return level, true +} + +func TestPipeDACLOwnerOnly(t *testing.T) { + service := uniquePipeService(t) + listener := startListener(t, testPipeRunDir, service, defaultServerConfig()) + defer listener.Close() + + want := ownerOnlyDACL(t, "FA") + if got := objectDACLSDDL(t, listener.handle); got != want { + t.Fatalf("first instance DACL = %q, want %q", got, want) + } + + // Instances created after an accept carry the same DACL. + pipeName, err := BuildPipeName(testPipeRunDir, service) + if err != nil { + t.Fatalf("BuildPipeName: %v", err) + } + client, err := createClientPipe(&pipeName[0]) + if err != nil { + t.Fatalf("client open: %v", err) + } + defer syscall.CloseHandle(client) + pending, err := listener.AcceptPending() + if err != nil { + t.Fatalf("AcceptPending: %v", err) + } + defer pending.Close() + if got := objectDACLSDDL(t, listener.handle); got != want { + t.Fatalf("next instance DACL = %q, want %q", got, want) + } +} + +func TestPipeRejectsRemoteClients(t *testing.T) { + service := uniquePipeService(t) + listener := startListener(t, testPipeRunDir, service, defaultServerConfig()) + defer listener.Close() + + // The same pipe through the SMB loopback is a remote client. + pipeName, err := BuildPipeName(testPipeRunDir, service) + if err != nil { + t.Fatalf("BuildPipeName: %v", err) + } + remote, err := syscall.UTF16PtrFromString(`\\127.0.0.1\` + syscall.UTF16ToString(pipeName[4:])) + if err != nil { + t.Fatalf("remote name: %v", err) + } + h, err := syscall.CreateFile(remote, _GENERIC_READ|_GENERIC_WRITE, 0, nil, _OPEN_EXISTING, 0, 0) + if err == nil { + syscall.CloseHandle(h) + t.Fatal("remote open succeeded, want refused") + } +} + +func TestClientRejectsUntrustedServer(t *testing.T) { + service := uniquePipeService(t) + listener := startListener(t, testPipeRunDir, service, defaultServerConfig()) + + // BUILTIN\Users never owns the test pipe. Nobody accepts, so a client that + // skipped the check fails on the deadline instead of hanging. + trustedOwnerForTests = "S-1-5-32-545" + _, err := ConnectTimeout(testPipeRunDir, service, defaultClientConfigPtr(), 2000, nil) + trustedOwnerForTests = "" + if !errors.Is(err, ErrUntrustedServer) { + t.Fatalf("connect to untrusted owner = %v, want ErrUntrustedServer", err) + } + listener.Close() + + // Trusting the real owner lets the client through, and the server sees + // only an identification-level token for it. + service = uniquePipeService(t) + listener = startListener(t, testPipeRunDir, service, defaultServerConfig()) + defer listener.Close() + owner, err := objectOwnerSID(listener.handle) + if err != nil { + t.Fatalf("pipe owner: %v", err) + } + type result struct { + level uint32 + ok bool + } + levels := make(chan result, 1) + go func() { + session, err := listener.Accept() + if err != nil { + levels <- result{} + return + } + defer session.Close() + level, ok := clientImpersonationLevel(session.handle) + levels <- result{level, ok} + }() + + trustedOwnerForTests = owner + client, err := ConnectTimeout(testPipeRunDir, service, defaultClientConfigPtr(), 10000, nil) + trustedOwnerForTests = "" + if err != nil { + listener.Close() + t.Fatalf("connect to trusted owner: %v", err) + } + defer client.Close() + got := <-levels + if !got.ok || got.level != securityIdentificationLevel { + t.Fatalf("client impersonation level = %d (ok=%v), want SecurityIdentification", got.level, got.ok) + } +} + +func TestWinShmObjectsDACLOwnerOnly(t *testing.T) { + ctx, err := WinShmServerCreate(t.TempDir(), "dacl", 0x4243, 23, WinShmProfileHybrid, 4096, 4096) + if err != nil { + t.Fatalf("WinShmServerCreate: %v", err) + } + defer ctx.WinShmDestroy() + + // SECTION_ALL_ACCESS and EVENT_ALL_ACCESS. + if got, want := objectDACLSDDL(t, ctx.mapping), ownerOnlyDACL(t, "CCDCLCSWRPSDRCWDWO"); got != want { + t.Fatalf("mapping DACL = %q, want %q", got, want) + } + if got, want := objectDACLSDDL(t, ctx.reqEvent), ownerOnlyDACL(t, "0x1f0003"); got != want { + t.Fatalf("request event DACL = %q, want %q", got, want) + } + if got, want := objectDACLSDDL(t, ctx.respEvent), ownerOnlyDACL(t, "0x1f0003"); got != want { + t.Fatalf("response event DACL = %q, want %q", got, want) + } +} diff --git a/src/go/pkg/netipc/transport/windows/shm.go b/src/go/pkg/netipc/transport/windows/shm.go index 9370ed99..a7584113 100644 --- a/src/go/pkg/netipc/transport/windows/shm.go +++ b/src/go/pkg/netipc/transport/windows/shm.go @@ -194,10 +194,18 @@ func WinShmServerCreate(runDir, serviceName string, authToken, sessionID uint64, respOff := winShmAlignCacheline(reqOff + reqCap) regionSize := uintptr(respOff + respCap) + // Session objects take the same owner-only DACL as the pipe, so other + // accounts cannot open them. + attrs, err := newOwnerOnlyAttributes() + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrWinShmCreateMapping, err) + } + defer attrs.free() + // Create page-file backed mapping r, _, callErr := winShmCreateFileMappingW( uintptr(syscall.InvalidHandle), // page file - 0, // NULL security + attrs.ptr(), uintptr(_PAGE_READWRITE), uintptr(regionSize>>32), uintptr(regionSize&0xFFFFFFFF), @@ -257,7 +265,7 @@ func WinShmServerCreate(runDir, serviceName string, authToken, sessionID uint64, return nil, err } - r, _, callErr := winShmCreateEventW(0, 0, 0, + r, _, callErr := winShmCreateEventW(attrs.ptr(), 0, 0, uintptr(unsafe.Pointer(&reqEventName[0]))) if r == 0 { procUnmapViewOfFile.Call(base) @@ -280,7 +288,7 @@ func WinShmServerCreate(runDir, serviceName string, authToken, sessionID uint64, return nil, err } - r, _, callErr = winShmCreateEventW(0, 0, 0, + r, _, callErr = winShmCreateEventW(attrs.ptr(), 0, 0, uintptr(unsafe.Pointer(&respEventName[0]))) if r == 0 { syscall.CloseHandle(reqEvent) @@ -588,7 +596,9 @@ func (c *WinShmContext) WinShmSend(msg []byte) error { // 4. If HYBRID and peer waiting, signal event if c.profile == WinShmProfileHybrid { if atomic.LoadInt32((*int32)(unsafe.Pointer(&data[peerWaitingOff]))) != 0 { - procSetEvent.Call(uintptr(peerEvent)) + // SyscallN, unlike LazyProc.Call, keeps its arguments on the + // stack: this runs per message. + syscall.SyscallN(procSetEvent.Addr(), uintptr(peerEvent)) } } @@ -698,7 +708,7 @@ func (c *WinShmContext) WinShmReceive(buf []byte, timeoutMs uint32) (int, error) waitMs = uintptr(deadlineMs - elapsed) } - ret, _, _ := procWaitForSingleObj.Call(uintptr(waitEvent), waitMs) + ret, _, _ := syscall.SyscallN(procWaitForSingleObj.Addr(), uintptr(waitEvent), waitMs) atomic.StoreInt32((*int32)(unsafe.Pointer(&data[selfWaitingOff])), 0) mlen, ok, err = winShmCopyObserved(data, seqOff, lenOff, expectedSeq, areaOff, maxCopy, buf) @@ -780,6 +790,10 @@ func (c *WinShmContext) WinShmReceive(buf []byte, timeoutMs uint32) (int, error) if int(mlen) > maxCopy { return int(mlen), ErrWinShmMsgTooLarge } + // Send rejects zero-length messages, so one here is corruption. + if mlen == 0 { + return 0, fmt.Errorf("%w: zero-length message", ErrWinShmBadHeader) + } return int(mlen), nil } diff --git a/src/go/pkg/netipc/transport/windows/shm_test.go b/src/go/pkg/netipc/transport/windows/shm_test.go index 8f872a36..40174410 100644 --- a/src/go/pkg/netipc/transport/windows/shm_test.go +++ b/src/go/pkg/netipc/transport/windows/shm_test.go @@ -774,3 +774,34 @@ func testWinShmReceiveIgnoresSpuriousWake(t *testing.T, serverReceives bool) { t.Fatal("second WinShmReceive timed out") } } + +// Send rejects zero-length messages, so a slot published with length 0 is +// corruption: receive reports ErrWinShmBadHeader instead of an empty message. +func TestWinShmReceiveRejectsZeroLength(t *testing.T) { + runDir := t.TempDir() + service := "zero-length-slot" + const authToken uint64 = 0x8917 + const sessionID uint64 = 29 + + server, err := WinShmServerCreate(runDir, service, authToken, sessionID, WinShmProfileHybrid, 4096, 4096) + if err != nil { + t.Fatalf("WinShmServerCreate failed: %v", err) + } + defer server.WinShmDestroy() + + client, err := WinShmClientAttach(runDir, service, authToken, sessionID, WinShmProfileHybrid) + if err != nil { + t.Fatalf("WinShmClientAttach failed: %v", err) + } + defer client.WinShmClose() + + // Publish the response slot length as 0 before its sequence advances. + data := unsafe.Slice((*byte)(unsafe.Pointer(server.base)), server.size) + atomic.StoreInt32((*int32)(unsafe.Pointer(&data[wshOFFRespLen])), 0) + atomic.AddInt64((*int64)(unsafe.Pointer(&data[wshOFFRespSeq])), 1) + + n, err := client.WinShmReceive(make([]byte, 8192), 1000) + if !errors.Is(err, ErrWinShmBadHeader) || n != 0 { + t.Fatalf("zero-length slot: n=%d err=%v, want 0 and ErrWinShmBadHeader", n, err) + } +} diff --git a/src/libnetdata/netipc/include/netipc/netipc_named_pipe.h b/src/libnetdata/netipc/include/netipc/netipc_named_pipe.h index e565f9fa..e28bd9e9 100644 --- a/src/libnetdata/netipc/include/netipc/netipc_named_pipe.h +++ b/src/libnetdata/netipc/include/netipc/netipc_named_pipe.h @@ -39,6 +39,10 @@ extern "C" { /* Max concurrent pipe instances */ #define NIPC_NP_MAX_INSTANCES PIPE_UNLIMITED_INSTANCES +/* How long a connect waits for a free pipe instance when every instance is + * busy (ERROR_PIPE_BUSY). */ +#define NIPC_NP_CONNECT_BUSY_WAIT_MS 1000 + /* FNV-1a 64-bit constants */ #define NIPC_FNV1A_OFFSET_BASIS 0xcbf29ce484222325ULL #define NIPC_FNV1A_PRIME 0x00000100000001B3ULL @@ -70,6 +74,8 @@ typedef enum { NIPC_NP_ERR_DISCONNECTED, /* peer disconnected (graceful) */ NIPC_NP_ERR_TIMEOUT, /* receive deadline expired */ NIPC_NP_ERR_ABORTED, /* receive aborted by caller */ + NIPC_NP_ERR_UNTRUSTED_SERVER, /* pipe owner is not this account, + SYSTEM or Administrators */ } nipc_np_error_t; /* ------------------------------------------------------------------ */ @@ -116,7 +122,7 @@ typedef struct { /* ------------------------------------------------------------------ */ typedef struct { - HANDLE pipe; /* connected pipe handle (native wait object) */ + HANDLE pipe; /* connected pipe handle */ nipc_np_role_t role; /* Negotiated limits */ @@ -136,6 +142,20 @@ typedef struct { uint64_t *inflight_ids; uint32_t inflight_count; uint32_t inflight_capacity; + + /* Events of the overlapped client handle's reads and writes; NULL on + * server sessions, whose handles are synchronous. */ + HANDLE read_event; + HANDLE write_event; + + /* Messages read while a pipelined send waited for its write, each a + * 4-byte length then its bytes; receive returns them first. */ + uint8_t *pending_buf; + size_t pending_len; + size_t pending_head; + size_t pending_cap; + /* Queue bound of the send in progress; 0 writes without draining. */ + size_t progress_bound; } nipc_np_session_t; /* ------------------------------------------------------------------ */ @@ -185,22 +205,77 @@ nipc_np_error_t nipc_np_listen(const char *run_dir, * Accept one client on a listener. Performs the full handshake. * session_id is placed into the hello-ack so the client can attach * to the correct per-session SHM region. - * Blocks until a client connects and the handshake completes. + * Blocks until a client connects; the handshake then waits at most + * NIPC_SERVER_HANDSHAKE_TIMEOUT_MS for HELLO. * After accepting, creates a new pipe instance for subsequent clients. */ nipc_np_error_t nipc_np_accept(nipc_np_listener_t *listener, uint64_t session_id, nipc_np_session_t *out); +/* + * Accept one connection without handshaking, so the caller can run the + * handshake on another thread. Blocks until a client connects, then + * creates a new pipe instance for subsequent clients. The caller owns + * *pipe_out and passes it to nipc_np_accept_handshake(). + */ +nipc_np_error_t nipc_np_accept_pending(nipc_np_listener_t *listener, + HANDLE *pipe_out); + +/* + * Complete the server side of the handshake on a pipe returned by + * nipc_np_accept_pending(). Waits at most timeout_ms for HELLO + * (NIPC_NP_ERR_TIMEOUT); 0 waits without a deadline. Takes ownership of + * pipe: on success it belongs to *out, on failure it is disconnected + * and closed. + */ +nipc_np_error_t nipc_np_accept_handshake(HANDLE pipe, + const nipc_np_server_config_t *config, + uint64_t session_id, + uint32_t timeout_ms, + nipc_np_session_t *out); + +/* + * Called by the server handshake once a profile is selected and the + * payload limits are agreed, before HELLO_ACK is sent, so the caller can + * set up profile resources (an SHM region) only for sessions that use + * them. Returning false drops that profile and the handshake selects + * again; with no profile left the client is rejected as unsupported. + */ +typedef bool (*nipc_np_profile_prepare_fn)(void *user, uint32_t profile, + uint32_t max_request_payload_bytes, + uint32_t max_response_payload_bytes); + +/* nipc_np_accept_handshake() with a prepare step; prepare may be NULL. */ +nipc_np_error_t nipc_np_accept_handshake_prepared( + HANDLE pipe, const nipc_np_server_config_t *config, uint64_t session_id, + uint32_t timeout_ms, nipc_np_profile_prepare_fn prepare, void *user, + nipc_np_session_t *out); + /* * Connect to a server pipe derived from run_dir + service_name. * Performs the full handshake. Blocks until connected + handshake done. + * When every pipe instance is busy, waits up to NIPC_NP_CONNECT_BUSY_WAIT_MS + * for a free one. */ nipc_np_error_t nipc_np_connect(const char *run_dir, const char *service_name, const nipc_np_client_config_t *config, nipc_np_session_t *out); +/* + * nipc_np_connect() bounded by timeout_ms: the busy-pipe wait and the wait + * for HELLO_ACK together end with NIPC_NP_ERR_TIMEOUT after timeout_ms + * (0 = no deadline), and with NIPC_NP_ERR_ABORTED once abort_event is + * signaled (NULL = no abort). + */ +nipc_np_error_t nipc_np_connect_timeout(const char *run_dir, + const char *service_name, + const nipc_np_client_config_t *config, + uint32_t timeout_ms, + HANDLE abort_event, + nipc_np_session_t *out); + /* * Close a session. Releases pipe handle and internal buffers. * Safe to call on a zero-initialized session (no-op). @@ -229,6 +304,22 @@ nipc_np_error_t nipc_np_send(nipc_np_session_t *session, const void *payload, size_t payload_len); +/* + * nipc_np_send() with an optional deadline and abort event for a client + * session. timeout_ms == 0 waits without a deadline; abort_event == NULL + * disables abort waiting. If the deadline passes or abort_event is signaled + * before a write completes, the write is cancelled, the session is closed + * (later calls fail with NIPC_NP_ERR_BAD_PARAM) and the call returns + * NIPC_NP_ERR_TIMEOUT or NIPC_NP_ERR_ABORTED, since part of the message may + * already be on the wire. + */ +nipc_np_error_t nipc_np_send_timeout(nipc_np_session_t *session, + nipc_header_t *hdr, + const void *payload, + size_t payload_len, + uint32_t timeout_ms, + HANDLE abort_event); + /* * Receive one logical message. Blocks until a complete message arrives. * @@ -266,7 +357,8 @@ nipc_np_error_t nipc_np_receive_timeout(nipc_np_session_t *session, * * On success: * - returns NIPC_NP_OK - * - sets *readable_out to true if bytes are available + * - sets *readable_out to true if bytes are available or a pipelined + * send queued messages (nipc_np_session_has_pending) * - sets *readable_out to false on timeout with no pending bytes * * Returns NIPC_NP_ERR_DISCONNECTED if the peer has gone away while waiting. @@ -279,7 +371,21 @@ nipc_np_error_t nipc_np_wait_readable(nipc_np_session_t *session, /* Utility */ /* ------------------------------------------------------------------ */ -/* Get the HANDLE for WaitForSingleObject / WaitForMultipleObjects. */ +/* + * Whether the next receive can return a message read earlier by a + * pipelined send, without the pipe becoming readable. + */ +static inline bool nipc_np_session_has_pending(const nipc_np_session_t *s) { + return s->pending_head < s->pending_len; +} + +/* + * The raw pipe HANDLE. It is not a readability signal: a pipe handle is + * signalled by I/O completion, not by arriving data. Use + * nipc_np_wait_readable(), and check nipc_np_session_has_pending() first: a + * pipelined send can read responses into the session, where the pipe no + * longer shows them. + */ static inline HANDLE nipc_np_session_handle(const nipc_np_session_t *s) { return s->pipe; } @@ -288,6 +394,27 @@ static inline HANDLE nipc_np_listener_handle(const nipc_np_listener_t *l) { return l->pipe; } +#ifdef NIPC_INTERNAL_TESTING +/* Call hook(user) whenever a connect finds every pipe instance busy, before + * it waits for one. NULL removes the hook. */ +void nipc_np_set_connect_busy_hook_for_tests(void (*hook)(void *user), + void *user); + +/* Call hook(user) whenever WaitNamedPipeW reports a free instance, before + * the connect retries CreateFileW, so a test can take that instance first. + * NULL removes the hook. */ +void nipc_np_set_connect_waited_hook_for_tests(void (*hook)(void *user), + void *user); + +/* Fail the next `count` creations of a replacement pipe instance in accept, + * as CreateNamedPipeW can under resource exhaustion. */ +void nipc_np_fail_next_instances_for_tests(int count); + +/* Clients trust only pipes owned by this SID string instead of their own + * account, SYSTEM and Administrators. NULL restores the default. */ +void nipc_np_set_trusted_owner_for_tests(const char *sid); +#endif + #ifdef __cplusplus } #endif diff --git a/src/libnetdata/netipc/include/netipc/netipc_protocol.h b/src/libnetdata/netipc/include/netipc/netipc_protocol.h index 8f9278b8..87c08140 100644 --- a/src/libnetdata/netipc/include/netipc/netipc_protocol.h +++ b/src/libnetdata/netipc/include/netipc/netipc_protocol.h @@ -52,6 +52,10 @@ extern "C" { #define NIPC_CODE_HELLO 1u #define NIPC_CODE_HELLO_ACK 2u +/* How long an accepted connection may take to send HELLO before the server + * drops it. Bounds what a stalled client can hold on the server. */ +#define NIPC_SERVER_HANDSHAKE_TIMEOUT_MS 5000u + /* Method codes */ #define NIPC_METHOD_INCREMENT 1u #define NIPC_METHOD_CGROUPS_SNAPSHOT 2u @@ -101,6 +105,8 @@ typedef enum { NIPC_ERR_NOT_READY, /* client not connected / service unavailable */ NIPC_ERR_TIMEOUT, /* synchronous call deadline expired */ NIPC_ERR_ABORTED, /* synchronous call aborted by caller */ + NIPC_ERR_DISCONNECTED, /* peer closed the session or its process exited */ + NIPC_ERR_TRANSPORT, /* send/receive I/O failed, not a peer disconnect */ } nipc_error_t; /* ------------------------------------------------------------------ */ diff --git a/src/libnetdata/netipc/include/netipc/netipc_service.h b/src/libnetdata/netipc/include/netipc/netipc_service.h index 23f7c9d9..c5ca7fc1 100644 --- a/src/libnetdata/netipc/include/netipc/netipc_service.h +++ b/src/libnetdata/netipc/include/netipc/netipc_service.h @@ -40,6 +40,13 @@ #define NIPC_CLIENT_CALL_TIMEOUT_DEFAULT_MS 30000u #define NIPC_CLIENT_ABORT_POLL_MS 100u +/* Server poll slice: how often idle workers and the acceptor notice a stop. */ +#define NIPC_SERVER_POLL_DEFAULT_MS 100u +/* How long destroy waits for session workers to finish their current + * message before interrupting their blocking I/O. */ +#define NIPC_SERVER_STOP_GRACE_DEFAULT_MS 1000u +/* How long a client retries attaching a negotiated SHM region. */ +#define NIPC_CLIENT_SHM_ATTACH_TIMEOUT_DEFAULT_MS 5000u #define NIPC_SERVICE_MIN_PAYLOAD_BUFFER_BYTES 1024u #define NIPC_LOGICAL_LOOKUP_ITEMS_DEFAULT 65536u #define NIPC_LOGICAL_LOOKUP_SUBCALLS_DEFAULT 4096u @@ -155,6 +162,10 @@ typedef struct { uint32_t max_logical_lookup_subcalls; uint32_t max_logical_lookup_response_bytes; uint32_t abort_requested; + /* How long to retry attaching a negotiated SHM region; tests shorten it. */ + uint32_t shm_attach_timeout_ms; + /* Monotonic deadline of the call or refresh in progress (internal). */ + uint64_t op_deadline_ms; /* Stats */ uint32_t connect_count; @@ -334,6 +345,12 @@ typedef struct nipc_session_ctx { #endif } nipc_session_ctx_t; +enum { + NIPC_SERVER_TEST_EVENT_HANDSHAKE_STARTED = 1, + NIPC_SERVER_TEST_EVENT_SESSION_EXITED = 2, + NIPC_SERVER_TEST_EVENT_SHM_CREATED = 3, +}; + struct nipc_managed_server { /* Listener */ #if defined(_WIN32) || defined(__MSYS__) @@ -362,8 +379,10 @@ struct nipc_managed_server { volatile LONG running; volatile LONG accept_loop_active; volatile LONG accept_loop_thread_id; + volatile LONG stop_requested; /* set by stop/drain/destroy, never cleared */ #else bool running; + bool stop_requested; /* set by stop/drain/destroy, never cleared */ #endif /* Session tracking */ @@ -387,6 +406,19 @@ struct nipc_managed_server { /* Auth token needed for SHM kernel object naming */ uint64_t auth_token; #endif + + /* Waits. Init sets the production defaults; repo tests shorten them so + * timeout paths run in milliseconds. */ + uint32_t poll_ms; + uint32_t stop_grace_ms; + uint32_t handshake_ms; + + /* Test-only event notifications, NULL in production. Events: + * NIPC_SERVER_TEST_EVENT_HANDSHAKE_STARTED, a worker waits for HELLO; + * NIPC_SERVER_TEST_EVENT_SESSION_EXITED, a worker freed its slot; + * NIPC_SERVER_TEST_EVENT_SHM_CREATED, a session's SHM region exists. */ + void (*test_event_hook)(void *user, int event); + void *test_event_user; }; /* @@ -430,6 +462,26 @@ nipc_server_init_raw_for_tests(nipc_managed_server_t *server, int worker_count, uint16_t expected_method_code, nipc_server_handler_fn handler, void *user); +/* + * Shorten the server waits: the poll slice, the stop grace and the handshake + * deadline. Call after init and before run. + */ +void nipc_server_set_timings_for_tests(nipc_managed_server_t *server, + uint32_t poll_ms, uint32_t stop_grace_ms, + uint32_t handshake_ms); + +/* + * Report server events to fn, called from worker threads. Call after init + * and before run. + */ +void nipc_server_set_event_hook_for_tests(nipc_managed_server_t *server, + void (*fn)(void *user, int event), + void *user); + +/* Shorten the client's SHM attach retry window. */ +void nipc_client_set_shm_attach_timeout_for_tests(nipc_client_ctx_t *ctx, + uint32_t timeout_ms); + nipc_error_t nipc_apps_lookup_remaining_timeout_for_tests( nipc_client_ctx_t *ctx, uint64_t deadline_ms, uint32_t *timeout_out); nipc_error_t nipc_cgroups_lookup_remaining_timeout_for_tests( @@ -480,14 +532,18 @@ void nipc_posix_service_test_fault_clear(void); /* * Run the acceptor loop. Blocking. Accepts clients, reads requests, - * dispatches to the handler, sends responses. + * dispatches to the handler, sends responses. Each connection's handshake + * runs in its worker with a NIPC_SERVER_HANDSHAKE_TIMEOUT_MS deadline; a + * connection over the worker limit is closed before HELLO_ACK. * - * Returns when nipc_server_stop() is called or on fatal error. + * Returns when nipc_server_stop() is called or on fatal error. A stop + * issued before run makes run return at once; the server is single-use. */ void nipc_server_run(nipc_managed_server_t *server); /* - * Signal shutdown. The acceptor loop will exit after current work. + * Signal shutdown. Only signals and wakes the acceptor loop; run owns the + * listener and closes it on exit. */ void nipc_server_stop(nipc_managed_server_t *server); @@ -504,7 +560,8 @@ void nipc_server_stop(nipc_managed_server_t *server); bool nipc_server_drain(nipc_managed_server_t *server, uint32_t timeout_ms); /* - * Cleanup: close listener, free workers. Safe after stop. + * Cleanup: stop, close listener, free workers. Sessions get a 1000 ms grace + * to finish, then their blocking I/O is interrupted until they exit. */ void nipc_server_destroy(nipc_managed_server_t *server); diff --git a/src/libnetdata/netipc/include/netipc/netipc_shm.h b/src/libnetdata/netipc/include/netipc/netipc_shm.h index 51629ad1..bf035ee2 100644 --- a/src/libnetdata/netipc/include/netipc/netipc_shm.h +++ b/src/libnetdata/netipc/include/netipc/netipc_shm.h @@ -1,5 +1,5 @@ /* - * netipc_shm.h - L1 POSIX SHM transport (Linux only). + * netipc_shm.h - L1 POSIX SHM transport (Linux and FreeBSD). * * Shared memory data plane with spin+futex synchronization. * The SHM region carries the same outer protocol envelope as the UDS @@ -54,6 +54,7 @@ typedef enum { NIPC_SHM_ERR_BAD_PARAM, /* invalid argument */ NIPC_SHM_ERR_PEER_DEAD, /* owner process has exited */ NIPC_SHM_ERR_ALLOCATE, /* backing allocation failed; errno preserved */ + NIPC_SHM_ERR_UNSUPPORTED, /* no POSIX SHM on this platform (Linux and FreeBSD only) */ } nipc_shm_error_t; /* ------------------------------------------------------------------ */ @@ -118,7 +119,6 @@ typedef struct { uint64_t local_resp_seq; /* last known resp_seq */ uint32_t spin_tries; /* spin count before futex wait */ - uint32_t owner_generation; /* cached for PID reuse detection */ char path[256]; /* stored for unlink on destroy */ @@ -203,13 +203,6 @@ nipc_shm_error_t nipc_shm_receive(nipc_shm_ctx_t *ctx, size_t *msg_len_out, uint32_t timeout_ms); -/* ------------------------------------------------------------------ */ -/* Utility */ -/* ------------------------------------------------------------------ */ - -/* Check if the region's owner process is still alive. */ -bool nipc_shm_owner_alive(const nipc_shm_ctx_t *ctx); - /* * Scan for and unlink stale per-session SHM files left by a crashed * server. Call once at server startup before accepting connections. diff --git a/src/libnetdata/netipc/include/netipc/netipc_uds.h b/src/libnetdata/netipc/include/netipc/netipc_uds.h index e0b0d7ab..f235c8fc 100644 --- a/src/libnetdata/netipc/include/netipc/netipc_uds.h +++ b/src/libnetdata/netipc/include/netipc/netipc_uds.h @@ -66,7 +66,7 @@ typedef struct { uint32_t max_response_payload_bytes;/* 0 = use default */ uint32_t max_response_batch_items; /* 0 = use default (1) */ uint64_t auth_token; - uint32_t packet_size; /* 0 = auto-detect from SO_SNDBUF */ + uint32_t packet_size; /* 0 = transport limit (SEQPACKET: SO_SNDBUF-32; stream: 256 KiB); capped at 256 KiB */ } nipc_uds_client_config_t; /* ------------------------------------------------------------------ */ @@ -81,7 +81,7 @@ typedef struct { uint32_t max_response_payload_bytes; /* 0 = use default */ uint32_t max_response_batch_items; /* 0 = use default (1) */ uint64_t auth_token; /* expected token from clients */ - uint32_t packet_size; /* 0 = auto-detect from SO_SNDBUF */ + uint32_t packet_size; /* 0 = transport limit (SEQPACKET: SO_SNDBUF-32; stream: 256 KiB); capped at 256 KiB */ int backlog; /* listen backlog, 0 = default (16) */ } nipc_uds_server_config_t; @@ -112,6 +112,18 @@ typedef struct { uint64_t *inflight_ids; uint32_t inflight_count; uint32_t inflight_capacity; + + /* Inbound packets read while a pipelined client send waited for + * buffer space; receive consumes them before reading the socket. */ + uint8_t *pending_buf; + size_t pending_cap; + size_t pending_head; /* next unconsumed byte */ + size_t pending_tail; /* end of the stored bytes */ + + /* Stream baseline: a receive failed after reading part of a message, + * so the stream is off a packet boundary and every later receive + * fails. */ + bool recv_failed; } nipc_uds_session_t; /* ------------------------------------------------------------------ */ @@ -122,6 +134,8 @@ typedef struct { int fd; /* listening socket */ nipc_uds_server_config_t config; char path[108]; /* stored for cleanup */ + bool has_lock; /* lock_fd holds the endpoint lock */ + int lock_fd; /* {service_name}.lock, flock'ed */ } nipc_uds_listener_t; /* ------------------------------------------------------------------ */ @@ -130,8 +144,11 @@ typedef struct { /* * Create a listener on {run_dir}/{service_name}.sock. - * Performs stale endpoint recovery: if the socket file exists and no - * live server is connected, unlinks and recreates it. + * First takes an exclusive flock on {run_dir}/{service_name}.lock, held + * until nipc_uds_close_listener(); a held lock means another server owns + * the endpoint (NIPC_UDS_ERR_ADDR_IN_USE). With the lock held, an existing + * socket file no live server accepts on is stale: it is unlinked and + * recreated. */ nipc_uds_error_t nipc_uds_listen(const char *run_dir, const char *service_name, @@ -142,12 +159,50 @@ nipc_uds_error_t nipc_uds_listen(const char *run_dir, * Accept one client on a listener. Performs the full handshake. * session_id is placed into the hello-ack so the client can attach * to the correct per-session SHM region. - * Blocks until a client connects and the handshake completes (or fails). + * Blocks until a client connects; the handshake then waits at most + * NIPC_SERVER_HANDSHAKE_TIMEOUT_MS for HELLO. */ nipc_uds_error_t nipc_uds_accept(nipc_uds_listener_t *listener, uint64_t session_id, nipc_uds_session_t *out); +/* + * Accept one connection without handshaking, so the caller can run the + * handshake on another thread. Blocks until a client connects. The + * caller owns *fd_out and passes it to nipc_uds_accept_handshake(). + */ +nipc_uds_error_t nipc_uds_accept_pending(nipc_uds_listener_t *listener, + int *fd_out); + +/* + * Complete the server side of the handshake on a connection returned by + * nipc_uds_accept_pending(). Waits at most timeout_ms for HELLO + * (NIPC_UDS_ERR_TIMEOUT); 0 waits without a deadline. Takes ownership + * of fd: on success it belongs to *out, on failure it is closed. + */ +nipc_uds_error_t nipc_uds_accept_handshake(int fd, + const nipc_uds_server_config_t *config, + uint64_t session_id, + uint32_t timeout_ms, + nipc_uds_session_t *out); + +/* + * Called by the server handshake once a profile is selected and the + * payload limits are agreed, before HELLO_ACK is sent, so the caller can + * set up profile resources (an SHM region) only for sessions that use + * them. Returning false drops that profile and the handshake selects + * again; with no profile left the client is rejected as unsupported. + */ +typedef bool (*nipc_uds_profile_prepare_fn)(void *user, uint32_t profile, + uint32_t max_request_payload_bytes, + uint32_t max_response_payload_bytes); + +/* nipc_uds_accept_handshake() with a prepare step; prepare may be NULL. */ +nipc_uds_error_t nipc_uds_accept_handshake_prepared( + int fd, const nipc_uds_server_config_t *config, uint64_t session_id, + uint32_t timeout_ms, nipc_uds_profile_prepare_fn prepare, void *user, + nipc_uds_session_t *out); + /* * Connect to a server at {run_dir}/{service_name}.sock. * Performs the full handshake. Blocks until connected + handshake done. @@ -157,6 +212,19 @@ nipc_uds_error_t nipc_uds_connect(const char *run_dir, const nipc_uds_client_config_t *config, nipc_uds_session_t *out); +/* + * nipc_uds_connect() bounded by timeout_ms: connect and the wait for + * HELLO_ACK together end with NIPC_UDS_ERR_TIMEOUT after timeout_ms + * (0 = no deadline), and with NIPC_UDS_ERR_ABORTED once abort_fd becomes + * readable (-1 = no abort). + */ +nipc_uds_error_t nipc_uds_connect_timeout(const char *run_dir, + const char *service_name, + const nipc_uds_client_config_t *config, + uint32_t timeout_ms, + int abort_fd, + nipc_uds_session_t *out); + /* * Close a session. Releases socket and internal buffers. * Safe to call on a zero-initialized session (no-op). @@ -164,8 +232,15 @@ nipc_uds_error_t nipc_uds_connect(const char *run_dir, void nipc_uds_close_session(nipc_uds_session_t *session); /* - * Close a listener. Stops accepting, closes the socket, unlinks - * the socket file. + * Stop accepting: close the listening socket and unlink the socket file, + * keeping the endpoint lock, so no other server takes the endpoint while + * this one still serves its sessions. Idempotent. + */ +void nipc_uds_stop_listening(nipc_uds_listener_t *listener); + +/* + * Close a listener: stop listening if still listening, then release the + * endpoint lock. Idempotent. */ void nipc_uds_close_listener(nipc_uds_listener_t *listener); @@ -181,13 +256,34 @@ void nipc_uds_close_listener(nipc_uds_listener_t *listener); * If the total message (32 + payload_len) exceeds packet_size, the * message is chunked transparently. * - * Blocks until all chunks are sent or an error occurs. + * Blocks until all chunks are sent or an error occurs. A client with other + * requests in flight keeps reading their responses into the session while + * it waits for buffer space, so pipelining never deadlocks with a server + * that is blocked sending to it; a later nipc_uds_receive() returns them. + * Inbound bytes beyond what the in-flight responses can occupy fail the + * session with NIPC_UDS_ERR_PROTOCOL. */ nipc_uds_error_t nipc_uds_send(nipc_uds_session_t *session, nipc_header_t *hdr, const void *payload, size_t payload_len); +/* + * nipc_uds_send() with an optional deadline and abort fd for a client + * session. timeout_ms == 0 waits without a deadline; abort_fd < 0 disables + * abort waiting. While the socket lacks buffer space the send reads the + * peer's bytes as a pipelined send does. If the deadline passes or abort_fd + * becomes readable before the message is sent, returns NIPC_UDS_ERR_TIMEOUT + * or NIPC_UDS_ERR_ABORTED and shuts the session down, since part of the + * message may already be on the wire. + */ +nipc_uds_error_t nipc_uds_send_timeout(nipc_uds_session_t *session, + nipc_header_t *hdr, + const void *payload, + size_t payload_len, + uint32_t timeout_ms, + int abort_fd); + /* * Receive one logical message. Blocks until a complete message arrives. * @@ -226,15 +322,34 @@ nipc_uds_error_t nipc_uds_receive_timeout(nipc_uds_session_t *session, /* Utility */ /* ------------------------------------------------------------------ */ -/* Get the fd for poll/epoll integration. */ +/* Get the fd for poll/epoll integration. A pipelined send can read + * responses into the session's pending queue, where the fd no longer shows + * them: check nipc_uds_session_has_pending() before waiting on the fd. */ static inline int nipc_uds_session_fd(const nipc_uds_session_t *s) { return s->fd; } +/* True when the next receive can return data read earlier by a pipelined + * send, without the fd becoming readable. */ +static inline bool nipc_uds_session_has_pending(const nipc_uds_session_t *s) { + return !s->recv_failed && s->pending_tail > s->pending_head; +} + static inline int nipc_uds_listener_fd(const nipc_uds_listener_t *l) { return l->fd; } +#ifdef NIPC_INTERNAL_TESTING +/* Run hook(user) inside nipc_uds_listen() between bind() and listen(), so + * tests can hold a listener in that window. Process-wide; NULL clears. */ +void nipc_uds_set_listen_hook_for_tests(void (*hook)(void *user), void *user); + +/* Run hook(tmp_path, user) just before a new endpoint lock file is linked to + * its final name. Process-wide; NULL clears. */ +void nipc_uds_set_lock_publish_hook_for_tests( + void (*hook)(const char *tmp_path, void *user), void *user); +#endif + #ifdef __cplusplus } #endif diff --git a/src/libnetdata/netipc/src/service/netipc_service.c b/src/libnetdata/netipc/src/service/netipc_service.c index 001c023f..781c2129 100644 --- a/src/libnetdata/netipc/src/service/netipc_service.c +++ b/src/libnetdata/netipc/src/service/netipc_service.c @@ -26,12 +26,6 @@ #include #include -#define CLIENT_SHM_ATTACH_RETRY_INTERVAL_MS 5u -#define CLIENT_SHM_ATTACH_RETRY_TIMEOUT_MS 5000u -#define CLIENT_CALL_RECONNECT_RETRY_INTERVAL_MS 5u -#define CLIENT_CALL_RECONNECT_DRAIN_MS (SERVER_POLL_TIMEOUT_MS + 50u) -#define CLIENT_CALL_RECONNECT_RETRIES 20u - static uint64_t g_posix_service_test_fault_state = 0; void nipc_service_posix_sleep_us(unsigned int usec) { diff --git a/src/libnetdata/netipc/src/service/netipc_service_apps_lookup.c b/src/libnetdata/netipc/src/service/netipc_service_apps_lookup.c index 24b535c8..eab0b7b0 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_apps_lookup.c +++ b/src/libnetdata/netipc/src/service/netipc_service_apps_lookup.c @@ -75,7 +75,7 @@ static nipc_error_t apps_lookup_do_raw_call_with_retry( .timeout_ms = timeout_ms, }; return nipc_service_platform_call_with_retry(ctx, apps_lookup_raw_call_attempt, - &state); + &state, timeout_ms); } static bool apps_lookup_request_capacity_can_reconnect(nipc_client_ctx_t *ctx, @@ -209,8 +209,12 @@ static nipc_error_t do_apps_lookup_attempt(nipc_client_ctx_t *ctx, if (err == NIPC_ERR_OVERFLOW && start < s->pid_count && apps_lookup_request_size(1, &one_item_size) && apps_lookup_request_capacity_can_reconnect(ctx, one_item_size)) { - err = nipc_service_platform_ensure_request_capacity(ctx, - one_item_size); + uint32_t capacity_timeout; + err = apps_lookup_remaining_timeout(ctx, s->deadline_ms, + &capacity_timeout); + if (err == NIPC_OK) + err = nipc_service_platform_ensure_request_capacity( + ctx, one_item_size, capacity_timeout); if (err == NIPC_OK) continue; } diff --git a/src/libnetdata/netipc/src/service/netipc_service_cgroups_lookup.c b/src/libnetdata/netipc/src/service/netipc_service_cgroups_lookup.c index 11e8c5f7..01c0b935 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_cgroups_lookup.c +++ b/src/libnetdata/netipc/src/service/netipc_service_cgroups_lookup.c @@ -78,7 +78,7 @@ static nipc_error_t cgroups_lookup_do_raw_call_with_retry( .timeout_ms = timeout_ms, }; return nipc_service_platform_call_with_retry( - ctx, cgroups_lookup_raw_call_attempt, &state); + ctx, cgroups_lookup_raw_call_attempt, &state, timeout_ms); } static bool @@ -295,8 +295,12 @@ static nipc_error_t do_cgroups_lookup_attempt(nipc_client_ctx_t *ctx, &one_item_size) && cgroups_lookup_request_capacity_can_reconnect(ctx, one_item_size)) { - err = nipc_service_platform_ensure_request_capacity(ctx, - one_item_size); + uint32_t capacity_timeout; + err = cgroups_lookup_remaining_timeout(ctx, s->deadline_ms, + &capacity_timeout); + if (err == NIPC_OK) + err = nipc_service_platform_ensure_request_capacity( + ctx, one_item_size, capacity_timeout); if (err == NIPC_OK) continue; goto cleanup; diff --git a/src/libnetdata/netipc/src/service/netipc_service_cgroups_snapshot.c b/src/libnetdata/netipc/src/service/netipc_service_cgroups_snapshot.c index 77b94b84..185327a1 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_cgroups_snapshot.c +++ b/src/libnetdata/netipc/src/service/netipc_service_cgroups_snapshot.c @@ -77,7 +77,7 @@ nipc_error_t nipc_client_call_cgroups_snapshot_timeout( .timeout_ms = timeout_ms, }; return nipc_service_platform_call_with_retry( - ctx, do_cgroups_snapshot_attempt, &state); + ctx, do_cgroups_snapshot_attempt, &state, timeout_ms); } nipc_error_t nipc_server_init_typed( diff --git a/src/libnetdata/netipc/src/service/netipc_service_common.c b/src/libnetdata/netipc/src/service/netipc_service_common.c index d6d9fdc2..ce8cf255 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_common.c +++ b/src/libnetdata/netipc/src/service/netipc_service_common.c @@ -1,4 +1,5 @@ #include "netipc_service_common.h" +#include "netipc_service_platform.h" #include "netipc/netipc_protocol.h" @@ -140,6 +141,7 @@ void nipc_service_common_client_init(nipc_client_ctx_t *ctx, ctx->session_valid = false; ctx->shm = NULL; ctx->call_timeout_ms = NIPC_CLIENT_CALL_TIMEOUT_DEFAULT_MS; + ctx->shm_attach_timeout_ms = NIPC_CLIENT_SHM_ATTACH_TIMEOUT_DEFAULT_MS; nipc_service_common_copy_cstr_field(ctx->run_dir, sizeof(ctx->run_dir), run_dir); nipc_service_common_copy_cstr_field(ctx->service_name, @@ -195,10 +197,31 @@ bool nipc_service_common_client_abort_requested(const nipc_client_ctx_t *ctx) { return __atomic_load_n(&ctx->abort_requested, __ATOMIC_ACQUIRE) != 0; } +void nipc_service_common_client_begin_op(nipc_client_ctx_t *ctx, + uint32_t timeout_ms) { + ctx->op_deadline_ms = + nipc_service_platform_monotonic_ms() + + nipc_service_common_client_call_timeout_ms(ctx, timeout_ms); +} + +uint32_t +nipc_service_common_client_op_remaining_ms(const nipc_client_ctx_t *ctx) { + uint64_t now = nipc_service_platform_monotonic_ms(); + if (now >= ctx->op_deadline_ms) + return 0; + uint64_t left = ctx->op_deadline_ms - now; + return left > UINT32_MAX ? UINT32_MAX : (uint32_t)left; +} + bool nipc_service_common_client_refresh( nipc_client_ctx_t *ctx, const nipc_service_common_client_ops_t *ops) { nipc_client_state_t old_state = ctx->state; + /* A pending abort keeps the context offline until it is cleared. */ + if (nipc_service_common_client_abort_requested(ctx)) + return false; + nipc_service_common_client_begin_op(ctx, 0); + switch (ctx->state) { case NIPC_CLIENT_DISCONNECTED: case NIPC_CLIENT_NOT_FOUND: @@ -325,6 +348,12 @@ nipc_error_t nipc_service_common_do_raw_call( if (nipc_service_common_client_abort_requested(ctx)) return NIPC_ERR_ABORTED; + uint32_t remaining = nipc_service_common_client_op_remaining_ms(ctx); + if (remaining == 0) + return NIPC_ERR_TIMEOUT; + if (timeout_ms == 0 || timeout_ms > remaining) + timeout_ms = remaining; + nipc_header_t hdr = {0}; hdr.kind = NIPC_KIND_REQUEST; hdr.code = method_code; @@ -333,10 +362,19 @@ nipc_error_t nipc_service_common_do_raw_call( hdr.message_id = (uint64_t)(ctx->call_count + 1); hdr.transport_status = NIPC_STATUS_OK; - nipc_error_t err = send_fn(ctx, &hdr, request_payload, request_len); + uint64_t attempt_deadline = nipc_service_platform_monotonic_ms() + timeout_ms; + nipc_error_t err = + send_fn(ctx, &hdr, request_payload, request_len, timeout_ms); if (err != NIPC_OK) return err; + /* The send may have waited for buffer space: the receive gets what is + * left of this attempt's budget. */ + uint64_t now = nipc_service_platform_monotonic_ms(); + if (now >= attempt_deadline) + return NIPC_ERR_TIMEOUT; + timeout_ms = (uint32_t)(attempt_deadline - now); + nipc_header_t resp_hdr; err = receive_fn(ctx, ctx->response_buf, ctx->response_buf_size, &resp_hdr, response_payload_out, response_len_out, timeout_ms); @@ -352,13 +390,30 @@ nipc_error_t nipc_service_common_do_raw_call( return nipc_service_common_response_status_to_error(ctx, &resp_hdr); } +/* A reconnect inside a call failed: report why the call ended. */ +static nipc_error_t call_reconnect_failure(const nipc_client_ctx_t *ctx, + nipc_error_t err) { + if (nipc_service_common_client_abort_requested(ctx)) + return NIPC_ERR_ABORTED; + if (nipc_service_common_client_op_remaining_ms(ctx) == 0) + return NIPC_ERR_TIMEOUT; + return err; +} + +static void call_mark_broken(nipc_client_ctx_t *ctx, + const nipc_service_common_client_ops_t *ops) { + ops->disconnect(ctx); + ctx->state = NIPC_CLIENT_BROKEN; +} + nipc_error_t nipc_service_common_call_with_retry( nipc_client_ctx_t *ctx, nipc_service_common_attempt_fn attempt, void *state, - const nipc_service_common_client_ops_t *ops) { + uint32_t timeout_ms, const nipc_service_common_client_ops_t *ops) { if (ctx->state != NIPC_CLIENT_READY) { ctx->error_count++; return NIPC_ERR_NOT_READY; } + nipc_service_common_client_begin_op(ctx, timeout_ms); int overflow_retries = 0; for (;;) { @@ -374,42 +429,34 @@ nipc_error_t nipc_service_common_call_with_retry( } if (err == NIPC_ERR_TIMEOUT || err == NIPC_ERR_ABORTED) { - ops->disconnect(ctx); - ctx->state = NIPC_CLIENT_BROKEN; + call_mark_broken(ctx, ops); ctx->error_count++; return err; } if (err != NIPC_ERR_OVERFLOW) { - ops->disconnect(ctx); - ctx->state = NIPC_CLIENT_BROKEN; - if (ops->reconnect_drain_ms > 0 && ops->sleep_ms) - ops->sleep_ms(ops->reconnect_drain_ms); + call_mark_broken(ctx, ops); if (!ops->reconnect_for_call(ctx)) { ctx->error_count++; - return err; + return call_reconnect_failure(ctx, err); } ctx->reconnect_count++; - if (ops->sleep_ms && ops->reconnect_retry_interval_ms > 0) - ops->sleep_ms(ops->reconnect_retry_interval_ms); err = attempt(ctx, state); if (err == NIPC_OK) { ctx->call_count++; return NIPC_OK; } - ops->disconnect(ctx); - ctx->state = NIPC_CLIENT_BROKEN; + call_mark_broken(ctx, ops); ctx->error_count++; return err; } - ops->disconnect(ctx); - ctx->state = NIPC_CLIENT_BROKEN; + call_mark_broken(ctx, ops); if (!ops->reconnect_for_call(ctx)) { ctx->error_count++; - return err; + return call_reconnect_failure(ctx, err); } ctx->reconnect_count++; @@ -417,33 +464,28 @@ nipc_error_t nipc_service_common_call_with_retry( ctx->session.max_response_payload_bytes <= prev_resp && ctx->transport_config.max_request_payload_bytes <= prev_cfg_req && ctx->transport_config.max_response_payload_bytes <= prev_cfg_resp) { - ops->disconnect(ctx); - ctx->state = NIPC_CLIENT_BROKEN; + call_mark_broken(ctx, ops); ctx->error_count++; return err; } if (++overflow_retries >= 8) { - ops->disconnect(ctx); - ctx->state = NIPC_CLIENT_BROKEN; + call_mark_broken(ctx, ops); ctx->error_count++; return err; } - if (ops->sleep_ms && ops->reconnect_retry_interval_ms > 0) - ops->sleep_ms(ops->reconnect_retry_interval_ms); } } nipc_error_t nipc_service_common_client_ensure_request_capacity( - nipc_client_ctx_t *ctx, size_t required_payload_bytes, + nipc_client_ctx_t *ctx, size_t required_payload_bytes, uint32_t timeout_ms, const nipc_service_common_client_ops_t *ops) { if (ctx->state != NIPC_CLIENT_READY) { ctx->error_count++; return NIPC_ERR_NOT_READY; } if (nipc_service_common_client_abort_requested(ctx)) { - ops->disconnect(ctx); - ctx->state = NIPC_CLIENT_BROKEN; + call_mark_broken(ctx, ops); ctx->error_count++; return NIPC_ERR_ABORTED; } @@ -456,21 +498,18 @@ nipc_error_t nipc_service_common_client_ensure_request_capacity( if (ctx->transport_config.max_request_payload_bytes < required) return NIPC_ERR_OVERFLOW; + nipc_service_common_client_begin_op(ctx, timeout_ms); uint32_t prev_req = ctx->session.max_request_payload_bytes; - ops->disconnect(ctx); - ctx->state = NIPC_CLIENT_BROKEN; - if (ops->reconnect_drain_ms > 0 && ops->sleep_ms) - ops->sleep_ms(ops->reconnect_drain_ms); + call_mark_broken(ctx, ops); if (!ops->reconnect_for_call(ctx)) { ctx->error_count++; - return NIPC_ERR_OVERFLOW; + return call_reconnect_failure(ctx, NIPC_ERR_OVERFLOW); } ctx->reconnect_count++; if (ctx->session.max_request_payload_bytes <= prev_req || ctx->session.max_request_payload_bytes < required) { - ops->disconnect(ctx); - ctx->state = NIPC_CLIENT_BROKEN; + call_mark_broken(ctx, ops); ctx->error_count++; return NIPC_ERR_OVERFLOW; } @@ -497,6 +536,9 @@ nipc_error_t nipc_service_common_server_init_base( server->handler = handler; server->handler_user = user; server->worker_count = worker_count; + server->poll_ms = NIPC_SERVER_POLL_DEFAULT_MS; + server->stop_grace_ms = NIPC_SERVER_STOP_GRACE_DEFAULT_MS; + server->handshake_ms = NIPC_SERVER_HANDSHAKE_TIMEOUT_MS; server->expected_method_code = expected_method_code; server->learned_request_payload_bytes = max_request_payload_bytes > 0 ? max_request_payload_bytes @@ -530,7 +572,8 @@ void nipc_service_common_server_note_request_capacity( if (grown > server->request_payload_growth_ceiling) grown = server->request_payload_growth_ceiling; #if defined(_WIN32) || defined(__MSYS__) - uint32_t current = server->learned_request_payload_bytes; + uint32_t current = (uint32_t)InterlockedCompareExchange( + (volatile LONG *)&server->learned_request_payload_bytes, 0, 0); while (grown > current) { uint32_t previous = (uint32_t)InterlockedCompareExchange( (volatile LONG *)&server->learned_request_payload_bytes, (LONG)grown, @@ -556,7 +599,8 @@ void nipc_service_common_server_note_response_capacity( if (grown > server->response_payload_growth_ceiling) grown = server->response_payload_growth_ceiling; #if defined(_WIN32) || defined(__MSYS__) - uint32_t current = server->learned_response_payload_bytes; + uint32_t current = (uint32_t)InterlockedCompareExchange( + (volatile LONG *)&server->learned_response_payload_bytes, 0, 0); while (grown > current) { uint32_t previous = (uint32_t)InterlockedCompareExchange( (volatile LONG *)&server->learned_response_payload_bytes, (LONG)grown, @@ -644,3 +688,23 @@ void nipc_service_common_apply_dispatch_result( break; } } + +void nipc_server_set_timings_for_tests(nipc_managed_server_t *server, + uint32_t poll_ms, uint32_t stop_grace_ms, + uint32_t handshake_ms) { + server->poll_ms = poll_ms; + server->stop_grace_ms = stop_grace_ms; + server->handshake_ms = handshake_ms; +} + +void nipc_server_set_event_hook_for_tests(nipc_managed_server_t *server, + void (*fn)(void *user, int event), + void *user) { + server->test_event_hook = fn; + server->test_event_user = user; +} + +void nipc_client_set_shm_attach_timeout_for_tests(nipc_client_ctx_t *ctx, + uint32_t timeout_ms) { + ctx->shm_attach_timeout_ms = timeout_ms; +} diff --git a/src/libnetdata/netipc/src/service/netipc_service_common.h b/src/libnetdata/netipc/src/service/netipc_service_common.h index 7ad3c902..4d14895a 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_common.h +++ b/src/libnetdata/netipc/src/service/netipc_service_common.h @@ -23,20 +23,19 @@ typedef struct { typedef nipc_error_t (*nipc_service_common_attempt_fn)(nipc_client_ctx_t *ctx, void *state); +/* Sends within timeout_ms (0 = no deadline), ending early on abort. */ typedef nipc_error_t (*nipc_service_common_transport_send_fn)( nipc_client_ctx_t *ctx, nipc_header_t *hdr, const void *payload, - size_t payload_len); + size_t payload_len, uint32_t timeout_ms); typedef nipc_error_t (*nipc_service_common_transport_receive_fn)( nipc_client_ctx_t *ctx, void *buf, size_t buf_size, nipc_header_t *hdr_out, const void **payload_out, size_t *payload_len_out, uint32_t timeout_ms); typedef struct { void (*disconnect)(nipc_client_ctx_t *ctx); + /* Both connect within the time left on ctx->op_deadline_ms. */ nipc_client_state_t (*try_connect)(nipc_client_ctx_t *ctx); bool (*reconnect_for_call)(nipc_client_ctx_t *ctx); - void (*sleep_ms)(uint32_t ms); - uint32_t reconnect_drain_ms; - uint32_t reconnect_retry_interval_ms; } nipc_service_common_client_ops_t; typedef void *(*nipc_service_common_calloc_fn)(size_t count, size_t size, @@ -97,6 +96,12 @@ uint32_t nipc_service_common_client_call_timeout_ms(const nipc_client_ctx_t *ctx, uint32_t timeout_ms); bool nipc_service_common_client_abort_requested(const nipc_client_ctx_t *ctx); +/* Start a call or refresh: one deadline, timeout_ms resolved as for calls, + * bounds every connect, handshake, attach and retry inside it. */ +void nipc_service_common_client_begin_op(nipc_client_ctx_t *ctx, + uint32_t timeout_ms); +/* Time left before the current deadline; 0 once it passed. */ +uint32_t nipc_service_common_client_op_remaining_ms(const nipc_client_ctx_t *ctx); bool nipc_service_common_client_refresh( nipc_client_ctx_t *ctx, const nipc_service_common_client_ops_t *ops); void nipc_service_common_client_note_request_capacity(nipc_client_ctx_t *ctx, @@ -120,9 +125,9 @@ nipc_error_t nipc_service_common_do_raw_call( nipc_service_common_transport_receive_fn receive_fn); nipc_error_t nipc_service_common_call_with_retry( nipc_client_ctx_t *ctx, nipc_service_common_attempt_fn attempt, void *state, - const nipc_service_common_client_ops_t *ops); + uint32_t timeout_ms, const nipc_service_common_client_ops_t *ops); nipc_error_t nipc_service_common_client_ensure_request_capacity( - nipc_client_ctx_t *ctx, size_t required_payload_bytes, + nipc_client_ctx_t *ctx, size_t required_payload_bytes, uint32_t timeout_ms, const nipc_service_common_client_ops_t *ops); void nipc_service_common_server_note_request_capacity( diff --git a/src/libnetdata/netipc/src/service/netipc_service_platform.h b/src/libnetdata/netipc/src/service/netipc_service_platform.h index ebebe5c6..a3e923ee 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_platform.h +++ b/src/libnetdata/netipc/src/service/netipc_service_platform.h @@ -39,9 +39,9 @@ nipc_error_t nipc_service_platform_do_raw_call( nipc_error_t nipc_service_platform_call_with_retry(nipc_client_ctx_t *ctx, nipc_service_platform_attempt_fn attempt, - void *state); + void *state, uint32_t timeout_ms); nipc_error_t nipc_service_platform_ensure_request_capacity( - nipc_client_ctx_t *ctx, size_t required_payload_bytes); + nipc_client_ctx_t *ctx, size_t required_payload_bytes, uint32_t timeout_ms); void nipc_service_platform_server_config_from_service( nipc_service_platform_server_config_t *transport, diff --git a/src/libnetdata/netipc/src/service/netipc_service_posix_client.c b/src/libnetdata/netipc/src/service/netipc_service_posix_client.c index 9c6a82ab..3e27d41e 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_posix_client.c +++ b/src/libnetdata/netipc/src/service/netipc_service_posix_client.c @@ -2,6 +2,11 @@ * netipc_service_posix_client.c - POSIX public service client API. */ +/* pipe2() is a GNU/BSD extension on glibc and musl. */ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif + #include "netipc/netipc_service.h" #include "netipc/netipc_protocol.h" #include "netipc/netipc_uds.h" @@ -30,11 +35,6 @@ static nipc_uds_client_config_t service_client_config_to_transport( return transport; } -static void client_sleep_ms(uint32_t ms) -{ - nipc_service_posix_sleep_us(ms * 1000u); -} - static void close_abort_pipe(nipc_client_ctx_t *ctx) { if (!ctx->abort_pipe_lock_initialized) @@ -89,18 +89,26 @@ static void init_abort_pipe(nipc_client_ctx_t *ctx) return; ctx->abort_pipe_lock_initialized = true; + /* Close-on-exec so a consumer's exec'd children do not inherit it. */ int fds[2]; +#if defined(__linux__) || defined(__FreeBSD__) + if (pipe2(fds, O_CLOEXEC | O_NONBLOCK) != 0) { +#else if (pipe(fds) != 0) { +#endif pthread_mutex_destroy(&ctx->abort_pipe_lock); ctx->abort_pipe_lock_initialized = false; return; } +#if !defined(__linux__) && !defined(__FreeBSD__) for (int i = 0; i < 2; i++) { int flags = fcntl(fds[i], F_GETFL, 0); if (flags >= 0) (void)fcntl(fds[i], F_SETFL, flags | O_NONBLOCK); + (void)fcntl(fds[i], F_SETFD, FD_CLOEXEC); } +#endif ctx->abort_pipe[0] = fds[0]; ctx->abort_pipe[1] = fds[1]; @@ -113,9 +121,6 @@ const nipc_service_common_client_ops_t *nipc_service_posix_client_ops(void) .disconnect = nipc_service_posix_client_disconnect, .try_connect = nipc_service_posix_client_try_connect, .reconnect_for_call = nipc_service_posix_client_reconnect_for_call, - .sleep_ms = client_sleep_ms, - .reconnect_drain_ms = CLIENT_CALL_RECONNECT_DRAIN_MS, - .reconnect_retry_interval_ms = CLIENT_CALL_RECONNECT_RETRY_INTERVAL_MS, }; return &ops; } diff --git a/src/libnetdata/netipc/src/service/netipc_service_posix_client_call.c b/src/libnetdata/netipc/src/service/netipc_service_posix_client_call.c index 46b9b371..841ce970 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_posix_client_call.c +++ b/src/libnetdata/netipc/src/service/netipc_service_posix_client_call.c @@ -18,6 +18,20 @@ /* Internal: send/receive via the active transport */ /* ------------------------------------------------------------------ */ +/* The UDS transport reports EOF, EPIPE and ECONNRESET as plain send/recv + * failures; the session probe tells a gone server apart from any other I/O + * failure. Protocol errors keep their own meaning. */ +static nipc_error_t socket_failure(const nipc_client_ctx_t *ctx, + nipc_uds_error_t uerr, + nipc_error_t otherwise) +{ + if (uerr == NIPC_UDS_ERR_SEND || uerr == NIPC_UDS_ERR_RECV) + return nipc_service_posix_peer_disconnected(ctx->session.fd) + ? NIPC_ERR_DISCONNECTED + : NIPC_ERR_TRANSPORT; + return otherwise; +} + /* * Send a complete message (header + payload) using whichever transport * is active: SHM if negotiated, UDS otherwise. @@ -25,7 +39,8 @@ static nipc_error_t transport_send(nipc_client_ctx_t *ctx, nipc_header_t *hdr, const void *payload, - size_t payload_len) + size_t payload_len, + uint32_t timeout_ms) { if (payload_len > UINT32_MAX) return NIPC_ERR_OVERFLOW; @@ -44,18 +59,31 @@ static nipc_error_t transport_send(nipc_client_ctx_t *ctx, ctx, (uint32_t)payload_len); return NIPC_ERR_OVERFLOW; } - return (serr == NIPC_SHM_OK) ? NIPC_OK : NIPC_ERR_NOT_READY; + if (serr == NIPC_SHM_OK) + return NIPC_OK; + return nipc_service_posix_peer_disconnected(ctx->session.fd) + ? NIPC_ERR_DISCONNECTED + : NIPC_ERR_TRANSPORT; } - /* UDS path */ - nipc_uds_error_t uerr = nipc_uds_send(&ctx->session, hdr, - payload, payload_len); - if (uerr == NIPC_UDS_ERR_LIMIT_EXCEEDED) { + /* UDS path. The SHM send above never waits for the peer. */ + int abort_fd = ctx->abort_pipe_valid ? ctx->abort_pipe[0] : -1; + nipc_uds_error_t uerr = nipc_uds_send_timeout( + &ctx->session, hdr, payload, payload_len, timeout_ms, abort_fd); + switch (uerr) { + case NIPC_UDS_OK: + return NIPC_OK; + case NIPC_UDS_ERR_LIMIT_EXCEEDED: nipc_service_common_client_note_request_capacity( ctx, (uint32_t)payload_len); return NIPC_ERR_OVERFLOW; + case NIPC_UDS_ERR_TIMEOUT: + return NIPC_ERR_TIMEOUT; + case NIPC_UDS_ERR_ABORTED: + return NIPC_ERR_ABORTED; + default: + return socket_failure(ctx, uerr, NIPC_ERR_TRANSPORT); } - return (uerr == NIPC_UDS_OK) ? NIPC_OK : NIPC_ERR_NOT_READY; } /* @@ -97,9 +125,18 @@ static nipc_error_t transport_receive(nipc_client_ctx_t *ctx, &msg_len, wait_ms); if (serr == NIPC_SHM_OK) break; - if (serr == NIPC_SHM_ERR_TIMEOUT) + if (serr == NIPC_SHM_ERR_TIMEOUT) { + /* Only an idle slice pays for the probe. */ + if (nipc_service_posix_peer_disconnected(ctx->session.fd)) + return NIPC_ERR_DISCONNECTED; continue; - return NIPC_ERR_TRUNCATED; + } + if (serr == NIPC_SHM_ERR_MSG_TOO_LARGE) + return NIPC_ERR_OVERFLOW; + /* A zero-length or corrupt slot is a truncated message. */ + if (serr == NIPC_SHM_ERR_BAD_HEADER) + return NIPC_ERR_TRUNCATED; + return NIPC_ERR_TRANSPORT; } return nipc_service_common_client_parse_shm_response( @@ -124,7 +161,7 @@ static nipc_error_t transport_receive(nipc_client_ctx_t *ctx, case NIPC_UDS_ERR_LIMIT_EXCEEDED: return NIPC_ERR_OVERFLOW; default: - return NIPC_ERR_TRUNCATED; + return socket_failure(ctx, uerr, NIPC_ERR_TRUNCATED); } } @@ -164,15 +201,16 @@ nipc_error_t nipc_service_platform_do_raw_call(nipc_client_ctx_t *ctx, nipc_error_t nipc_service_platform_call_with_retry( nipc_client_ctx_t *ctx, nipc_service_platform_attempt_fn attempt, - void *state) + void *state, + uint32_t timeout_ms) { return nipc_service_common_call_with_retry( - ctx, attempt, state, nipc_service_posix_client_ops()); + ctx, attempt, state, timeout_ms, nipc_service_posix_client_ops()); } nipc_error_t nipc_service_platform_ensure_request_capacity( - nipc_client_ctx_t *ctx, size_t required_payload_bytes) + nipc_client_ctx_t *ctx, size_t required_payload_bytes, uint32_t timeout_ms) { return nipc_service_common_client_ensure_request_capacity( - ctx, required_payload_bytes, nipc_service_posix_client_ops()); + ctx, required_payload_bytes, timeout_ms, nipc_service_posix_client_ops()); } diff --git a/src/libnetdata/netipc/src/service/netipc_service_posix_client_connect.c b/src/libnetdata/netipc/src/service/netipc_service_posix_client_connect.c index 2e3d9a58..b7925cb3 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_posix_client_connect.c +++ b/src/libnetdata/netipc/src/service/netipc_service_posix_client_connect.c @@ -76,9 +76,14 @@ nipc_client_state_t nipc_service_posix_client_try_connect(nipc_client_ctx_t *ctx memset(&session, 0, sizeof(session)); session.fd = -1; - nipc_uds_error_t err = nipc_uds_connect( + uint32_t remaining = nipc_service_common_client_op_remaining_ms(ctx); + if (remaining == 0 || nipc_service_common_client_abort_requested(ctx)) + return NIPC_CLIENT_DISCONNECTED; + + int abort_fd = ctx->abort_pipe_valid ? ctx->abort_pipe[0] : -1; + nipc_uds_error_t err = nipc_uds_connect_timeout( ctx->run_dir, ctx->service_name, - &ctx->transport_config, &session); + &ctx->transport_config, remaining, abort_fd, &session); switch (err) { case NIPC_UDS_OK: @@ -119,7 +124,16 @@ nipc_client_state_t nipc_service_posix_client_try_connect(nipc_client_ctx_t *ctx /* Retry attach: server creates the SHM region after * the UDS handshake, so it may not exist yet. */ nipc_shm_error_t serr = NIPC_SHM_ERR_NOT_READY; - uint64_t deadline_ms = nipc_service_platform_monotonic_ms() + CLIENT_SHM_ATTACH_RETRY_TIMEOUT_MS; + bool keep_shm = false; + uint32_t attach_ms = nipc_service_common_client_op_remaining_ms(ctx); + /* When the call deadline ends the wait first, SHM is not at + * fault and stays enabled for the next connect. */ + /* The remaining time is rounded up, so a tie means the call + * deadline comes first. */ + bool call_bound = attach_ms <= ctx->shm_attach_timeout_ms; + if (!call_bound) + attach_ms = ctx->shm_attach_timeout_ms; + uint64_t deadline_ms = nipc_service_platform_monotonic_ms() + attach_ms; for (;;) { serr = nipc_shm_client_attach( ctx->run_dir, ctx->service_name, @@ -130,13 +144,31 @@ nipc_client_state_t nipc_service_posix_client_try_connect(nipc_client_ctx_t *ctx serr != NIPC_SHM_ERR_OPEN && serr != NIPC_SHM_ERR_BAD_MAGIC) break; - if (nipc_service_platform_monotonic_ms() >= deadline_ms) + if (nipc_service_platform_monotonic_ms() >= deadline_ms) { + keep_shm = call_bound; + break; + } + if (nipc_service_common_client_abort_requested(ctx)) { + keep_shm = true; break; + } + /* A server that refused or died closes the session socket; + * waiting for its SHM region would only burn the timeout. */ + if (nipc_service_posix_peer_disconnected(session.fd)) { + keep_shm = true; + break; + } nipc_service_posix_sleep_us(CLIENT_SHM_ATTACH_RETRY_INTERVAL_MS * 1000u); } if (serr == NIPC_SHM_OK) { ctx->shm = shm; + } else if (keep_shm) { + /* Not an SHM problem: keep SHM enabled for the next connect. */ + free(shm); + nipc_uds_close_session(&ctx->session); + ctx->session_valid = false; + return NIPC_CLIENT_DISCONNECTED; } else { /* SHM attach failed after negotiation. Close that session, * blacklist SHM for this client context, and retry @@ -155,18 +187,10 @@ nipc_client_state_t nipc_service_posix_client_try_connect(nipc_client_ctx_t *ctx return NIPC_CLIENT_READY; } +/* One reconnect per failed call, as the spec states; the call deadline + * bounds it. */ bool nipc_service_posix_client_reconnect_for_call(nipc_client_ctx_t *ctx) { - for (uint32_t i = 0; i < CLIENT_CALL_RECONNECT_RETRIES; i++) { - ctx->state = nipc_service_posix_client_try_connect(ctx); - if (ctx->state == NIPC_CLIENT_READY) - return true; - if (ctx->state == NIPC_CLIENT_AUTH_FAILED || - ctx->state == NIPC_CLIENT_INCOMPATIBLE) - return false; - if (i + 1u < CLIENT_CALL_RECONNECT_RETRIES) - nipc_service_posix_sleep_us(CLIENT_CALL_RECONNECT_RETRY_INTERVAL_MS * 1000u); - } - - return false; + ctx->state = nipc_service_posix_client_try_connect(ctx); + return ctx->state == NIPC_CLIENT_READY; } diff --git a/src/libnetdata/netipc/src/service/netipc_service_posix_internal.h b/src/libnetdata/netipc/src/service/netipc_service_posix_internal.h index f043ac76..6ecb4b2a 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_posix_internal.h +++ b/src/libnetdata/netipc/src/service/netipc_service_posix_internal.h @@ -8,12 +8,7 @@ #include #include -#define SERVER_POLL_TIMEOUT_MS 100 #define CLIENT_SHM_ATTACH_RETRY_INTERVAL_MS 5u -#define CLIENT_SHM_ATTACH_RETRY_TIMEOUT_MS 5000u -#define CLIENT_CALL_RECONNECT_RETRY_INTERVAL_MS 5u -#define CLIENT_CALL_RECONNECT_DRAIN_MS (SERVER_POLL_TIMEOUT_MS + 50u) -#define CLIENT_CALL_RECONNECT_RETRIES 20u typedef struct { pthread_mutex_t lock; @@ -21,6 +16,7 @@ typedef struct { bool copied; nipc_managed_server_t *server; nipc_session_ctx_t *session; + int fd; /* accepted connection, handshake still pending */ } nipc_posix_session_start_arg_t; enum { @@ -40,6 +36,11 @@ enum { }; void nipc_service_posix_sleep_us(unsigned int usec); + +/* Zero-wait probe of a session socket. SHM sessions keep the socket open + * for their whole life and the kernel closes it when the peer exits, so + * HUP/ERR/EOF here is the dead-peer signal for both directions. */ +bool nipc_service_posix_peer_disconnected(int fd); const nipc_service_common_client_ops_t *nipc_service_posix_client_ops(void); void nipc_service_posix_client_disconnect(nipc_client_ctx_t *ctx); nipc_client_state_t nipc_service_posix_client_try_connect(nipc_client_ctx_t *ctx); @@ -48,9 +49,12 @@ void *nipc_service_posix_malloc(size_t size, int fault_site); void *nipc_service_posix_calloc(size_t count, size_t size, int fault_site); bool nipc_service_posix_ensure_buffer(uint8_t **buf, size_t *buf_size, size_t need, int fault_site); -int nipc_service_posix_poll_with_shutdown(int fd, bool *running); +int nipc_service_posix_poll_with_shutdown(int fd, bool *running, + uint32_t poll_ms); void *nipc_service_posix_session_handler_thread(void *arg); void nipc_service_posix_server_reap_sessions_locked(nipc_managed_server_t *server); +bool nipc_service_posix_server_wait_sessions(nipc_managed_server_t *server, + uint32_t grace_ms); int nipc_service_posix_pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), diff --git a/src/libnetdata/netipc/src/service/netipc_service_posix_server.c b/src/libnetdata/netipc/src/service/netipc_service_posix_server.c index 1510b793..6e125f2b 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_posix_server.c +++ b/src/libnetdata/netipc/src/service/netipc_service_posix_server.c @@ -23,100 +23,32 @@ /* Internal: managed server session handler */ /* ------------------------------------------------------------------ */ -/* - * Wait for data on a file descriptor with periodic shutdown checks. - * Returns: 1 = data ready, 0 = server stopping, -1 = error/hangup. - */ -static void server_destroy_precreated_shm(nipc_shm_ctx_t **shm) { - if (!shm || !*shm) - return; - nipc_shm_destroy(*shm); - free(*shm); - *shm = NULL; -} - -static bool server_prepare_accept_config(nipc_managed_server_t *server, - uint64_t sid, - nipc_uds_server_config_t *cfg_out, - nipc_shm_ctx_t **shm_out) { - *cfg_out = server->base_config; - cfg_out->max_request_payload_bytes = - __atomic_load_n(&server->learned_request_payload_bytes, __ATOMIC_ACQUIRE); - cfg_out->max_response_payload_bytes = __atomic_load_n( - &server->learned_response_payload_bytes, __ATOMIC_ACQUIRE); - *shm_out = NULL; - - uint32_t shm_profiles = cfg_out->supported_profiles & - (NIPC_PROFILE_SHM_HYBRID | NIPC_PROFILE_SHM_FUTEX); - if (shm_profiles == 0) - return true; - - uint32_t request_capacity; - uint32_t response_capacity; - if (!nipc_service_common_header_payload_len_u32( - cfg_out->max_request_payload_bytes, &request_capacity) || - !nipc_service_common_header_payload_len_u32( - cfg_out->max_response_payload_bytes, &response_capacity)) { - cfg_out->supported_profiles &= ~shm_profiles; - cfg_out->preferred_profiles &= ~shm_profiles; - return cfg_out->supported_profiles != 0; - } - - nipc_shm_ctx_t *shm = nipc_service_posix_calloc( - 1, sizeof(nipc_shm_ctx_t), - NIPC_POSIX_SERVICE_TEST_FAULT_SERVER_SHM_CTX_CALLOC_INTERNAL); - if (!shm) - return false; - - /* HELLO has not been read yet, so the request segment must cover any - * client proposal the handshake may legally echo back. */ - nipc_shm_error_t serr = - nipc_shm_server_create(server->run_dir, server->service_name, sid, - request_capacity, response_capacity, shm); - if (serr == NIPC_SHM_OK) { - *shm_out = shm; - return true; - } - - free(shm); - cfg_out->supported_profiles &= - ~(NIPC_PROFILE_SHM_HYBRID | NIPC_PROFILE_SHM_FUTEX); - cfg_out->preferred_profiles &= - ~(NIPC_PROFILE_SHM_HYBRID | NIPC_PROFILE_SHM_FUTEX); - return cfg_out->supported_profiles != 0; -} - -static void server_accept_loop_mark_active(nipc_managed_server_t *server) { - if (!server || !server->sessions) - return; - - pthread_mutex_lock(&server->sessions_lock); +/* Run and drain/destroy meet without the session lock, which drain and + * destroy free: run publishes acceptor_started and then reads + * stop_requested; a stop publishes stop_requested and then reads + * acceptor_started. Both are sequentially consistent, so at least one side + * sees the other: either the stop waits for the loop, or run sees the stop + * and returns without touching the server again. */ +static bool server_accept_loop_enter(nipc_managed_server_t *server) { server->acceptor_thread = pthread_self(); - server->acceptor_started = true; - pthread_mutex_unlock(&server->sessions_lock); + __atomic_store_n(&server->acceptor_started, true, __ATOMIC_SEQ_CST); + if (!__atomic_load_n(&server->stop_requested, __ATOMIC_SEQ_CST)) + return true; + __atomic_store_n(&server->acceptor_started, false, __ATOMIC_SEQ_CST); + return false; } -static void server_accept_loop_mark_inactive(nipc_managed_server_t *server) { - if (!server || !server->sessions) - return; - - pthread_mutex_lock(&server->sessions_lock); - server->acceptor_started = false; - pthread_mutex_unlock(&server->sessions_lock); +/* The loop's last access to the server. */ +static void server_accept_loop_leave(nipc_managed_server_t *server) { + __atomic_store_n(&server->acceptor_started, false, __ATOMIC_SEQ_CST); } static void server_wait_accept_loop_inactive(nipc_managed_server_t *server) { - if (!server || !server->sessions) - return; - for (;;) { - pthread_mutex_lock(&server->sessions_lock); - bool active = server->acceptor_started; - bool current_thread = - active && pthread_equal(server->acceptor_thread, pthread_self()); - pthread_mutex_unlock(&server->sessions_lock); - - if (!active || current_thread) + if (!__atomic_load_n(&server->acceptor_started, __ATOMIC_SEQ_CST)) + return; + /* Called from the loop's own thread, there is nothing to wait for. */ + if (pthread_equal(server->acceptor_thread, pthread_self())) return; nipc_service_posix_sleep_us(1000); } @@ -149,6 +81,10 @@ nipc_error_t nipc_service_platform_server_init_raw( if (ierr != NIPC_OK) return ierr; server->base_config = *config; + /* 0 selects the baseline profile, as in the transport handshake; the + * accept path masks profiles and would otherwise reject every client. */ + if (server->base_config.supported_profiles == 0) + server->base_config.supported_profiles = NIPC_PROFILE_BASELINE; ierr = nipc_service_common_server_alloc_sessions( server, nipc_service_posix_calloc, NIPC_POSIX_SERVICE_TEST_FAULT_SERVER_SESSIONS_CALLOC_INTERNAL); @@ -184,90 +120,88 @@ nipc_server_init_raw_for_tests(nipc_managed_server_t *server, handler, user); } +static void server_request_stop(nipc_managed_server_t *server) { + __atomic_store_n(&server->stop_requested, true, __ATOMIC_SEQ_CST); + __atomic_store_n(&server->running, false, __ATOMIC_SEQ_CST); +} + +static bool server_stop_requested(nipc_managed_server_t *server) { + return __atomic_load_n(&server->stop_requested, __ATOMIC_ACQUIRE); +} + +/* Takes sessions_lock and keeps it on return. When all workers are busy, + * waits up to two poll slices for one to finish (a worker whose client left + * exits within one), so a client that + * reconnects right after closing is not refused while its old worker exits. + * The loop has nothing else to serve while full, so waiting costs nothing. */ +static bool server_wait_for_slot_locked(nipc_managed_server_t *server) { + uint64_t deadline = + nipc_service_platform_monotonic_ms() + 2u * (uint64_t)server->poll_ms; + pthread_mutex_lock(&server->sessions_lock); + for (;;) { + nipc_service_posix_server_reap_sessions_locked(server); + if (server->session_count < server->worker_count) + return true; + if (!__atomic_load_n(&server->running, __ATOMIC_RELAXED) || + nipc_service_platform_monotonic_ms() >= deadline) + return false; + pthread_mutex_unlock(&server->sessions_lock); + nipc_service_posix_sleep_us(1000); + pthread_mutex_lock(&server->sessions_lock); + } +} + void nipc_server_run(nipc_managed_server_t *server) { - __atomic_store_n(&server->running, true, __ATOMIC_RELEASE); - server_accept_loop_mark_active(server); + /* A stop, drain or destroy before run must win: drain and destroy may + * already have released the listener and the session lock. */ + if (server_stop_requested(server)) + return; + /* running is set before the stop check: a stop the check misses clears + * it afterwards. */ + __atomic_store_n(&server->running, true, __ATOMIC_SEQ_CST); + if (!server_accept_loop_enter(server)) { + __atomic_store_n(&server->running, false, __ATOMIC_SEQ_CST); + return; + } while (__atomic_load_n(&server->running, __ATOMIC_RELAXED)) { /* Poll the listener fd before blocking on accept */ int pr = nipc_service_posix_poll_with_shutdown(server->listener.fd, - &server->running); + &server->running, + server->poll_ms); if (pr <= 0) break; /* shutdown or error */ - /* Accept one client via L1 */ - nipc_uds_session_t session; - memset(&session, 0, sizeof(session)); - session.fd = -1; - - uint64_t sid = server->next_session_id++; - nipc_uds_server_config_t accept_cfg; - nipc_shm_ctx_t *prepared_shm = NULL; - if (!server_prepare_accept_config(server, sid, &accept_cfg, - &prepared_shm)) { - nipc_service_posix_sleep_us(10000); - continue; - } - - server->listener.config = accept_cfg; - nipc_uds_error_t uerr = nipc_uds_accept(&server->listener, sid, &session); - if (uerr != NIPC_UDS_OK) { - server_destroy_precreated_shm(&prepared_shm); + /* The handshake runs on the session thread, so a client that never + * sends HELLO cannot stall this loop. */ + int fd = -1; + if (nipc_uds_accept_pending(&server->listener, &fd) != NIPC_UDS_OK) { if (!__atomic_load_n(&server->running, __ATOMIC_RELAXED)) break; nipc_service_posix_sleep_us(10000); continue; } - nipc_service_common_server_note_request_capacity( - server, session.max_request_payload_bytes); - nipc_service_common_server_note_response_capacity( - server, session.max_response_payload_bytes); - - /* Enforce worker_count limit: reap finished sessions, check count */ - pthread_mutex_lock(&server->sessions_lock); - nipc_service_posix_server_reap_sessions_locked(server); - - if (server->session_count >= server->worker_count) { - /* At capacity: reject this client by closing the session */ + /* Enforce worker_count limit: a connection holds its slot from accept, + * handshake included. Refuse before HELLO_ACK so the client's connect + * fails instead of a later call. */ + if (!server_wait_for_slot_locked(server)) { pthread_mutex_unlock(&server->sessions_lock); - server_destroy_precreated_shm(&prepared_shm); - nipc_uds_close_session(&session); + close(fd); continue; } - /* SHM profile guarantee: only negotiate SHM for sessions that already - * have a prepared per-session SHM region. */ - nipc_shm_ctx_t *shm = prepared_shm; - if (session.selected_profile == NIPC_PROFILE_SHM_HYBRID || - session.selected_profile == NIPC_PROFILE_SHM_FUTEX) { - if (!shm) { - pthread_mutex_unlock(&server->sessions_lock); - nipc_uds_close_session(&session); - continue; - } - } else { - server_destroy_precreated_shm(&prepared_shm); - shm = NULL; - } - - /* Create session context */ nipc_session_ctx_t *sctx = nipc_service_posix_calloc( 1, sizeof(nipc_session_ctx_t), NIPC_POSIX_SERVICE_TEST_FAULT_SERVER_SESSION_CTX_CALLOC_INTERNAL); if (!sctx) { - if (shm) { - nipc_shm_destroy(shm); - free(shm); - } pthread_mutex_unlock(&server->sessions_lock); - nipc_uds_close_session(&session); + close(fd); continue; } - sctx->session = session; - sctx->shm = shm; - sctx->id = sid; + sctx->session.fd = -1; + sctx->id = server->next_session_id++; __atomic_store_n(&sctx->active, true, __ATOMIC_RELAXED); server->sessions[server->session_count++] = sctx; @@ -278,6 +212,7 @@ void nipc_server_run(nipc_managed_server_t *server) { .copied = false, .server = server, .session = sctx, + .fd = fd, }; int start_mutex_rc = pthread_mutex_init(&start_arg.lock, NULL); int start_cond_rc = start_mutex_rc == 0 @@ -313,125 +248,72 @@ void nipc_server_run(nipc_managed_server_t *server) { } pthread_mutex_unlock(&server->sessions_lock); - if (shm) { - nipc_shm_destroy(shm); - free(shm); - } - nipc_uds_close_session(&session); + close(fd); free(sctx); } } - server_accept_loop_mark_inactive(server); + /* Run owns the listening socket. The endpoint lock stays held until + * drain or destroy has joined the workers. */ + nipc_uds_stop_listening(&server->listener); + server_accept_loop_leave(server); } void nipc_server_stop(nipc_managed_server_t *server) { - __atomic_store_n(&server->running, false, __ATOMIC_RELEASE); + server_request_stop(server); } -bool nipc_server_drain(nipc_managed_server_t *server, uint32_t timeout_ms) { - /* 1. Stop accepting new clients. - * Do NOT close the listener here — the run loop may still be - * polling on listener.fd. Setting the flag is enough; the run - * loop will exit on its next poll timeout (100ms). The listener - * is closed later by nipc_server_destroy(). */ - __atomic_store_n(&server->running, false, __ATOMIC_RELEASE); - server_wait_accept_loop_inactive(server); - - /* 2. Wait for in-flight sessions to complete */ - bool all_drained = true; - if (server->sessions) { - struct timespec deadline; - clock_gettime(CLOCK_MONOTONIC, &deadline); - deadline.tv_sec += timeout_ms / 1000; - deadline.tv_nsec += (timeout_ms % 1000) * 1000000L; - if (deadline.tv_nsec >= 1000000000L) { - deadline.tv_sec++; - deadline.tv_nsec -= 1000000000L; - } - - /* Poll until all sessions are inactive or timeout */ - while (1) { - pthread_mutex_lock(&server->sessions_lock); - int active_count = 0; - for (int i = 0; i < server->session_count; i++) { - if (__atomic_load_n(&server->sessions[i]->active, __ATOMIC_ACQUIRE)) - active_count++; - } - pthread_mutex_unlock(&server->sessions_lock); - - if (active_count == 0) - break; +static void server_join_sessions(nipc_managed_server_t *server) { + pthread_mutex_lock(&server->sessions_lock); + for (int i = 0; i < server->session_count; i++) { + nipc_session_ctx_t *s = server->sessions[i]; + pthread_mutex_unlock(&server->sessions_lock); + pthread_join(s->thread, NULL); + free(s); + pthread_mutex_lock(&server->sessions_lock); + } + server->session_count = 0; + pthread_mutex_unlock(&server->sessions_lock); - struct timespec now; - clock_gettime(CLOCK_MONOTONIC, &now); - if (now.tv_sec > deadline.tv_sec || - (now.tv_sec == deadline.tv_sec && now.tv_nsec >= deadline.tv_nsec)) { - /* Timeout: force-close session fds to unblock recv. - * Closing the fd causes poll/recv to return error, - * which terminates the session handler loop. */ - pthread_mutex_lock(&server->sessions_lock); - for (int i = 0; i < server->session_count; i++) { - nipc_session_ctx_t *s = server->sessions[i]; - if (__atomic_load_n(&s->active, __ATOMIC_ACQUIRE)) { - if (s->session.fd >= 0) { - shutdown(s->session.fd, SHUT_RDWR); - } - } - } - pthread_mutex_unlock(&server->sessions_lock); - all_drained = false; - break; - } + free(server->sessions); + server->sessions = NULL; + server->session_capacity = 0; + pthread_mutex_destroy(&server->sessions_lock); +} - nipc_service_posix_sleep_us(5000); /* 5ms poll interval */ - } +/* Stop accepting: the run loop closes the socket when it notices the stop; + * this closes it when no loop ran. */ +static void server_stop_accepting(nipc_managed_server_t *server) { + server_request_stop(server); + server_wait_accept_loop_inactive(server); + nipc_uds_stop_listening(&server->listener); +} - /* 3. Join all session threads (finished or not) */ - pthread_mutex_lock(&server->sessions_lock); - for (int i = 0; i < server->session_count; i++) { - nipc_session_ctx_t *s = server->sessions[i]; - pthread_mutex_unlock(&server->sessions_lock); - pthread_join(s->thread, NULL); - free(s); - pthread_mutex_lock(&server->sessions_lock); - } - server->session_count = 0; - pthread_mutex_unlock(&server->sessions_lock); +bool nipc_server_drain(nipc_managed_server_t *server, uint32_t timeout_ms) { + /* 1. Stop accepting new clients */ + server_stop_accepting(server); - free(server->sessions); - server->sessions = NULL; - server->session_capacity = 0; - pthread_mutex_destroy(&server->sessions_lock); + /* 2. Wait for in-flight sessions, 3. join them (finished or not) */ + bool all_drained = true; + if (server->sessions) { + all_drained = nipc_service_posix_server_wait_sessions(server, timeout_ms); + server_join_sessions(server); } + /* 4. Release the endpoint once no session of this server runs. */ + nipc_uds_close_listener(&server->listener); server->worker_count = 0; return all_drained; } void nipc_server_destroy(nipc_managed_server_t *server) { - __atomic_store_n(&server->running, false, __ATOMIC_RELEASE); - nipc_uds_close_listener(&server->listener); - server_wait_accept_loop_inactive(server); + server_stop_accepting(server); - /* Join all active session threads */ if (server->sessions) { - pthread_mutex_lock(&server->sessions_lock); - for (int i = 0; i < server->session_count; i++) { - nipc_session_ctx_t *s = server->sessions[i]; - pthread_mutex_unlock(&server->sessions_lock); - pthread_join(s->thread, NULL); - free(s); - pthread_mutex_lock(&server->sessions_lock); - } - server->session_count = 0; - pthread_mutex_unlock(&server->sessions_lock); - - free(server->sessions); - server->sessions = NULL; - server->session_capacity = 0; - pthread_mutex_destroy(&server->sessions_lock); + (void)nipc_service_posix_server_wait_sessions(server, server->stop_grace_ms); + server_join_sessions(server); } + nipc_uds_close_listener(&server->listener); server->worker_count = 0; } diff --git a/src/libnetdata/netipc/src/service/netipc_service_posix_server_session.c b/src/libnetdata/netipc/src/service/netipc_service_posix_server_session.c index 15347373..97fcd1bf 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_posix_server_session.c +++ b/src/libnetdata/netipc/src/service/netipc_service_posix_server_session.c @@ -15,12 +15,15 @@ #include #include #include +#include +#include -int nipc_service_posix_poll_with_shutdown(int fd, bool *running) +int nipc_service_posix_poll_with_shutdown(int fd, bool *running, + uint32_t poll_ms) { while (__atomic_load_n(running, __ATOMIC_RELAXED)) { struct pollfd pfd = { .fd = fd, .events = POLLIN }; - int ret = poll(&pfd, 1, SERVER_POLL_TIMEOUT_MS); + int ret = poll(&pfd, 1, (int)poll_ms); if (ret < 0) { if (errno == EINTR) @@ -40,7 +43,7 @@ int nipc_service_posix_poll_with_shutdown(int fd, bool *running) return 0; } -static bool session_peer_disconnected(int fd) +bool nipc_service_posix_peer_disconnected(int fd) { if (fd < 0) return true; @@ -118,9 +121,9 @@ static void server_handle_session(nipc_managed_server_t *server, if (shm) { size_t msg_len; nipc_shm_error_t serr = nipc_shm_receive(shm, recv_buf, recv_size, - &msg_len, SERVER_POLL_TIMEOUT_MS); + &msg_len, server->poll_ms); if (serr == NIPC_SHM_ERR_TIMEOUT) { - if (session_peer_disconnected(session->fd)) + if (nipc_service_posix_peer_disconnected(session->fd)) break; continue; /* check running flag */ } @@ -137,7 +140,8 @@ static void server_handle_session(nipc_managed_server_t *server, payload_len = msg_len - NIPC_HEADER_LEN; } else { /* Poll the session fd before blocking on receive */ - int pr = nipc_service_posix_poll_with_shutdown(session->fd, &server->running); + int pr = nipc_service_posix_poll_with_shutdown(session->fd, &server->running, + server->poll_ms); if (pr <= 0) break; /* shutdown or error */ @@ -270,6 +274,168 @@ static void server_handle_session(nipc_managed_server_t *server, /* Internal: per-session handler thread */ /* ------------------------------------------------------------------ */ +static void server_destroy_precreated_shm(nipc_shm_ctx_t **shm) { + if (!shm || !*shm) + return; + nipc_shm_destroy(*shm); + free(*shm); + *shm = NULL; +} + +static bool server_prepare_accept_config(nipc_managed_server_t *server, + nipc_uds_server_config_t *cfg_out) { + *cfg_out = server->base_config; + cfg_out->max_request_payload_bytes = + __atomic_load_n(&server->learned_request_payload_bytes, __ATOMIC_ACQUIRE); + cfg_out->max_response_payload_bytes = __atomic_load_n( + &server->learned_response_payload_bytes, __ATOMIC_ACQUIRE); + + uint32_t shm_profiles = cfg_out->supported_profiles & + (NIPC_PROFILE_SHM_HYBRID | NIPC_PROFILE_SHM_FUTEX); + uint32_t request_capacity; + uint32_t response_capacity; + if (shm_profiles != 0 && + (!nipc_service_common_header_payload_len_u32( + cfg_out->max_request_payload_bytes, &request_capacity) || + !nipc_service_common_header_payload_len_u32( + cfg_out->max_response_payload_bytes, &response_capacity))) { + cfg_out->supported_profiles &= ~shm_profiles; + cfg_out->preferred_profiles &= ~shm_profiles; + } + return cfg_out->supported_profiles != 0; +} + +typedef struct { + nipc_managed_server_t *server; + uint64_t session_id; + nipc_shm_ctx_t *shm; +} server_shm_prepare_t; + +/* Handshake prepare step: create the session's SHM region once the + * negotiation selected SHM, sized for the agreed limits. */ +static bool server_prepare_profile(void *user, uint32_t profile, + uint32_t max_request_payload_bytes, + uint32_t max_response_payload_bytes) { + server_shm_prepare_t *prep = (server_shm_prepare_t *)user; + if (profile != NIPC_PROFILE_SHM_HYBRID && profile != NIPC_PROFILE_SHM_FUTEX) + return true; + + uint32_t request_capacity; + uint32_t response_capacity; + if (!nipc_service_common_header_payload_len_u32(max_request_payload_bytes, + &request_capacity) || + !nipc_service_common_header_payload_len_u32(max_response_payload_bytes, + &response_capacity)) + return false; + + nipc_shm_ctx_t *shm = nipc_service_posix_calloc( + 1, sizeof(nipc_shm_ctx_t), + NIPC_POSIX_SERVICE_TEST_FAULT_SERVER_SHM_CTX_CALLOC_INTERNAL); + if (!shm) + return false; + + nipc_managed_server_t *server = prep->server; + if (nipc_shm_server_create(server->run_dir, server->service_name, + prep->session_id, request_capacity, + response_capacity, shm) != NIPC_SHM_OK) { + free(shm); + return false; + } + + prep->shm = shm; + if (server->test_event_hook) + server->test_event_hook(server->test_event_user, + NIPC_SERVER_TEST_EVENT_SHM_CREATED); + return true; +} + +/* Wait for HELLO without outliving a server stop or the handshake + * deadline. The clock bounds the wait, so time lost to EINTR counts and the + * last slice ends at the deadline. */ +static bool server_wait_hello(nipc_managed_server_t *server, int fd, + uint64_t deadline_ms) +{ + while (__atomic_load_n(&server->running, __ATOMIC_RELAXED)) { + uint64_t now = nipc_service_platform_monotonic_ms(); + if (now >= deadline_ms) + return false; + uint64_t left = deadline_ms - now; + int slice = left < server->poll_ms ? (int)left : (int)server->poll_ms; + struct pollfd pfd = { .fd = fd, .events = POLLIN }; + int ret = poll(&pfd, 1, slice); + if (ret < 0) { + if (errno == EINTR) + continue; + return false; + } + if (ret > 0) + return true; + } + return false; +} + +/* Run the handshake for an accepted connection and publish the session. + * Takes ownership of fd. Returns false if the session must not start. */ +static bool server_session_handshake(nipc_managed_server_t *server, + nipc_session_ctx_t *sctx, int fd) +{ + nipc_uds_server_config_t cfg; + if (!server_prepare_accept_config(server, &cfg)) { + close(fd); + return false; + } + + if (server->test_event_hook) + server->test_event_hook(server->test_event_user, + NIPC_SERVER_TEST_EVENT_HANDSHAKE_STARTED); + uint64_t deadline_ms = + nipc_service_platform_monotonic_ms() + server->handshake_ms; + if (!server_wait_hello(server, fd, deadline_ms)) { + close(fd); + return false; + } + + /* HELLO has started to arrive; a stream peer may still be sending the + * rest, which the remaining handshake time bounds. */ + uint64_t now = nipc_service_platform_monotonic_ms(); + uint64_t left = now < deadline_ms ? deadline_ms - now : 1; + server_shm_prepare_t prep = { .server = server, .session_id = sctx->id }; + nipc_uds_session_t session; + if (nipc_uds_accept_handshake_prepared(fd, &cfg, sctx->id, + left > UINT32_MAX ? UINT32_MAX + : (uint32_t)left, + server_prepare_profile, &prep, + &session) != NIPC_UDS_OK) { + server_destroy_precreated_shm(&prep.shm); + return false; + } + nipc_shm_ctx_t *shm = prep.shm; + + nipc_service_common_server_note_request_capacity( + server, session.max_request_payload_bytes); + nipc_service_common_server_note_response_capacity( + server, session.max_response_payload_bytes); + + /* SHM profile guarantee: the prepare step created the region before the + * ACK selected SHM. */ + if (session.selected_profile == NIPC_PROFILE_SHM_HYBRID || + session.selected_profile == NIPC_PROFILE_SHM_FUTEX) { + if (!shm) { + nipc_uds_close_session(&session); + return false; + } + } else { + server_destroy_precreated_shm(&shm); + } + + /* Publish under the lock: shutdown paths read session.fd under it. */ + pthread_mutex_lock(&server->sessions_lock); + sctx->session = session; + sctx->shm = shm; + pthread_mutex_unlock(&server->sessions_lock); + return true; +} + /* Thread function: handles one client session from accept to disconnect. */ void *nipc_service_posix_session_handler_thread(void *arg) { @@ -277,36 +443,94 @@ void *nipc_service_posix_session_handler_thread(void *arg) pthread_mutex_lock(&start->lock); nipc_session_ctx_t *sctx = start->session; nipc_managed_server_t *server = start->server; + int fd = start->fd; start->copied = true; pthread_cond_signal(&start->copied_cond); pthread_mutex_unlock(&start->lock); - /* Allocate a per-session response buffer */ - size_t resp_size = (size_t)sctx->session.max_response_payload_bytes; - if (resp_size < NIPC_SERVICE_MIN_PAYLOAD_BUFFER_BYTES) - resp_size = NIPC_SERVICE_MIN_PAYLOAD_BUFFER_BYTES; - uint8_t *resp_buf = nipc_service_posix_malloc( - resp_size, NIPC_POSIX_SERVICE_TEST_FAULT_SERVER_RESP_BUF_MALLOC_INTERNAL); - if (resp_buf) { - server_handle_session(server, &sctx->session, sctx->shm, - resp_buf, resp_size); - free(resp_buf); + if (server_session_handshake(server, sctx, fd)) { + /* Allocate a per-session response buffer */ + size_t resp_size = (size_t)sctx->session.max_response_payload_bytes; + if (resp_size < NIPC_SERVICE_MIN_PAYLOAD_BUFFER_BYTES) + resp_size = NIPC_SERVICE_MIN_PAYLOAD_BUFFER_BYTES; + uint8_t *resp_buf = nipc_service_posix_malloc( + resp_size, NIPC_POSIX_SERVICE_TEST_FAULT_SERVER_RESP_BUF_MALLOC_INTERNAL); + if (resp_buf) { + server_handle_session(server, &sctx->session, sctx->shm, + resp_buf, resp_size); + free(resp_buf); + } } /* Cleanup SHM and session */ if (sctx->shm) { nipc_shm_destroy(sctx->shm); free(sctx->shm); + sctx->shm = NULL; } + /* Close under the lock so a concurrent shutdown never targets a + * reused descriptor. */ + pthread_mutex_lock(&server->sessions_lock); nipc_uds_close_session(&sctx->session); + pthread_mutex_unlock(&server->sessions_lock); /* Mark inactive so the acceptor's reap loop (or server destroy) * can join this thread and free sctx. Do NOT remove from the * tracking array here — the reap/destroy path owns that. */ __atomic_store_n(&sctx->active, false, __ATOMIC_RELEASE); + /* sctx may be freed from here on; report through the server only. */ + if (server->test_event_hook) + server->test_event_hook(server->test_event_user, + NIPC_SERVER_TEST_EVENT_SESSION_EXITED); return NULL; } +/* Wait up to grace_ms for session threads to finish on their own, then shut + * down the sockets of the rest so threads blocked in send/recv return. + * Returns true if all finished within the grace. */ +bool nipc_service_posix_server_wait_sessions(nipc_managed_server_t *server, + uint32_t grace_ms) +{ + struct timespec deadline; + clock_gettime(CLOCK_MONOTONIC, &deadline); + deadline.tv_sec += grace_ms / 1000; + deadline.tv_nsec += (grace_ms % 1000) * 1000000L; + if (deadline.tv_nsec >= 1000000000L) { + deadline.tv_sec++; + deadline.tv_nsec -= 1000000000L; + } + + for (;;) { + pthread_mutex_lock(&server->sessions_lock); + int active_count = 0; + for (int i = 0; i < server->session_count; i++) { + if (__atomic_load_n(&server->sessions[i]->active, __ATOMIC_ACQUIRE)) + active_count++; + } + pthread_mutex_unlock(&server->sessions_lock); + + if (active_count == 0) + return true; + + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + if (now.tv_sec > deadline.tv_sec || + (now.tv_sec == deadline.tv_sec && now.tv_nsec >= deadline.tv_nsec)) + break; + + nipc_service_posix_sleep_us(5000); /* 5ms poll interval */ + } + + pthread_mutex_lock(&server->sessions_lock); + for (int i = 0; i < server->session_count; i++) { + nipc_session_ctx_t *s = server->sessions[i]; + if (__atomic_load_n(&s->active, __ATOMIC_ACQUIRE) && s->session.fd >= 0) + shutdown(s->session.fd, SHUT_RDWR); + } + pthread_mutex_unlock(&server->sessions_lock); + return false; +} + /* ------------------------------------------------------------------ */ /* Internal: reap finished session threads */ /* ------------------------------------------------------------------ */ diff --git a/src/libnetdata/netipc/src/service/netipc_service_win.c b/src/libnetdata/netipc/src/service/netipc_service_win.c index 9feb76ca..7a123b7e 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_win.c +++ b/src/libnetdata/netipc/src/service/netipc_service_win.c @@ -25,10 +25,9 @@ #include #include -#define CLIENT_SHM_ATTACH_RETRY_INTERVAL_MS 5u -#define CLIENT_SHM_ATTACH_RETRY_TIMEOUT_MS 5000u -#define CLIENT_CALL_RECONNECT_RETRY_INTERVAL_MS 5u -#define CLIENT_CALL_RECONNECT_RETRIES 20u +#ifdef __MSYS__ +#include +#endif static uint64_t g_win_service_test_fault_state = 0; @@ -95,6 +94,39 @@ static void *service_realloc(void *ptr, size_t size, int fault_site) { return realloc(ptr, size); } +#ifdef __MSYS__ +/* The MSYS runtime does not support threads started with CreateThread: under + * load such a thread may never run and the process dies silently. Threads + * start through pthread_create instead, and each one duplicates its own + * handle for the creator, so callers keep using CancelSynchronousIo, + * WaitForSingleObject and CloseHandle on a real thread handle. */ +typedef struct { + unsigned(__stdcall *start_address)(void *); + void *arglist; + HANDLE started; + HANDLE thread; + DWORD thread_id; +} service_win_thread_start_t; + +static void *service_win_thread_trampoline(void *p) { + service_win_thread_start_t *start = (service_win_thread_start_t *)p; + unsigned(__stdcall *start_address)(void *) = start->start_address; + void *arglist = start->arglist; + HANDLE thread = NULL; + if (!DuplicateHandle(GetCurrentProcess(), GetCurrentThread(), + GetCurrentProcess(), &thread, 0, FALSE, + DUPLICATE_SAME_ACCESS)) + thread = NULL; + start->thread = thread; + start->thread_id = GetCurrentThreadId(); + /* start lives on the creator's stack: no access after this. */ + SetEvent(start->started); + if (thread) + start_address(arglist); + return NULL; +} +#endif + uintptr_t nipc_service_win_beginthreadex(void *security, unsigned stack_size, unsigned(__stdcall *start_address)(void *), @@ -105,14 +137,30 @@ nipc_service_win_beginthreadex(void *security, unsigned stack_size, return 0; #ifdef __MSYS__ { - DWORD thread_id = 0; - HANDLE thread = - CreateThread((LPSECURITY_ATTRIBUTES)security, (SIZE_T)stack_size, - (LPTHREAD_START_ROUTINE)start_address, arglist, - (DWORD)initflag, &thread_id); + /* Callers pass no security attributes, stack size or creation flags. */ + (void)security; + (void)stack_size; + (void)initflag; + service_win_thread_start_t start = { + .start_address = start_address, + .arglist = arglist, + .started = CreateEventW(NULL, TRUE, FALSE, NULL), + }; + if (!start.started) + return 0; + pthread_t thread; + if (pthread_create(&thread, NULL, service_win_thread_trampoline, &start) != + 0) { + CloseHandle(start.started); + return 0; + } + pthread_detach(thread); + /* A created pthread always runs, so this wait ends. */ + WaitForSingleObject(start.started, INFINITE); + CloseHandle(start.started); if (thrdaddr) - *thrdaddr = (unsigned)thread_id; - return (uintptr_t)thread; + *thrdaddr = (unsigned)start.thread_id; + return (uintptr_t)start.thread; } #else return _beginthreadex(security, stack_size, start_address, arglist, initflag, diff --git a/src/libnetdata/netipc/src/service/netipc_service_win_client.c b/src/libnetdata/netipc/src/service/netipc_service_win_client.c index f7e9a54b..e3cd3729 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_win_client.c +++ b/src/libnetdata/netipc/src/service/netipc_service_win_client.c @@ -30,11 +30,6 @@ static nipc_np_client_config_t service_client_config_to_transport( return transport; } -static void client_sleep_ms(uint32_t ms) -{ - Sleep(ms); -} - static void close_abort_event(nipc_client_ctx_t *ctx) { if (ctx->abort_event) { @@ -49,9 +44,6 @@ const nipc_service_common_client_ops_t *nipc_service_win_client_ops(void) .disconnect = nipc_service_win_client_disconnect, .try_connect = nipc_service_win_client_try_connect, .reconnect_for_call = nipc_service_win_client_reconnect_for_call, - .sleep_ms = client_sleep_ms, - .reconnect_drain_ms = CLIENT_CALL_RECONNECT_DRAIN_MS, - .reconnect_retry_interval_ms = CLIENT_CALL_RECONNECT_RETRY_INTERVAL_MS, }; return &ops; } diff --git a/src/libnetdata/netipc/src/service/netipc_service_win_client_call.c b/src/libnetdata/netipc/src/service/netipc_service_win_client_call.c index c7a434c4..4bf7f160 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_win_client_call.c +++ b/src/libnetdata/netipc/src/service/netipc_service_win_client_call.c @@ -21,10 +21,26 @@ /* Internal: send/receive via the active transport */ /* ------------------------------------------------------------------ */ +/* A broken pipe is a disconnect; a plain send/recv failure is one when the + * session probe says the server is gone, and a transport failure otherwise. + * Protocol errors keep their own meaning. */ +static nipc_error_t pipe_failure(nipc_client_ctx_t *ctx, nipc_np_error_t werr, + nipc_error_t otherwise) +{ + if (werr == NIPC_NP_ERR_DISCONNECTED) + return NIPC_ERR_DISCONNECTED; + if (werr == NIPC_NP_ERR_SEND || werr == NIPC_NP_ERR_RECV) + return nipc_service_win_peer_disconnected(&ctx->session) + ? NIPC_ERR_DISCONNECTED + : NIPC_ERR_TRANSPORT; + return otherwise; +} + static nipc_error_t transport_send(nipc_client_ctx_t *ctx, nipc_header_t *hdr, const void *payload, - size_t payload_len) + size_t payload_len, + uint32_t timeout_ms) { if (payload_len > UINT32_MAX) return NIPC_ERR_OVERFLOW; @@ -43,18 +59,31 @@ static nipc_error_t transport_send(nipc_client_ctx_t *ctx, ctx, (uint32_t)payload_len); return NIPC_ERR_OVERFLOW; } - return (serr == NIPC_WIN_SHM_OK) ? NIPC_OK : NIPC_ERR_NOT_READY; + if (serr == NIPC_WIN_SHM_OK) + return NIPC_OK; + return nipc_service_win_peer_disconnected(&ctx->session) + ? NIPC_ERR_DISCONNECTED + : NIPC_ERR_TRANSPORT; } - /* Named Pipe path */ - nipc_np_error_t uerr = nipc_np_send(&ctx->session, hdr, - payload, payload_len); - if (uerr == NIPC_NP_ERR_LIMIT_EXCEEDED) { + /* Named Pipe path. The SHM send above never waits for the peer. */ + nipc_np_error_t uerr = nipc_np_send_timeout( + &ctx->session, hdr, payload, payload_len, timeout_ms, + ctx->abort_event); + switch (uerr) { + case NIPC_NP_OK: + return NIPC_OK; + case NIPC_NP_ERR_LIMIT_EXCEEDED: nipc_service_common_client_note_request_capacity( ctx, (uint32_t)payload_len); return NIPC_ERR_OVERFLOW; + case NIPC_NP_ERR_TIMEOUT: + return NIPC_ERR_TIMEOUT; + case NIPC_NP_ERR_ABORTED: + return NIPC_ERR_ABORTED; + default: + return pipe_failure(ctx, uerr, NIPC_ERR_TRANSPORT); } - return (uerr == NIPC_NP_OK) ? NIPC_OK : NIPC_ERR_NOT_READY; } static nipc_error_t transport_receive(nipc_client_ctx_t *ctx, @@ -89,9 +118,20 @@ static nipc_error_t transport_receive(nipc_client_ctx_t *ctx, ctx->shm, buf, buf_size, &msg_len, wait_ms); if (serr == NIPC_WIN_SHM_OK) break; - if (serr == NIPC_WIN_SHM_ERR_TIMEOUT) + if (serr == NIPC_WIN_SHM_ERR_TIMEOUT) { + /* Only an idle slice pays for the probe. */ + if (nipc_service_win_peer_disconnected(&ctx->session)) + return NIPC_ERR_DISCONNECTED; continue; - return NIPC_ERR_TRUNCATED; + } + if (serr == NIPC_WIN_SHM_ERR_DISCONNECTED) + return NIPC_ERR_DISCONNECTED; + if (serr == NIPC_WIN_SHM_ERR_MSG_TOO_LARGE) + return NIPC_ERR_OVERFLOW; + /* A zero-length or corrupt slot is a truncated message. */ + if (serr == NIPC_WIN_SHM_ERR_BAD_HEADER) + return NIPC_ERR_TRUNCATED; + return NIPC_ERR_TRANSPORT; } return nipc_service_common_client_parse_shm_response( @@ -115,7 +155,7 @@ static nipc_error_t transport_receive(nipc_client_ctx_t *ctx, case NIPC_NP_ERR_LIMIT_EXCEEDED: return NIPC_ERR_OVERFLOW; default: - return NIPC_ERR_TRUNCATED; + return pipe_failure(ctx, uerr, NIPC_ERR_TRUNCATED); } } @@ -155,17 +195,18 @@ nipc_error_t nipc_service_platform_do_raw_call(nipc_client_ctx_t *ctx, nipc_error_t nipc_service_platform_call_with_retry( nipc_client_ctx_t *ctx, nipc_service_platform_attempt_fn attempt, - void *state) + void *state, + uint32_t timeout_ms) { return nipc_service_common_call_with_retry( - ctx, attempt, state, nipc_service_win_client_ops()); + ctx, attempt, state, timeout_ms, nipc_service_win_client_ops()); } nipc_error_t nipc_service_platform_ensure_request_capacity( - nipc_client_ctx_t *ctx, size_t required_payload_bytes) + nipc_client_ctx_t *ctx, size_t required_payload_bytes, uint32_t timeout_ms) { return nipc_service_common_client_ensure_request_capacity( - ctx, required_payload_bytes, nipc_service_win_client_ops()); + ctx, required_payload_bytes, timeout_ms, nipc_service_win_client_ops()); } diff --git a/src/libnetdata/netipc/src/service/netipc_service_win_client_connect.c b/src/libnetdata/netipc/src/service/netipc_service_win_client_connect.c index af914b21..8b8e7295 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_win_client_connect.c +++ b/src/libnetdata/netipc/src/service/netipc_service_win_client_connect.c @@ -79,9 +79,13 @@ nipc_client_state_t nipc_service_win_client_try_connect(nipc_client_ctx_t *ctx) memset(&session, 0, sizeof(session)); session.pipe = INVALID_HANDLE_VALUE; - nipc_np_error_t err = nipc_np_connect( + uint32_t remaining = nipc_service_common_client_op_remaining_ms(ctx); + if (remaining == 0 || nipc_service_common_client_abort_requested(ctx)) + return NIPC_CLIENT_DISCONNECTED; + + nipc_np_error_t err = nipc_np_connect_timeout( ctx->run_dir, ctx->service_name, - &ctx->transport_config, &session); + &ctx->transport_config, remaining, ctx->abort_event, &session); switch (err) { case NIPC_NP_OK: @@ -89,6 +93,7 @@ nipc_client_state_t nipc_service_win_client_try_connect(nipc_client_ctx_t *ctx) case NIPC_NP_ERR_CONNECT: return NIPC_CLIENT_NOT_FOUND; case NIPC_NP_ERR_AUTH_FAILED: + case NIPC_NP_ERR_UNTRUSTED_SERVER: return NIPC_CLIENT_AUTH_FAILED; case NIPC_NP_ERR_NO_PROFILE: case NIPC_NP_ERR_INCOMPATIBLE: @@ -122,7 +127,16 @@ nipc_client_state_t nipc_service_win_client_try_connect(nipc_client_ctx_t *ctx) /* Retry attach: the server prepares SHM before accepting the * handshake, but filesystem/object visibility may lag briefly. */ nipc_win_shm_error_t serr = NIPC_WIN_SHM_ERR_OPEN_MAPPING; - ULONGLONG deadline_ms = GetTickCount64() + CLIENT_SHM_ATTACH_RETRY_TIMEOUT_MS; + bool keep_shm = false; + uint32_t attach_ms = nipc_service_common_client_op_remaining_ms(ctx); + /* When the call deadline ends the wait first, WinSHM is not at + * fault and stays enabled for the next connect. */ + /* The remaining time is rounded up, so a tie means the call + * deadline comes first. */ + bool call_bound = attach_ms <= ctx->shm_attach_timeout_ms; + if (!call_bound) + attach_ms = ctx->shm_attach_timeout_ms; + ULONGLONG deadline_ms = GetTickCount64() + attach_ms; for (;;) { serr = nipc_win_shm_client_attach( ctx->run_dir, ctx->service_name, @@ -136,13 +150,31 @@ nipc_client_state_t nipc_service_win_client_try_connect(nipc_client_ctx_t *ctx) serr != NIPC_WIN_SHM_ERR_OPEN_EVENT && serr != NIPC_WIN_SHM_ERR_BAD_MAGIC) break; - if (GetTickCount64() >= deadline_ms) + if (GetTickCount64() >= deadline_ms) { + keep_shm = call_bound; + break; + } + if (nipc_service_common_client_abort_requested(ctx)) { + keep_shm = true; break; + } + /* A server that refused or died breaks the session pipe; + * waiting for its SHM objects would only burn the timeout. */ + if (nipc_service_win_peer_disconnected(&session)) { + keep_shm = true; + break; + } Sleep(CLIENT_SHM_ATTACH_RETRY_INTERVAL_MS); } if (serr == NIPC_WIN_SHM_OK) { ctx->shm = shm; + } else if (keep_shm) { + /* Not a WinSHM problem: keep WinSHM enabled for the next connect. */ + free(shm); + nipc_np_close_session(&ctx->session); + ctx->session_valid = false; + return NIPC_CLIENT_DISCONNECTED; } else { /* WinSHM attach failed after negotiation. Close that * session, blacklist WinSHM for this client context, and @@ -161,21 +193,12 @@ nipc_client_state_t nipc_service_win_client_try_connect(nipc_client_ctx_t *ctx) return NIPC_CLIENT_READY; } +/* One reconnect per failed call, as the spec states; the call deadline + * bounds it. */ bool nipc_service_win_client_reconnect_for_call(nipc_client_ctx_t *ctx) { - for (uint32_t i = 0; i < CLIENT_CALL_RECONNECT_RETRIES; i++) { - ctx->state = nipc_service_win_client_try_connect(ctx); - if (ctx->state == NIPC_CLIENT_READY) - return true; - if (ctx->state == NIPC_CLIENT_AUTH_FAILED || - ctx->state == NIPC_CLIENT_INCOMPATIBLE) - return false; - if (i + 1u < CLIENT_CALL_RECONNECT_RETRIES) - Sleep(CLIENT_CALL_RECONNECT_RETRY_INTERVAL_MS); - } - - return false; + ctx->state = nipc_service_win_client_try_connect(ctx); + return ctx->state == NIPC_CLIENT_READY; } - #endif /* _WIN32 || __MSYS__ */ diff --git a/src/libnetdata/netipc/src/service/netipc_service_win_internal.h b/src/libnetdata/netipc/src/service/netipc_service_win_internal.h index 2ff0f87a..c35cbc19 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_win_internal.h +++ b/src/libnetdata/netipc/src/service/netipc_service_win_internal.h @@ -11,12 +11,7 @@ #include #include -#define SERVER_POLL_TIMEOUT_MS 100 #define CLIENT_SHM_ATTACH_RETRY_INTERVAL_MS 5u -#define CLIENT_SHM_ATTACH_RETRY_TIMEOUT_MS 5000u -#define CLIENT_CALL_RECONNECT_RETRY_INTERVAL_MS 5u -#define CLIENT_CALL_RECONNECT_DRAIN_MS (SERVER_POLL_TIMEOUT_MS + 50u) -#define CLIENT_CALL_RECONNECT_RETRIES 20u enum { NIPC_WIN_SERVICE_TEST_FAULT_CLIENT_RESPONSE_BUF_REALLOC_INTERNAL = 1, @@ -36,6 +31,16 @@ enum { const nipc_service_common_client_ops_t *nipc_service_win_client_ops(void); void nipc_service_win_client_disconnect(nipc_client_ctx_t *ctx); + +/* Zero-wait probe of a session pipe. WinSHM sessions keep the pipe open for + * their whole life and the kernel breaks it when the peer exits, so a broken + * pipe is the dead-peer signal for both directions. */ +static inline bool nipc_service_win_peer_disconnected(nipc_np_session_t *session) +{ + bool readable = false; + return nipc_np_wait_readable(session, 0, &readable) == + NIPC_NP_ERR_DISCONNECTED; +} nipc_client_state_t nipc_service_win_client_try_connect(nipc_client_ctx_t *ctx); bool nipc_service_win_client_reconnect_for_call(nipc_client_ctx_t *ctx); void *nipc_service_win_malloc(size_t size, int fault_site); @@ -44,6 +49,8 @@ bool nipc_service_win_ensure_buffer(uint8_t **buf, size_t *buf_size, size_t need, int fault_site); unsigned __stdcall nipc_service_win_session_handler_thread(void *arg); void nipc_service_win_server_reap_sessions_locked(nipc_managed_server_t *server); +bool nipc_service_win_server_wait_sessions(nipc_managed_server_t *server, + uint32_t grace_ms); uintptr_t nipc_service_win_beginthreadex(void *security, unsigned stack_size, unsigned (__stdcall *start_address)(void *), diff --git a/src/libnetdata/netipc/src/service/netipc_service_win_server.c b/src/libnetdata/netipc/src/service/netipc_service_win_server.c index 6905a563..a8aab1c8 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_win_server.c +++ b/src/libnetdata/netipc/src/service/netipc_service_win_server.c @@ -21,146 +21,33 @@ /* Internal: managed server session handler */ /* ------------------------------------------------------------------ */ -/* - * Handle one client session: read requests, dispatch to handler, - * send responses. Each session gets its own response buffer. - * Runs until the client disconnects or server stops. - */ -typedef struct { - nipc_win_shm_ctx_t *hybrid; - nipc_win_shm_ctx_t *busywait; -} prepared_win_shm_t; - -static void server_destroy_prepared_win_shm(prepared_win_shm_t *prepared) { - if (!prepared) - return; - if (prepared->hybrid) { - nipc_win_shm_destroy(prepared->hybrid); - free(prepared->hybrid); - prepared->hybrid = NULL; - } - if (prepared->busywait) { - nipc_win_shm_destroy(prepared->busywait); - free(prepared->busywait); - prepared->busywait = NULL; - } -} - -static nipc_win_shm_ctx_t * -server_take_prepared_win_shm(prepared_win_shm_t *prepared, uint32_t profile) { - if (!prepared) - return NULL; - if (profile == NIPC_WIN_SHM_PROFILE_HYBRID) { - nipc_win_shm_ctx_t *ctx = prepared->hybrid; - prepared->hybrid = NULL; - return ctx; - } - if (profile == NIPC_WIN_SHM_PROFILE_BUSYWAIT) { - nipc_win_shm_ctx_t *ctx = prepared->busywait; - prepared->busywait = NULL; - return ctx; - } - return NULL; -} - -static bool server_prepare_accept_config(nipc_managed_server_t *server, - uint64_t sid, - nipc_np_server_config_t *cfg_out, - prepared_win_shm_t *prepared) { - *cfg_out = server->base_config; - cfg_out->max_request_payload_bytes = server->learned_request_payload_bytes; - cfg_out->max_response_payload_bytes = server->learned_response_payload_bytes; - memset(prepared, 0, sizeof(*prepared)); - - uint32_t shm_profiles = - cfg_out->supported_profiles & - (NIPC_WIN_SHM_PROFILE_HYBRID | NIPC_WIN_SHM_PROFILE_BUSYWAIT); - if (shm_profiles == 0) - return true; - - uint32_t request_capacity; - uint32_t response_capacity; - if (!nipc_service_common_header_payload_len_u32( - cfg_out->max_request_payload_bytes, &request_capacity) || - !nipc_service_common_header_payload_len_u32( - cfg_out->max_response_payload_bytes, &response_capacity)) { - cfg_out->supported_profiles &= ~shm_profiles; - cfg_out->preferred_profiles &= ~shm_profiles; - return cfg_out->supported_profiles != 0; - } - - const uint32_t profiles[] = { - NIPC_WIN_SHM_PROFILE_HYBRID, - NIPC_WIN_SHM_PROFILE_BUSYWAIT, - }; - for (size_t i = 0; i < sizeof(profiles) / sizeof(profiles[0]); i++) { - uint32_t profile = profiles[i]; - if (!(cfg_out->supported_profiles & profile)) - continue; - - nipc_win_shm_ctx_t *ctx = nipc_service_win_calloc( - 1, sizeof(nipc_win_shm_ctx_t), - NIPC_WIN_SERVICE_TEST_FAULT_SERVER_SHM_CTX_CALLOC_INTERNAL); - if (!ctx) - continue; - - /* HELLO has not been read yet, so the request segment must cover any - * client proposal the handshake may legally echo back. */ - nipc_win_shm_error_t serr = nipc_win_shm_server_create( - server->run_dir, server->service_name, server->auth_token, sid, profile, - request_capacity, response_capacity, ctx); - if (serr == NIPC_WIN_SHM_OK) { - if (profile == NIPC_WIN_SHM_PROFILE_HYBRID) - prepared->hybrid = ctx; - else - prepared->busywait = ctx; - continue; - } - - free(ctx); - cfg_out->supported_profiles &= ~profile; - cfg_out->preferred_profiles &= ~profile; - } - - return cfg_out->supported_profiles != 0; -} - +/* Wake an accept loop blocked in ConnectNamedPipe by connecting to it. + * Uses only the pipe name, which is fixed after listen; the pipe handle + * changes on every accept. */ static void server_wake_listener(nipc_managed_server_t *server) { - if (server->listener.pipe == INVALID_HANDLE_VALUE || - server->listener.pipe == NULL || server->listener.pipe_name[0] == L'\0') + if (server->listener.pipe_name[0] == L'\0') return; HANDLE wake = CreateFileW(server->listener.pipe_name, GENERIC_READ | GENERIC_WRITE, 0, - NULL, OPEN_EXISTING, 0, NULL); + NULL, OPEN_EXISTING, + SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION, NULL); if (wake != INVALID_HANDLE_VALUE) CloseHandle(wake); } -/* Ask active session threads to leave synchronous ReadFile/WriteFile waits. - * This is safer than targeting pipe handles from another thread because the - * thread handle stays valid until the owner joins it. */ -static void -server_cancel_active_session_io_locked(nipc_managed_server_t *server) { - for (int i = 0; i < server->session_count; i++) { - nipc_session_ctx_t *s = server->sessions[i]; - if (!s) - continue; - if (!InterlockedCompareExchange(&s->active, 0, 0)) - continue; - if (s->thread == NULL || s->thread == INVALID_HANDLE_VALUE) - continue; - CancelSynchronousIo(s->thread); - } -} - +/* Wait for the accept loop to exit, re-sending the wake: a real client can + * take the free pipe instance before the wake connection does. */ static void server_wait_accept_loop_inactive(nipc_managed_server_t *server) { LONG current_thread_id = (LONG)GetCurrentThreadId(); - while (InterlockedCompareExchange(&server->accept_loop_active, 0, 0)) { + for (unsigned i = 0; + InterlockedCompareExchange(&server->accept_loop_active, 0, 0); i++) { LONG owner = InterlockedCompareExchange(&server->accept_loop_thread_id, 0, 0); if (owner == current_thread_id) return; + if (i % 50 == 49) + server_wake_listener(server); Sleep(1); } } @@ -191,6 +78,10 @@ nipc_error_t nipc_service_platform_server_init_raw( if (ierr != NIPC_OK) return ierr; server->base_config = *config; + /* 0 selects the baseline profile, as in the transport handshake; the + * accept path masks profiles and would otherwise reject every client. */ + if (server->base_config.supported_profiles == 0) + server->base_config.supported_profiles = NIPC_PROFILE_BASELINE; server->auth_token = config->auth_token; ierr = nipc_service_common_server_alloc_sessions( server, nipc_service_win_calloc, @@ -227,194 +118,170 @@ nipc_server_init_raw_for_tests(nipc_managed_server_t *server, handler, user); } +static void server_request_stop(nipc_managed_server_t *server) { + InterlockedExchange(&server->stop_requested, 1); + InterlockedExchange(&server->running, 0); +} + +static bool server_stop_requested(nipc_managed_server_t *server) { + return InterlockedCompareExchange(&server->stop_requested, 0, 0) != 0; +} + +/* Takes sessions_lock and keeps it on return. When all workers are busy, + * waits up to two poll slices for one to finish (a worker whose client left + * exits within one), so a client that + * reconnects right after closing is not refused while its old worker exits. + * The loop has nothing else to serve while full, so waiting costs nothing. */ +static bool server_wait_for_slot_locked(nipc_managed_server_t *server) { + ULONGLONG deadline = GetTickCount64() + 2u * (ULONGLONG)server->poll_ms; + EnterCriticalSection(&server->sessions_lock); + for (;;) { + nipc_service_win_server_reap_sessions_locked(server); + if (server->session_count < server->worker_count) + return true; + if (!InterlockedCompareExchange(&server->running, 0, 0) || + GetTickCount64() >= deadline) + return false; + LeaveCriticalSection(&server->sessions_lock); + Sleep(1); + EnterCriticalSection(&server->sessions_lock); + } +} + void nipc_server_run(nipc_managed_server_t *server) { + /* A stop, drain or destroy before run must win: they may already have + * released the listener and the session lock. */ + if (server_stop_requested(server)) + return; + /* running is set before the stop check: a stop the check misses clears + * it afterwards. The loop is published, then the stop is checked; drain + * and destroy publish the stop, then check the loop. Interlocked calls + * are full barriers, so at least one side sees the other: either they + * wait for this loop, or it sees the stop and leaves the listener to + * them. */ InterlockedExchange(&server->accept_loop_thread_id, (LONG)GetCurrentThreadId()); - InterlockedExchange(&server->accept_loop_active, 1); InterlockedExchange(&server->running, 1); + InterlockedExchange(&server->accept_loop_active, 1); + if (server_stop_requested(server)) { + InterlockedExchange(&server->running, 0); + InterlockedExchange(&server->accept_loop_active, 0); + InterlockedExchange(&server->accept_loop_thread_id, 0); + return; + } while (InterlockedCompareExchange(&server->running, 0, 0)) { - /* Accept one client via L1 (blocking with internal timeout) */ - nipc_np_session_t session; - memset(&session, 0, sizeof(session)); - session.pipe = INVALID_HANDLE_VALUE; - - uint64_t sid = server->next_session_id++; - nipc_np_server_config_t accept_cfg; - prepared_win_shm_t prepared_shm; - if (!server_prepare_accept_config(server, sid, &accept_cfg, - &prepared_shm)) { - Sleep(10); - continue; - } - - server->listener.config = accept_cfg; - nipc_np_error_t uerr = nipc_np_accept(&server->listener, sid, &session); + /* The handshake runs on the session thread, so a client that never + * sends HELLO cannot stall this loop. */ + HANDLE pipe = INVALID_HANDLE_VALUE; + nipc_np_error_t uerr = nipc_np_accept_pending(&server->listener, &pipe); if (uerr != NIPC_NP_OK) { - server_destroy_prepared_win_shm(&prepared_shm); if (!InterlockedCompareExchange(&server->running, 0, 0)) break; Sleep(10); continue; } - nipc_service_common_server_note_request_capacity( - server, session.max_request_payload_bytes); - nipc_service_common_server_note_response_capacity( - server, session.max_response_payload_bytes); - - /* Enforce worker_count limit: reap finished sessions, check count */ - EnterCriticalSection(&server->sessions_lock); - nipc_service_win_server_reap_sessions_locked(server); + /* Stop wakes ConnectNamedPipe with a throwaway connection. */ + if (!InterlockedCompareExchange(&server->running, 0, 0)) { + DisconnectNamedPipe(pipe); + CloseHandle(pipe); + break; + } - if (server->session_count >= server->worker_count) { - /* At capacity: reject this client by closing the session */ + /* Enforce worker_count limit: a connection holds its slot from accept, + * handshake included. Refuse before HELLO_ACK so the client's connect + * fails instead of a later call. */ + if (!server_wait_for_slot_locked(server)) { LeaveCriticalSection(&server->sessions_lock); - server_destroy_prepared_win_shm(&prepared_shm); - nipc_np_close_session(&session); + DisconnectNamedPipe(pipe); + CloseHandle(pipe); continue; } - /* SHM profile guarantee: only negotiate SHM for sessions backed by - * prepared per-session kernel objects for the selected profile. */ - nipc_win_shm_ctx_t *shm = NULL; - if (session.selected_profile == NIPC_WIN_SHM_PROFILE_HYBRID || - session.selected_profile == NIPC_WIN_SHM_PROFILE_BUSYWAIT) { - shm = - server_take_prepared_win_shm(&prepared_shm, session.selected_profile); - if (!shm) { - server_destroy_prepared_win_shm(&prepared_shm); - LeaveCriticalSection(&server->sessions_lock); - nipc_np_close_session(&session); - continue; - } - server_destroy_prepared_win_shm(&prepared_shm); - } else { - server_destroy_prepared_win_shm(&prepared_shm); - } - - /* Create session context */ nipc_session_ctx_t *sctx = nipc_service_win_calloc( 1, sizeof(nipc_session_ctx_t), NIPC_WIN_SERVICE_TEST_FAULT_SERVER_SESSION_CTX_CALLOC_INTERNAL); if (!sctx) { LeaveCriticalSection(&server->sessions_lock); - if (shm) { - nipc_win_shm_destroy(shm); - free(shm); - } - nipc_np_close_session(&session); + DisconnectNamedPipe(pipe); + CloseHandle(pipe); continue; } sctx->server = server; - sctx->session = session; - sctx->shm = shm; - sctx->id = sid; + /* The thread takes the pending pipe from here and replaces it with + * the established session. */ + sctx->session.pipe = pipe; + sctx->id = server->next_session_id++; InterlockedExchange(&sctx->active, 1); - server->sessions[server->session_count++] = sctx; - LeaveCriticalSection(&server->sessions_lock); - - /* Spawn handler thread for this session */ + /* Create the thread under the lock so stop paths never see a session + * without its thread handle. */ unsigned tid_unused; sctx->thread = (HANDLE)nipc_service_win_beginthreadex( NULL, 0, nipc_service_win_session_handler_thread, sctx, 0, &tid_unused); if (sctx->thread == 0) { - /* Thread creation failed: clean up */ - EnterCriticalSection(&server->sessions_lock); - for (int i = 0; i < server->session_count; i++) { - if (server->sessions[i] == sctx) { - server->sessions[i] = server->sessions[server->session_count - 1]; - server->session_count--; - break; - } - } LeaveCriticalSection(&server->sessions_lock); - - if (shm) { - nipc_win_shm_destroy(shm); - free(shm); - } - nipc_np_close_session(&session); + DisconnectNamedPipe(pipe); + CloseHandle(pipe); free(sctx); + continue; } + + server->sessions[server->session_count++] = sctx; + LeaveCriticalSection(&server->sessions_lock); } + /* Close before marking inactive: drain/destroy close the listener only + * after the loop is gone, so the two never race. */ + nipc_np_close_listener(&server->listener); InterlockedExchange(&server->accept_loop_active, 0); InterlockedExchange(&server->accept_loop_thread_id, 0); - nipc_np_close_listener(&server->listener); } void nipc_server_stop(nipc_managed_server_t *server) { - InterlockedExchange(&server->running, 0); + server_request_stop(server); if (InterlockedCompareExchange(&server->accept_loop_active, 0, 0)) server_wake_listener(server); - else - nipc_np_close_listener(&server->listener); +} - if (server->sessions) { - EnterCriticalSection(&server->sessions_lock); - server_cancel_active_session_io_locked(server); +/* Stop the accept loop and release the listener. */ +static void server_stop_accepting(nipc_managed_server_t *server) { + server_request_stop(server); + if (InterlockedCompareExchange(&server->accept_loop_active, 0, 0)) + server_wake_listener(server); + server_wait_accept_loop_inactive(server); + nipc_np_close_listener(&server->listener); +} + +static void server_join_sessions(nipc_managed_server_t *server) { + EnterCriticalSection(&server->sessions_lock); + for (int i = 0; i < server->session_count; i++) { + nipc_session_ctx_t *s = server->sessions[i]; LeaveCriticalSection(&server->sessions_lock); + WaitForSingleObject(s->thread, INFINITE); + CloseHandle(s->thread); + free(s); + EnterCriticalSection(&server->sessions_lock); } + server->session_count = 0; + LeaveCriticalSection(&server->sessions_lock); + + free(server->sessions); + server->sessions = NULL; + server->session_capacity = 0; + DeleteCriticalSection(&server->sessions_lock); } bool nipc_server_drain(nipc_managed_server_t *server, uint32_t timeout_ms) { /* 1. Stop accepting new clients */ - InterlockedExchange(&server->running, 0); - if (InterlockedCompareExchange(&server->accept_loop_active, 0, 0)) - server_wake_listener(server); - else - nipc_np_close_listener(&server->listener); - server_wait_accept_loop_inactive(server); + server_stop_accepting(server); - /* 2. Wait for in-flight sessions to complete */ + /* 2. Wait for in-flight sessions, 3. join them */ bool all_drained = true; if (server->sessions) { - ULONGLONG deadline = GetTickCount64() + timeout_ms; - - /* Poll until all sessions are inactive or timeout */ - while (1) { - EnterCriticalSection(&server->sessions_lock); - int active_count = 0; - for (int i = 0; i < server->session_count; i++) { - if (InterlockedCompareExchange(&server->sessions[i]->active, 0, 0)) - active_count++; - } - LeaveCriticalSection(&server->sessions_lock); - - if (active_count == 0) - break; - - if (GetTickCount64() >= deadline) { - /* Timeout: cancel synchronous session I/O to unblock threads. */ - EnterCriticalSection(&server->sessions_lock); - server_cancel_active_session_io_locked(server); - LeaveCriticalSection(&server->sessions_lock); - all_drained = false; - break; - } - - Sleep(5); /* 5ms poll interval */ - } - - /* 3. Join all session threads */ - EnterCriticalSection(&server->sessions_lock); - for (int i = 0; i < server->session_count; i++) { - nipc_session_ctx_t *s = server->sessions[i]; - LeaveCriticalSection(&server->sessions_lock); - WaitForSingleObject(s->thread, INFINITE); - CloseHandle(s->thread); - free(s); - EnterCriticalSection(&server->sessions_lock); - } - server->session_count = 0; - LeaveCriticalSection(&server->sessions_lock); - - free(server->sessions); - server->sessions = NULL; - server->session_capacity = 0; - DeleteCriticalSection(&server->sessions_lock); + all_drained = nipc_service_win_server_wait_sessions(server, timeout_ms); + server_join_sessions(server); } server->worker_count = 0; @@ -423,32 +290,11 @@ bool nipc_server_drain(nipc_managed_server_t *server, uint32_t timeout_ms) { } void nipc_server_destroy(nipc_managed_server_t *server) { - InterlockedExchange(&server->running, 0); - if (InterlockedCompareExchange(&server->accept_loop_active, 0, 0)) - server_wake_listener(server); - else - nipc_np_close_listener(&server->listener); - server_wait_accept_loop_inactive(server); + server_stop_accepting(server); - /* Join all active session threads */ if (server->sessions) { - EnterCriticalSection(&server->sessions_lock); - server_cancel_active_session_io_locked(server); - for (int i = 0; i < server->session_count; i++) { - nipc_session_ctx_t *s = server->sessions[i]; - LeaveCriticalSection(&server->sessions_lock); - WaitForSingleObject(s->thread, INFINITE); - CloseHandle(s->thread); - free(s); - EnterCriticalSection(&server->sessions_lock); - } - server->session_count = 0; - LeaveCriticalSection(&server->sessions_lock); - - free(server->sessions); - server->sessions = NULL; - server->session_capacity = 0; - DeleteCriticalSection(&server->sessions_lock); + (void)nipc_service_win_server_wait_sessions(server, server->stop_grace_ms); + server_join_sessions(server); } server->worker_count = 0; diff --git a/src/libnetdata/netipc/src/service/netipc_service_win_server_session.c b/src/libnetdata/netipc/src/service/netipc_service_win_server_session.c index 106bfc2f..df4bd1e7 100644 --- a/src/libnetdata/netipc/src/service/netipc_service_win_server_session.c +++ b/src/libnetdata/netipc/src/service/netipc_service_win_server_session.c @@ -43,9 +43,14 @@ static void server_handle_session(nipc_managed_server_t *server, if (shm) { size_t msg_len; nipc_win_shm_error_t serr = nipc_win_shm_receive(shm, recv_buf, recv_size, - &msg_len, SERVER_POLL_TIMEOUT_MS); - if (serr == NIPC_WIN_SHM_ERR_TIMEOUT) + &msg_len, server->poll_ms); + if (serr == NIPC_WIN_SHM_ERR_TIMEOUT) { + /* A crashed client never sets the close flag; its broken + * pipe is the only sign it left. */ + if (nipc_service_win_peer_disconnected(session)) + break; continue; + } if (serr != NIPC_WIN_SHM_OK) break; if (msg_len < NIPC_HEADER_LEN) @@ -64,7 +69,7 @@ static void server_handle_session(nipc_managed_server_t *server, * request. */ bool readable = false; nipc_np_error_t werr = nipc_np_wait_readable( - session, SERVER_POLL_TIMEOUT_MS, &readable); + session, server->poll_ms, &readable); if (werr == NIPC_NP_ERR_DISCONNECTED) break; if (werr != NIPC_NP_OK) @@ -199,35 +204,248 @@ static void server_handle_session(nipc_managed_server_t *server, /* Internal: per-session handler thread */ /* ------------------------------------------------------------------ */ -/* Thread function: handles one client session from accept to disconnect. */ +static void server_destroy_win_shm(nipc_win_shm_ctx_t **shm) { + if (!shm || !*shm) + return; + nipc_win_shm_destroy(*shm); + free(*shm); + *shm = NULL; +} + +static bool server_prepare_accept_config(nipc_managed_server_t *server, + nipc_np_server_config_t *cfg_out) { + *cfg_out = server->base_config; + /* Workers grow these concurrently (InterlockedCompareExchange); read + * them with the same interlocked access. */ + cfg_out->max_request_payload_bytes = (uint32_t)InterlockedCompareExchange( + (volatile LONG *)&server->learned_request_payload_bytes, 0, 0); + cfg_out->max_response_payload_bytes = (uint32_t)InterlockedCompareExchange( + (volatile LONG *)&server->learned_response_payload_bytes, 0, 0); + + uint32_t shm_profiles = + cfg_out->supported_profiles & + (NIPC_WIN_SHM_PROFILE_HYBRID | NIPC_WIN_SHM_PROFILE_BUSYWAIT); + uint32_t request_capacity; + uint32_t response_capacity; + if (shm_profiles != 0 && + (!nipc_service_common_header_payload_len_u32( + cfg_out->max_request_payload_bytes, &request_capacity) || + !nipc_service_common_header_payload_len_u32( + cfg_out->max_response_payload_bytes, &response_capacity))) { + cfg_out->supported_profiles &= ~shm_profiles; + cfg_out->preferred_profiles &= ~shm_profiles; + } + return cfg_out->supported_profiles != 0; +} + +typedef struct { + nipc_managed_server_t *server; + uint64_t session_id; + nipc_win_shm_ctx_t *shm; +} server_shm_prepare_t; + +/* Handshake prepare step: create the session's kernel objects for the + * selected SHM profile only, sized for the agreed limits. */ +static bool server_prepare_profile(void *user, uint32_t profile, + uint32_t max_request_payload_bytes, + uint32_t max_response_payload_bytes) { + server_shm_prepare_t *prep = (server_shm_prepare_t *)user; + if (profile != NIPC_WIN_SHM_PROFILE_HYBRID && + profile != NIPC_WIN_SHM_PROFILE_BUSYWAIT) + return true; + + uint32_t request_capacity; + uint32_t response_capacity; + if (!nipc_service_common_header_payload_len_u32(max_request_payload_bytes, + &request_capacity) || + !nipc_service_common_header_payload_len_u32(max_response_payload_bytes, + &response_capacity)) + return false; + + nipc_win_shm_ctx_t *ctx = nipc_service_win_calloc( + 1, sizeof(nipc_win_shm_ctx_t), + NIPC_WIN_SERVICE_TEST_FAULT_SERVER_SHM_CTX_CALLOC_INTERNAL); + if (!ctx) + return false; + + nipc_managed_server_t *server = prep->server; + if (nipc_win_shm_server_create(server->run_dir, server->service_name, + server->auth_token, prep->session_id, + profile, request_capacity, + response_capacity, ctx) != NIPC_WIN_SHM_OK) { + free(ctx); + return false; + } + + prep->shm = ctx; + if (server->test_event_hook) + server->test_event_hook(server->test_event_user, + NIPC_SERVER_TEST_EVENT_SHM_CREATED); + return true; +} + +/* Wait for HELLO without outliving a server stop or the handshake + * deadline. */ +static bool server_wait_hello(nipc_managed_server_t *server, HANDLE pipe) +{ + ULONGLONG deadline = GetTickCount64() + server->handshake_ms; + for (;;) { + DWORD available = 0; + if (!PeekNamedPipe(pipe, NULL, 0, NULL, &available, NULL)) + return false; + if (available > 0) + return true; + if (!InterlockedCompareExchange(&server->running, 0, 0) || + GetTickCount64() >= deadline) + return false; + Sleep(1); + } +} + +/* Run the handshake for an accepted pipe and publish the session. + * Takes ownership of pipe. Returns false if the session must not start. */ +static bool server_session_handshake(nipc_managed_server_t *server, + nipc_session_ctx_t *sctx, HANDLE pipe) +{ + nipc_np_server_config_t cfg; + if (!server_prepare_accept_config(server, &cfg)) { + DisconnectNamedPipe(pipe); + CloseHandle(pipe); + return false; + } + if (server->test_event_hook) + server->test_event_hook(server->test_event_user, + NIPC_SERVER_TEST_EVENT_HANDSHAKE_STARTED); + if (!server_wait_hello(server, pipe)) { + DisconnectNamedPipe(pipe); + CloseHandle(pipe); + return false; + } + + /* HELLO is readable, so this does not block on the client. */ + server_shm_prepare_t prep = { .server = server, .session_id = sctx->id }; + nipc_np_session_t session; + if (nipc_np_accept_handshake_prepared(pipe, &cfg, sctx->id, server->poll_ms, + server_prepare_profile, &prep, + &session) != NIPC_NP_OK) { + server_destroy_win_shm(&prep.shm); + return false; + } + + nipc_service_common_server_note_request_capacity( + server, session.max_request_payload_bytes); + nipc_service_common_server_note_response_capacity( + server, session.max_response_payload_bytes); + + /* SHM profile guarantee: the prepare step created the selected + * profile's kernel objects before the ACK selected it. */ + nipc_win_shm_ctx_t *shm = prep.shm; + if (session.selected_profile == NIPC_WIN_SHM_PROFILE_HYBRID || + session.selected_profile == NIPC_WIN_SHM_PROFILE_BUSYWAIT) { + if (!shm) { + nipc_np_close_session(&session); + return false; + } + } else { + server_destroy_win_shm(&shm); + } + + EnterCriticalSection(&server->sessions_lock); + sctx->session = session; + sctx->shm = shm; + LeaveCriticalSection(&server->sessions_lock); + return true; +} + unsigned __stdcall nipc_service_win_session_handler_thread(void *arg) { nipc_session_ctx_t *sctx = (nipc_session_ctx_t *)arg; nipc_managed_server_t *server = sctx->server; - /* Allocate a per-session response buffer */ - size_t resp_size = (size_t)sctx->session.max_response_payload_bytes; - if (resp_size < NIPC_SERVICE_MIN_PAYLOAD_BUFFER_BYTES) - resp_size = NIPC_SERVICE_MIN_PAYLOAD_BUFFER_BYTES; - uint8_t *resp_buf = nipc_service_win_malloc( - resp_size, NIPC_WIN_SERVICE_TEST_FAULT_SERVER_RESP_BUF_MALLOC_INTERNAL); - if (resp_buf) { - server_handle_session(server, &sctx->session, sctx->shm, - resp_buf, resp_size); - free(resp_buf); + HANDLE pipe = sctx->session.pipe; + sctx->session.pipe = INVALID_HANDLE_VALUE; + + if (server_session_handshake(server, sctx, pipe)) { + /* Allocate a per-session response buffer */ + size_t resp_size = (size_t)sctx->session.max_response_payload_bytes; + if (resp_size < NIPC_SERVICE_MIN_PAYLOAD_BUFFER_BYTES) + resp_size = NIPC_SERVICE_MIN_PAYLOAD_BUFFER_BYTES; + uint8_t *resp_buf = nipc_service_win_malloc( + resp_size, NIPC_WIN_SERVICE_TEST_FAULT_SERVER_RESP_BUF_MALLOC_INTERNAL); + if (resp_buf) { + server_handle_session(server, &sctx->session, sctx->shm, + resp_buf, resp_size); + free(resp_buf); + } } /* Cleanup SHM and session */ if (sctx->shm) { nipc_win_shm_destroy(sctx->shm); free(sctx->shm); + sctx->shm = NULL; } nipc_np_close_session(&sctx->session); /* Mark inactive; the reap/destroy path owns removal from the array */ InterlockedExchange(&sctx->active, 0); + /* sctx may be freed from here on; report through the server only. */ + if (server->test_event_hook) + server->test_event_hook(server->test_event_user, + NIPC_SERVER_TEST_EVENT_SESSION_EXITED); return 0; } +/* Ask active session threads to leave synchronous ReadFile/WriteFile + * waits. Targets thread handles, which stay valid until the + * owner joins them, never pipe handles. Returns the number still active. */ +static int server_cancel_active_session_io_locked(nipc_managed_server_t *server) +{ + int active = 0; + for (int i = 0; i < server->session_count; i++) { + nipc_session_ctx_t *s = server->sessions[i]; + if (!s || !InterlockedCompareExchange(&s->active, 0, 0)) + continue; + active++; + if (s->thread != NULL && s->thread != INVALID_HANDLE_VALUE) + CancelSynchronousIo(s->thread); + } + return active; +} + +/* Wait up to grace_ms for session threads to finish on their own, then + * cancel their pipe I/O until they exit. A single cancel can land before a + * thread enters its next blocking call, so it repeats. Returns true if all + * finished within the grace. */ +bool nipc_service_win_server_wait_sessions(nipc_managed_server_t *server, + uint32_t grace_ms) +{ + ULONGLONG deadline = GetTickCount64() + grace_ms; + for (;;) { + EnterCriticalSection(&server->sessions_lock); + int active_count = 0; + for (int i = 0; i < server->session_count; i++) { + if (InterlockedCompareExchange(&server->sessions[i]->active, 0, 0)) + active_count++; + } + LeaveCriticalSection(&server->sessions_lock); + + if (active_count == 0) + return true; + if (GetTickCount64() >= deadline) + break; + Sleep(5); /* 5ms poll interval */ + } + + for (;;) { + EnterCriticalSection(&server->sessions_lock); + int active = server_cancel_active_session_io_locked(server); + LeaveCriticalSection(&server->sessions_lock); + if (active == 0) + return false; + Sleep(10); + } +} + /* ------------------------------------------------------------------ */ /* Internal: reap finished session threads */ /* ------------------------------------------------------------------ */ diff --git a/src/libnetdata/netipc/src/transport/posix/netipc_shm.c b/src/libnetdata/netipc/src/transport/posix/netipc_shm.c index 067a2b3d..b9c6e274 100644 --- a/src/libnetdata/netipc/src/transport/posix/netipc_shm.c +++ b/src/libnetdata/netipc/src/transport/posix/netipc_shm.c @@ -2,16 +2,96 @@ #define _GNU_SOURCE #endif +/* Run-directory scans and stats must survive 64-bit inode numbers and + * directory offsets; 32-bit glibc builds without LFS fail them with + * EOVERFLOW. Only file-local types are affected (no off_t/stat in headers). */ +#ifndef _FILE_OFFSET_BITS +#define _FILE_OFFSET_BITS 64 +#endif + /* - * netipc_shm.c - L1 POSIX SHM transport (Linux only). + * netipc_shm.c - L1 POSIX SHM transport (Linux and FreeBSD). * - * Shared memory data plane with spin+futex synchronization. + * Shared memory data plane with spin+futex synchronization (FreeBSD: + * _umtx_op wait/wake on the same signal words). * Uses the same wire envelope as UDS -- higher levels are unaware * of the underlying transport. */ #include "netipc/netipc_shm.h" +#if !defined(__linux__) && !defined(__FreeBSD__) + +/* POSIX SHM needs a futex-style wait/wake (Linux futex, FreeBSD _umtx_op). + * The handshake never selects an SHM profile on other systems, so these + * entry points only report that. */ + +nipc_shm_error_t nipc_shm_server_create(const char *run_dir, + const char *service_name, + uint64_t session_id, + uint32_t req_capacity, + uint32_t resp_capacity, + nipc_shm_ctx_t *out) { + (void)run_dir; + (void)service_name; + (void)session_id; + (void)req_capacity; + (void)resp_capacity; + (void)out; + return NIPC_SHM_ERR_UNSUPPORTED; +} + +void nipc_shm_destroy(nipc_shm_ctx_t *ctx) { (void)ctx; } + +nipc_shm_error_t nipc_shm_client_attach(const char *run_dir, + const char *service_name, + uint64_t session_id, + nipc_shm_ctx_t *out) { + (void)run_dir; + (void)service_name; + (void)session_id; + (void)out; + return NIPC_SHM_ERR_UNSUPPORTED; +} + +void nipc_shm_close(nipc_shm_ctx_t *ctx) { (void)ctx; } + +nipc_shm_error_t nipc_shm_send(nipc_shm_ctx_t *ctx, const void *msg, + size_t msg_len) { + (void)ctx; + (void)msg; + (void)msg_len; + return NIPC_SHM_ERR_UNSUPPORTED; +} + +nipc_shm_error_t nipc_shm_receive(nipc_shm_ctx_t *ctx, void *buf, + size_t buf_size, size_t *msg_len_out, + uint32_t timeout_ms) { + (void)ctx; + (void)buf; + (void)buf_size; + (void)msg_len_out; + (void)timeout_ms; + return NIPC_SHM_ERR_UNSUPPORTED; +} + +void nipc_shm_cleanup_stale(const char *run_dir, const char *service_name) { + (void)run_dir; + (void)service_name; +} + +#ifdef NIPC_INTERNAL_TESTING +void nipc_shm_test_fault_set(nipc_shm_test_fault_site_t site, + int error_number) { + (void)site; + (void)error_number; +} + +void nipc_shm_test_fault_clear(void) {} +#endif + +#else /* __linux__ || __FreeBSD__ */ + #include #include #include @@ -27,7 +107,11 @@ #include #include +#if defined(__linux__) #include +#else +#include +#endif /* ------------------------------------------------------------------ */ /* Internal helpers */ @@ -93,6 +177,9 @@ static int build_shm_path(char *dst, size_t dst_len, const char *run_dir, const char *service_name, uint64_t session_id) { + if (!run_dir || run_dir[0] == '\0') + return -2; + char shm_name[256]; int name_rc = build_shm_name(shm_name, sizeof(shm_name), service_name, session_id); @@ -115,12 +202,14 @@ static bool stale_check_unavailable(int err) return err == EMFILE || err == ENFILE; } -/* Remove a junk entry at an endpoint name; directories are junk too. */ +/* Remove a junk entry at an endpoint name; directories are junk too. + * Unlinking a directory fails with EISDIR on Linux and EPERM on FreeBSD. */ static int unlink_stale_name(int dir_fd, const char *name) { if (unlinkat(dir_fd, name, 0) == 0 || errno == ENOENT) return 0; - if (errno == EISDIR && unlinkat(dir_fd, name, AT_REMOVEDIR) == 0) + if ((errno == EISDIR || errno == EPERM) && + unlinkat(dir_fd, name, AT_REMOVEDIR) == 0) return 0; return -1; } @@ -137,6 +226,7 @@ static int open_run_dir_fd(const char *run_dir) return fd; } +#if defined(__linux__) /* Use the kernel ABI, not libc's timespec (which can use time64 on a * legacy 32-bit syscall ABI). All receive budgets are uint32_t milliseconds, * so their relative seconds fit even the legacy signed 32-bit field. @@ -165,6 +255,46 @@ static int futex_wait(uint32_t *addr, uint32_t expected, timeout ? kernel_timeout : NULL, NULL, 0); } +/* Reserve the whole region; 0 on success, -1 with errno on failure. */ +static int shm_allocate(int fd, off_t size) +{ + return fallocate(fd, 0, 0, size); +} +#else +/* The shared (not _PRIVATE) operations key on the mapped object, so they + * cross processes. A wait whose expected value is already stale returns 0, + * where a Linux futex returns EAGAIN; callers re-check either way. */ +static int futex_wake(uint32_t *addr, int count) +{ + return _umtx_op(addr, UMTX_OP_WAKE, count, NULL, NULL); +} + +static int futex_wait(uint32_t *addr, uint32_t expected, + const struct timespec *timeout) +{ + struct _umtx_time t; + if (!timeout) + return _umtx_op(addr, UMTX_OP_WAIT_UINT, expected, NULL, NULL); + memset(&t, 0, sizeof(t)); + t._timeout = *timeout; + t._clockid = CLOCK_MONOTONIC; + return _umtx_op(addr, UMTX_OP_WAIT_UINT, expected, + (void *)(uintptr_t)sizeof(t), &t); +} + +/* Reserve the whole region; 0 on success, -1 with errno on failure. + * posix_fallocate returns the error instead of setting errno. ZFS rejects + * it with EINVAL: SHM preparation fails and the session uses baseline. */ +static int shm_allocate(int fd, off_t size) +{ + int rc = posix_fallocate(fd, 0, size); + if (rc == 0) + return 0; + errno = rc; + return -1; +} +#endif + /* CPU pause hint for spin loops. */ static inline void cpu_relax(void) { @@ -371,10 +501,25 @@ nipc_shm_error_t nipc_shm_server_create(const char *run_dir, close(dir_fd); return NIPC_SHM_ERR_OPEN; } + /* The file takes 0600 & ~umask; a umask without owner read or write + * would stop the client from attaching read-write. A region the client + * cannot attach is not created: the session then drops SHM. */ + int chmod_rc; + do { + chmod_rc = fchmod(fd, S_IRUSR | S_IWUSR); + } while (chmod_rc < 0 && errno == EINTR); + if (chmod_rc < 0) { + int chmod_errno = errno; + close(fd); + unlinkat(dir_fd, shm_name, 0); + close(dir_fd); + errno = chmod_errno; + return NIPC_SHM_ERR_OPEN; + } int allocate_rc; do { - allocate_rc = fallocate(fd, 0, 0, (off_t)region_size); + allocate_rc = shm_allocate(fd, (off_t)region_size); } while (allocate_rc < 0 && errno == EINTR); if (allocate_rc < 0) { @@ -431,7 +576,6 @@ nipc_shm_error_t nipc_shm_server_create(const char *run_dir, out->local_req_seq = 0; out->local_resp_seq = 0; out->spin_tries = NIPC_SHM_DEFAULT_SPIN; - out->owner_generation = hdr->owner_generation; if (copy_cstr_checked(out->path, sizeof(out->path), path) != 0) { munmap(map, region_size); close(fd); @@ -586,6 +730,14 @@ nipc_shm_error_t nipc_shm_client_attach(const char *run_dir, return NIPC_SHM_ERR_BAD_SIZE; } + /* Guard against uint32 overflow in response_offset + response_capacity: + * a 32-bit size_t would wrap and let the file-size check pass. */ + if (hdr->response_offset > UINT32_MAX - hdr->response_capacity) { + munmap(map, file_size); + close(fd); + return NIPC_SHM_ERR_BAD_SIZE; + } + /* Validate region is large enough for the declared areas. */ size_t needed = 0; size_t req_end = (size_t)hdr->request_offset + hdr->request_capacity; @@ -618,7 +770,6 @@ nipc_shm_error_t nipc_shm_client_attach(const char *run_dir, out->local_req_seq = cur_req_seq; out->local_resp_seq = cur_resp_seq; out->spin_tries = NIPC_SHM_DEFAULT_SPIN; - out->owner_generation = hdr->owner_generation; if (copy_cstr_checked(out->path, sizeof(out->path), path) != 0) { munmap(map, file_size); close(fd); @@ -867,30 +1018,6 @@ nipc_shm_error_t nipc_shm_receive(nipc_shm_ctx_t *ctx, return NIPC_SHM_OK; } -/* ------------------------------------------------------------------ */ -/* Utility */ -/* ------------------------------------------------------------------ */ - -bool nipc_shm_owner_alive(const nipc_shm_ctx_t *ctx) -{ - if (!ctx || !ctx->base) - return false; - - const nipc_shm_region_header_t *hdr = - (const nipc_shm_region_header_t *)ctx->base; - - if (!pid_alive((pid_t)hdr->owner_pid)) - return false; - - /* PID is alive; verify generation matches to detect PID reuse. - * If owner_generation is 0, skip the check (legacy region). */ - if (ctx->owner_generation != 0 && - hdr->owner_generation != ctx->owner_generation) - return false; - - return true; -} - /* ------------------------------------------------------------------ */ /* Stale cleanup on server startup */ /* ------------------------------------------------------------------ */ @@ -942,3 +1069,5 @@ void nipc_shm_cleanup_stale(const char *run_dir, const char *service_name) closedir(dir); } + +#endif /* __linux__ || __FreeBSD__ */ diff --git a/src/libnetdata/netipc/src/transport/posix/netipc_uds.c b/src/libnetdata/netipc/src/transport/posix/netipc_uds.c index e6633302..6320bc13 100644 --- a/src/libnetdata/netipc/src/transport/posix/netipc_uds.c +++ b/src/libnetdata/netipc/src/transport/posix/netipc_uds.c @@ -4,9 +4,12 @@ #include "netipc_uds_internal.h" +#include #include +#include #include #include +#include bool nipc_uds_header_payload_len(size_t payload_len, size_t *msg_len_out) { @@ -21,29 +24,55 @@ bool nipc_uds_header_payload_len(size_t payload_len, size_t *msg_len_out) return true; } -uint32_t nipc_uds_detect_packet_size(int fd) +uint32_t nipc_uds_packet_size(int fd, uint32_t configured) { +#if NIPC_UDS_STREAM + /* A stream has no per-message size cap; the fixed cap keeps the first + * packet's buffer bounded and the chunking code shared. */ + (void)fd; + uint32_t limit = NIPC_UDS_PACKET_SIZE_MAX; +#else + /* Linux rejects a datagram larger than SO_SNDBUF minus 32 bytes of + * accounting overhead with EMSGSIZE; very large buffers also hit + * ENOBUFS near 1 MiB, hence the fixed cap. */ + uint32_t limit = NIPC_UDS_PACKET_SIZE_FALLBACK; int val = 0; socklen_t len = sizeof(val); - if (getsockopt(fd, SOL_SOCKET, SO_SNDBUF, &val, &len) < 0) - return 65536; + if (getsockopt(fd, SOL_SOCKET, SO_SNDBUF, &val, &len) == 0 && + val > NIPC_UDS_SNDBUF_OVERHEAD) + limit = (uint32_t)val - NIPC_UDS_SNDBUF_OVERHEAD; +#endif - if (val <= 0) - return 65536; - return (uint32_t)val; + if (limit > NIPC_UDS_PACKET_SIZE_MAX) + limit = NIPC_UDS_PACKET_SIZE_MAX; + if (configured != 0 && configured < limit) + return configured; + return limit; } +/* SOCK_SEQPACKET sends and receives whole messages or nothing, so a call a + * signal interrupted (EINTR) moved no data and is retried. */ nipc_uds_error_t nipc_uds_raw_send(int fd, const void *data, size_t len) { - ssize_t n = send(fd, data, len, MSG_NOSIGNAL); +#if NIPC_UDS_STREAM + return nipc_uds_stream_send_iov(fd, data, len, NULL, 0); +#else + ssize_t n; + do { + n = send(fd, data, len, NIPC_UDS_SEND_FLAGS); + } while (n < 0 && errno == EINTR); if (n < 0 || (size_t)n != len) return NIPC_UDS_ERR_SEND; return NIPC_UDS_OK; +#endif } nipc_uds_error_t nipc_uds_raw_send_iov(int fd, const void *hdr, size_t hdr_len, const void *payload, size_t payload_len) { +#if NIPC_UDS_STREAM + return nipc_uds_stream_send_iov(fd, hdr, hdr_len, payload, payload_len); +#else struct iovec iov[2]; struct msghdr msg; int iovcnt = 0; @@ -64,14 +93,277 @@ nipc_uds_error_t nipc_uds_raw_send_iov(int fd, const void *hdr, size_t hdr_len, msg.msg_iovlen = iovcnt; size_t total = hdr_len + payload_len; - ssize_t n = sendmsg(fd, &msg, MSG_NOSIGNAL); + ssize_t n; + do { + n = sendmsg(fd, &msg, NIPC_UDS_SEND_FLAGS); + } while (n < 0 && errno == EINTR); if (n < 0 || (size_t)n != total) return NIPC_UDS_ERR_SEND; return NIPC_UDS_OK; +#endif } ssize_t nipc_uds_raw_recv(int fd, void *buf, size_t buf_len) { - return recv(fd, buf, buf_len, 0); + ssize_t n; + do { + n = recv(fd, buf, buf_len, 0); + } while (n < 0 && errno == EINTR); + return n; +} + +/* ------------------------------------------------------------------ */ +/* Stream-socket packet I/O */ +/* ------------------------------------------------------------------ */ + +nipc_uds_error_t nipc_uds_stream_read_exact(int fd, void *dst, size_t len, + const nipc_uds_wait_t *wait, + bool mid_packet, size_t *got_out) +{ + uint8_t *p = dst; + size_t got = 0; + nipc_uds_error_t err = NIPC_UDS_OK; + while (got < len) { + if (wait) { + err = nipc_uds_wait_for_readable(fd, wait); + if (err != NIPC_UDS_OK) + break; + } + ssize_t n = nipc_uds_raw_recv(fd, p + got, len - got); + if (n == 0) { + err = (mid_packet || got > 0) ? NIPC_UDS_ERR_PROTOCOL + : NIPC_UDS_ERR_RECV; + break; + } + if (n < 0) { + err = NIPC_UDS_ERR_RECV; + break; + } + got += (size_t)n; + } + if (got_out) + *got_out = got; + return err; +} + +size_t nipc_uds_stream_body_len(const void *hdr, size_t buf_len, + uint32_t packet_size) +{ + /* The header fields are read raw; the receive path validates them. */ + size_t declared = 0; + uint32_t magic; + memcpy(&magic, hdr, sizeof(magic)); + if (magic == NIPC_MAGIC_MSG) { + nipc_header_t h; + memcpy(&h, hdr, NIPC_HEADER_LEN); + size_t cap = packet_size > NIPC_HEADER_LEN + ? (size_t)packet_size - NIPC_HEADER_LEN + : buf_len - NIPC_HEADER_LEN; + declared = h.payload_len < cap ? h.payload_len : cap; + } else if (magic == NIPC_MAGIC_CHUNK) { + nipc_chunk_header_t chk; + memcpy(&chk, hdr, NIPC_HEADER_LEN); + declared = chk.chunk_payload_len; + } + + size_t room = buf_len - NIPC_HEADER_LEN; + return declared < room ? declared : room; +} + +nipc_uds_error_t nipc_uds_stream_recv_packet(int fd, void *buf, size_t buf_len, + uint32_t packet_size, + const nipc_uds_wait_t *wait, + size_t *len_out) +{ + *len_out = 0; + if (buf_len < NIPC_HEADER_LEN) + return NIPC_UDS_ERR_BAD_PARAM; + + uint8_t *p = buf; + nipc_uds_error_t err = nipc_uds_stream_read_exact(fd, p, NIPC_HEADER_LEN, + wait, false, NULL); + if (err != NIPC_UDS_OK) + return err; + + size_t body = nipc_uds_stream_body_len(p, buf_len, packet_size); + err = nipc_uds_stream_read_exact(fd, p + NIPC_HEADER_LEN, body, wait, true, + NULL); + if (err != NIPC_UDS_OK) + return err; + + *len_out = NIPC_HEADER_LEN + body; + return NIPC_UDS_OK; +} + +nipc_uds_error_t nipc_uds_stream_send_iov(int fd, const void *hdr, + size_t hdr_len, const void *payload, + size_t payload_len) +{ + struct iovec iov[2]; + int iovcnt = 1; + iov[0].iov_base = (void *)hdr; + iov[0].iov_len = hdr_len; + if (payload && payload_len > 0) { + iov[1].iov_base = (void *)payload; + iov[1].iov_len = payload_len; + iovcnt = 2; + } + + struct iovec *cur = iov; + while (iovcnt > 0) { + struct msghdr msg; + memset(&msg, 0, sizeof(msg)); + msg.msg_iov = cur; + msg.msg_iovlen = iovcnt; + + /* A stream write may move part of the data; EINTR means none moved. */ + ssize_t n = sendmsg(fd, &msg, NIPC_UDS_SEND_FLAGS); + if (n < 0) { + if (errno == EINTR) + continue; + return NIPC_UDS_ERR_SEND; + } + if (n == 0) + return NIPC_UDS_ERR_SEND; + + size_t left = (size_t)n; + while (iovcnt > 0 && left >= cur->iov_len) { + left -= cur->iov_len; + cur++; + iovcnt--; + } + if (iovcnt > 0) { + cur->iov_base = (uint8_t *)cur->iov_base + left; + cur->iov_len -= left; + } + } + return NIPC_UDS_OK; +} + +/* ------------------------------------------------------------------ */ +/* Session pending queue */ +/* ------------------------------------------------------------------ */ + +/* Makes room for need more bytes after pending_tail. */ +static nipc_uds_error_t pending_reserve(nipc_uds_session_t *s, size_t need) +{ + if (s->pending_head > 0) { + size_t queued = s->pending_tail - s->pending_head; + memmove(s->pending_buf, s->pending_buf + s->pending_head, queued); + s->pending_head = 0; + s->pending_tail = queued; + } + if (s->pending_cap - s->pending_tail >= need) + return NIPC_UDS_OK; + if (need > SIZE_MAX - s->pending_tail) + return NIPC_UDS_ERR_ALLOC; + + size_t cap = s->pending_cap ? s->pending_cap : 4096; + while (cap < s->pending_tail + need) + cap = cap > SIZE_MAX / 2 ? s->pending_tail + need : cap * 2; + + uint8_t *p = realloc(s->pending_buf, cap); + if (!p) + return NIPC_UDS_ERR_ALLOC; + s->pending_buf = p; + s->pending_cap = cap; + return NIPC_UDS_OK; +} + +nipc_uds_error_t nipc_uds_pending_drain(nipc_uds_session_t *s, size_t bound, + bool *eof) +{ + for (;;) { + size_t read_len = s->packet_size; +#if !NIPC_UDS_STREAM + /* Each datagram is stored whole, so its later receive sees what a + * direct receive sees: one longer than packet_size, a protocol + * violation, is rejected there instead of being stored cut short. + * MSG_TRUNC reports the full length on Linux 3.4 and later; older + * kernels report at most the probe length. */ + uint8_t probe; + ssize_t full = recv(s->fd, &probe, sizeof(probe), + MSG_DONTWAIT | MSG_PEEK | MSG_TRUNC); + if (full < 0 && errno == EINTR) + continue; + if (full < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) + return NIPC_UDS_OK; + if (full <= 0) { + /* EOF or a socket error: the retried send reports it. */ + *eof = true; + return NIPC_UDS_OK; + } + if ((size_t)full > bound) + return NIPC_UDS_ERR_PROTOCOL; + if ((size_t)full > read_len) + read_len = (size_t)full; +#endif + nipc_uds_error_t err = pending_reserve( + s, NIPC_UDS_PENDING_RECORD_LEN + read_len); + if (err != NIPC_UDS_OK) + return err; + + uint8_t *dst = s->pending_buf + s->pending_tail + + NIPC_UDS_PENDING_RECORD_LEN; + ssize_t n = recv(s->fd, dst, read_len, MSG_DONTWAIT); + if (n < 0 && errno == EINTR) + continue; + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) + return NIPC_UDS_OK; + if (n <= 0) { + /* EOF or a socket error: the retried send reports it. */ + *eof = true; + return NIPC_UDS_OK; + } + +#if !NIPC_UDS_STREAM + uint32_t rec = (uint32_t)n; + memcpy(s->pending_buf + s->pending_tail, &rec, sizeof(rec)); +#endif + s->pending_tail += NIPC_UDS_PENDING_RECORD_LEN + (size_t)n; + if (s->pending_tail - s->pending_head > bound) + return NIPC_UDS_ERR_PROTOCOL; + } +} + +static void pending_consumed(nipc_uds_session_t *s) +{ + if (s->pending_head == s->pending_tail) + s->pending_head = s->pending_tail = 0; +} + +size_t nipc_uds_pending_pop_packet(nipc_uds_session_t *s, void *buf, + size_t buf_len) +{ + uint32_t rec; + memcpy(&rec, s->pending_buf + s->pending_head, sizeof(rec)); + s->pending_head += sizeof(rec); + + size_t n = rec < buf_len ? rec : buf_len; + memcpy(buf, s->pending_buf + s->pending_head, n); + s->pending_head += rec; + pending_consumed(s); + return n; +} + +size_t nipc_uds_pending_take(nipc_uds_session_t *s, void *dst, size_t len) +{ + size_t queued = s->pending_tail - s->pending_head; + size_t n = len < queued ? len : queued; + if (n > 0) { + memcpy(dst, s->pending_buf + s->pending_head, n); + s->pending_head += n; + pending_consumed(s); + } + return n; +} + +void nipc_uds_pending_free(nipc_uds_session_t *s) +{ + free(s->pending_buf); + s->pending_buf = NULL; + s->pending_cap = 0; + s->pending_head = 0; + s->pending_tail = 0; } diff --git a/src/libnetdata/netipc/src/transport/posix/netipc_uds_handshake.c b/src/libnetdata/netipc/src/transport/posix/netipc_uds_handshake.c index f92f8bf8..841580e0 100644 --- a/src/libnetdata/netipc/src/transport/posix/netipc_uds_handshake.c +++ b/src/libnetdata/netipc/src/transport/posix/netipc_uds_handshake.c @@ -124,17 +124,17 @@ static void fill_server_session(nipc_uds_session_t *session, int fd, nipc_uds_error_t nipc_uds_client_handshake(int fd, const nipc_uds_client_config_t *cfg, + uint32_t timeout_ms, int abort_fd, nipc_uds_session_t *session) { uint8_t buf[128]; - uint32_t pkt_size = cfg->packet_size; - if (pkt_size == 0) - pkt_size = nipc_uds_detect_packet_size(fd); + uint32_t pkt_size = nipc_uds_packet_size(fd, cfg->packet_size); nipc_hello_t hello = { .layout_version = 1, - .supported_profiles = cfg->supported_profiles ? cfg->supported_profiles - : NIPC_PROFILE_BASELINE, - .preferred_profiles = cfg->preferred_profiles, + .supported_profiles = (cfg->supported_profiles ? cfg->supported_profiles + : NIPC_PROFILE_BASELINE) & + NIPC_UDS_PLATFORM_PROFILES, + .preferred_profiles = cfg->preferred_profiles & NIPC_UDS_PLATFORM_PROFILES, .max_request_payload_bytes = apply_default(cfg->max_request_payload_bytes, NIPC_MAX_PAYLOAD_DEFAULT), .max_request_batch_items = @@ -161,9 +161,23 @@ nipc_uds_error_t nipc_uds_client_handshake(int fd, if (err != NIPC_UDS_OK) return err; + /* A server that accepted but never answers must not hold the client. */ + nipc_uds_wait_t wait = nipc_uds_wait_init(timeout_ms, abort_fd); +#if NIPC_UDS_STREAM + size_t len = 0; + err = nipc_uds_stream_recv_packet(fd, buf, sizeof(buf), 0, &wait, &len); + if (err != NIPC_UDS_OK) + return err; + ssize_t n = (ssize_t)len; +#else + err = nipc_uds_wait_for_readable(fd, &wait); + if (err != NIPC_UDS_OK) + return err; + ssize_t n = nipc_uds_raw_recv(fd, buf, sizeof(buf)); if (n <= 0) return NIPC_UDS_ERR_RECV; +#endif nipc_header_t ack_hdr; nipc_error_t perr = nipc_header_decode(buf, (size_t)n, &ack_hdr); @@ -188,9 +202,23 @@ nipc_uds_error_t nipc_uds_client_handshake(int fd, return NIPC_UDS_ERR_INCOMPATIBLE; if (perr != NIPC_OK) return NIPC_UDS_ERR_PROTOCOL; + /* HELLO_ACK is exactly one 48-byte payload: extra bytes in the packet, or + * a declared length a stream peer may still be sending, would otherwise + * be read as the session's first message. */ + if (ack_hdr.payload_len != sizeof(nipc_hello_ack_t) || + (size_t)n != NIPC_HEADER_LEN + sizeof(nipc_hello_ack_t)) + return NIPC_UDS_ERR_PROTOCOL; + + /* The server must pick exactly one of the profiles this client offered. */ + if (ack.selected_profile == 0 || + (ack.selected_profile & (ack.selected_profile - 1)) != 0 || + (ack.selected_profile & hello.supported_profiles) == 0) + return NIPC_UDS_ERR_PROTOCOL; fill_client_session(session, fd, &ack); - if (session->packet_size <= NIPC_HEADER_LEN) + /* A server may only lower the client's proposal; a larger size could + * exceed what this side's kernel accepts. */ + if (session->packet_size <= NIPC_HEADER_LEN || session->packet_size > pkt_size) return NIPC_UDS_ERR_PROTOCOL; return NIPC_UDS_OK; @@ -199,23 +227,36 @@ nipc_uds_error_t nipc_uds_client_handshake(int fd, nipc_uds_error_t nipc_uds_server_handshake(int fd, const nipc_uds_server_config_t *cfg, uint64_t session_id, + nipc_uds_profile_prepare_fn prepare, + void *prepare_user, + const nipc_uds_wait_t *wait, nipc_uds_session_t *session) { uint8_t buf[128]; - uint32_t server_pkt_size = cfg->packet_size; - if (server_pkt_size == 0) - server_pkt_size = nipc_uds_detect_packet_size(fd); + uint32_t server_pkt_size = nipc_uds_packet_size(fd, cfg->packet_size); uint32_t s_resp_pay = apply_default(cfg->max_response_payload_bytes, NIPC_MAX_PAYLOAD_DEFAULT); uint32_t s_req_pay = apply_default(cfg->max_request_payload_bytes, NIPC_MAX_PAYLOAD_DEFAULT); uint32_t s_profiles = - cfg->supported_profiles ? cfg->supported_profiles : NIPC_PROFILE_BASELINE; - uint32_t s_preferred = cfg->preferred_profiles; - + (cfg->supported_profiles ? cfg->supported_profiles : NIPC_PROFILE_BASELINE) & + NIPC_UDS_PLATFORM_PROFILES; + uint32_t s_preferred = cfg->preferred_profiles & NIPC_UDS_PLATFORM_PROFILES; + +#if NIPC_UDS_STREAM + size_t len = 0; + nipc_uds_error_t rerr = + nipc_uds_stream_recv_packet(fd, buf, sizeof(buf), 0, wait, &len); + if (rerr != NIPC_UDS_OK) + return rerr; + ssize_t n = (ssize_t)len; +#else + /* The caller waited for HELLO; a datagram arrives whole. */ + (void)wait; ssize_t n = nipc_uds_raw_recv(fd, buf, sizeof(buf)); if (n <= 0) return NIPC_UDS_ERR_RECV; +#endif nipc_header_t hdr; nipc_error_t perr = nipc_header_decode(buf, (size_t)n, &hdr); @@ -241,6 +282,10 @@ nipc_uds_error_t nipc_uds_server_handshake(int fd, } if (perr != NIPC_OK) return NIPC_UDS_ERR_PROTOCOL; + /* HELLO is exactly one 44-byte payload, as HELLO_ACK is 48. */ + if (hdr.payload_len != NIPC_HELLO_WIRE_SIZE || + (size_t)n != NIPC_HEADER_LEN + NIPC_HELLO_WIRE_SIZE) + return NIPC_UDS_ERR_PROTOCOL; uint32_t intersection = hello.supported_profiles & s_profiles; if (intersection == 0) { @@ -275,6 +320,21 @@ nipc_uds_error_t nipc_uds_server_handshake(int fd, return NIPC_UDS_ERR_INCOMPATIBLE; } + /* Set up the selected profile before the ACK promises it. A profile the + * caller cannot prepare is dropped, as if the server never offered it. */ + while (prepare && + !prepare(prepare_user, selected, agreed_req_pay, agreed_resp_pay)) { + s_profiles &= ~selected; + intersection &= ~selected; + if (intersection == 0) { + send_rejection_ack(fd, NIPC_STATUS_UNSUPPORTED); + return NIPC_UDS_ERR_NO_PROFILE; + } + preferred_intersection = intersection & hello.preferred_profiles & s_preferred; + selected = preferred_intersection != 0 ? highest_bit(preferred_intersection) + : highest_bit(intersection); + } + nipc_hello_ack_t ack = { .layout_version = 1, .server_supported_profiles = s_profiles, diff --git a/src/libnetdata/netipc/src/transport/posix/netipc_uds_internal.h b/src/libnetdata/netipc/src/transport/posix/netipc_uds_internal.h index 13523444..306dfb7f 100644 --- a/src/libnetdata/netipc/src/transport/posix/netipc_uds_internal.h +++ b/src/libnetdata/netipc/src/transport/posix/netipc_uds_internal.h @@ -7,19 +7,133 @@ #include #include #include +#include #include #define UDS_DEFAULT_BACKLOG 16 #define UDS_DEFAULT_BATCH_ITEMS 1 +#define NIPC_UDS_PACKET_SIZE_FALLBACK 65536u +#define NIPC_UDS_PACKET_SIZE_MAX 262144u +/* Bytes Linux keeps from SO_SNDBUF for a SEQPACKET datagram: it accepts + * SO_SNDBUF - 32. */ +#define NIPC_UDS_SNDBUF_OVERHEAD 32 + +/* macOS has no AF_UNIX SOCK_SEQPACKET, and FreeBSD 15 SEQPACKET keeps record + * boundaries only where the sender sets MSG_EOR and can write part of a + * record. Both use a stream socket carrying the same packets, each delimited + * by the length its header declares (docs/level1-posix-uds.md, "Stream + * baseline"). */ +#if defined(__APPLE__) || defined(__FreeBSD__) +#define NIPC_UDS_STREAM 1 +#define NIPC_UDS_SOCK_TYPE SOCK_STREAM +#else +#define NIPC_UDS_STREAM 0 +#define NIPC_UDS_SOCK_TYPE SOCK_SEQPACKET +#endif + +#ifdef MSG_NOSIGNAL +#define NIPC_UDS_SEND_FLAGS MSG_NOSIGNAL +#else +#define NIPC_UDS_SEND_FLAGS 0 +#endif + +/* Profiles this platform can provide; unassigned bits are never offered or + * accepted. POSIX SHM needs a futex-style wait (Linux futex, FreeBSD + * _umtx_op), so other POSIX systems offer and accept the baseline profile + * only. */ +#if defined(__linux__) || defined(__FreeBSD__) +#define NIPC_UDS_PLATFORM_PROFILES \ + (NIPC_PROFILE_BASELINE | NIPC_PROFILE_SHM_HYBRID | NIPC_PROFILE_SHM_FUTEX) +#else +#define NIPC_UDS_PLATFORM_PROFILES NIPC_PROFILE_BASELINE +#endif + bool nipc_uds_header_payload_len(size_t payload_len, size_t *msg_len_out); -uint32_t nipc_uds_detect_packet_size(int fd); + +/* Packet size this side may propose: the configured size (0 = no + * preference) capped by what the kernel accepts as one datagram. */ +uint32_t nipc_uds_packet_size(int fd, uint32_t configured); nipc_uds_error_t nipc_uds_raw_send(int fd, const void *data, size_t len); nipc_uds_error_t nipc_uds_raw_send_iov(int fd, const void *hdr, size_t hdr_len, const void *payload, size_t payload_len); ssize_t nipc_uds_raw_recv(int fd, void *buf, size_t buf_len); +/* Wait until fd is readable or hung up. timeout_ms 0 waits without a + * deadline; abort_fd -1 disables abort. */ +nipc_uds_error_t nipc_uds_wait_readable(int fd, uint32_t timeout_ms, int abort_fd); + +/* One deadline shared by every wait of one receive. */ +typedef struct { + bool infinite; + uint64_t deadline_ms; + int abort_fd; +} nipc_uds_wait_t; + +nipc_uds_wait_t nipc_uds_wait_init(uint32_t timeout_ms, int abort_fd); +/* Poll timeout left: -1 without a deadline, 0 once it passed. */ +int nipc_uds_wait_timeout(const nipc_uds_wait_t *wait); +nipc_uds_error_t nipc_uds_wait_for_readable(int fd, const nipc_uds_wait_t *wait); + +/* Stream-socket packet I/O (used by sessions on macOS and FreeBSD; built + * on every POSIX system so its tests run everywhere). A packet is the + * 32-byte header plus the bytes it declares, so the bytes on the wire match + * SEQPACKET. + * + * nipc_uds_stream_read_exact() reads len bytes, waiting before each read + * when wait is set (NULL blocks). EOF before the first byte is RECV (the + * peer disconnected between packets); EOF inside a packet (any byte read, + * or mid_packet) is PROTOCOL (the peer sent a truncated packet). When + * got_out is set, it receives the bytes read, also on failure. */ +nipc_uds_error_t nipc_uds_stream_read_exact(int fd, void *dst, size_t len, + const nipc_uds_wait_t *wait, + bool mid_packet, size_t *got_out); +/* Bytes that follow a received 32-byte header: min(payload_len, + * packet_size - 32) after an outer header, chunk_payload_len after a + * continuation header, none after any other magic. packet_size 0 + * (handshake) caps an outer header at buf_len. Bytes beyond buf_len are + * not counted, as a SEQPACKET recv into a short buffer drops them; the + * caller's length checks then fail. */ +size_t nipc_uds_stream_body_len(const void *hdr, size_t buf_len, + uint32_t packet_size); +/* Reads one packet (header, then its body); buf_len must be >= 32. */ +nipc_uds_error_t nipc_uds_stream_recv_packet(int fd, void *buf, size_t buf_len, + uint32_t packet_size, + const nipc_uds_wait_t *wait, + size_t *len_out); +/* Sends header + payload completely, resuming after partial writes. */ +nipc_uds_error_t nipc_uds_stream_send_iov(int fd, const void *hdr, + size_t hdr_len, const void *payload, + size_t payload_len); + +/* Session pending queue: inbound bytes a pipelined client send read while + * it waited for buffer space. SEQPACKET stores each datagram as a 4-byte + * length record followed by its bytes; the stream stores raw bytes. */ +#if NIPC_UDS_STREAM +#define NIPC_UDS_PENDING_RECORD_LEN 0u +#else +#define NIPC_UDS_PENDING_RECORD_LEN 4u +#endif + +static inline bool nipc_uds_pending_queued(const nipc_uds_session_t *s) +{ + return nipc_uds_session_has_pending(s); +} + +/* Reads without blocking until the socket has nothing more to read, EOF, + * or an error (both set *eof). Fails with PROTOCOL when the queued bytes + * would exceed bound, or ALLOC. */ +nipc_uds_error_t nipc_uds_pending_drain(nipc_uds_session_t *s, size_t bound, + bool *eof); +/* SEQPACKET: pops one queued datagram into buf, truncating it to buf_len + * like recv() does; returns the bytes copied. */ +size_t nipc_uds_pending_pop_packet(nipc_uds_session_t *s, void *buf, + size_t buf_len); +/* Stream: copies up to len queued bytes into dst; returns the bytes copied. */ +size_t nipc_uds_pending_take(nipc_uds_session_t *s, void *dst, size_t len); +void nipc_uds_pending_free(nipc_uds_session_t *s); + int nipc_uds_build_socket_name(char *dst, size_t dst_len, const char *service_name); int nipc_uds_build_socket_path(char *dst, size_t dst_len, @@ -29,12 +143,18 @@ int nipc_uds_check_and_recover_stale(const char *run_dir, const char *socket_name, const char *path); +/* Waits at most timeout_ms for HELLO_ACK (0 = no deadline); abort_fd -1 + * disables abort. */ nipc_uds_error_t nipc_uds_client_handshake(int fd, const nipc_uds_client_config_t *cfg, + uint32_t timeout_ms, int abort_fd, nipc_uds_session_t *session); nipc_uds_error_t nipc_uds_server_handshake(int fd, const nipc_uds_server_config_t *cfg, uint64_t session_id, + nipc_uds_profile_prepare_fn prepare, + void *prepare_user, + const nipc_uds_wait_t *wait, nipc_uds_session_t *session); int nipc_uds_inflight_add(nipc_uds_session_t *s, uint64_t id); diff --git a/src/libnetdata/netipc/src/transport/posix/netipc_uds_lifecycle.c b/src/libnetdata/netipc/src/transport/posix/netipc_uds_lifecycle.c index 5b5a5f30..6efe81d5 100644 --- a/src/libnetdata/netipc/src/transport/posix/netipc_uds_lifecycle.c +++ b/src/libnetdata/netipc/src/transport/posix/netipc_uds_lifecycle.c @@ -1,47 +1,200 @@ +/* Run-directory scans and stats must survive 64-bit inode numbers and + * directory offsets; 32-bit glibc builds without LFS fail them with + * EOVERFLOW. Only file-local types are affected (no off_t/stat in headers). */ +#ifndef _FILE_OFFSET_BITS +#define _FILE_OFFSET_BITS 64 +#endif + +/* accept4() is a GNU/BSD extension on glibc and musl. */ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif + #include "netipc_uds_internal.h" #include #include #include -#include #include #include #include #include +#include +#include + +#include #include #include +#include #include -static pthread_mutex_t bind_umask_lock = PTHREAD_MUTEX_INITIALIZER; +/* Longest single connect wait, so an abort ends a connect promptly. */ +#define UDS_CONNECT_SLICE_MS 100u -static int bind_owner_only_socket(int fd, const struct sockaddr_un *addr) +/* SO_NOSIGPIPE is macOS's documented way to keep a write to a closed peer + * from raising SIGPIPE in the embedding process; sends also pass + * MSG_NOSIGNAL where the SDK defines it. */ +static void uds_set_nosigpipe(int fd) { - int rc = pthread_mutex_lock(&bind_umask_lock); - if (rc != 0) { - errno = rc; - return -1; +#if defined(__APPLE__) + int on = 1; + (void)setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); +#else + (void)fd; +#endif +} + +/* Sockets must not leak into children a consumer forks and execs: an + * inherited session socket keeps the peer from ever seeing EOF, which is + * the dead-peer signal. Platforms without SOCK_CLOEXEC set the flag right + * after creation. */ +static int uds_socket_cloexec(void) +{ +#ifdef SOCK_CLOEXEC + int fd = socket(AF_UNIX, NIPC_UDS_SOCK_TYPE | SOCK_CLOEXEC, 0); +#else + int fd = socket(AF_UNIX, NIPC_UDS_SOCK_TYPE, 0); + if (fd >= 0) + (void)fcntl(fd, F_SETFD, FD_CLOEXEC); +#endif + if (fd >= 0) + uds_set_nosigpipe(fd); + return fd; +} + +/* A signal that interrupts the wait is retried: it must not end the + * server's accept loop. */ +static int accept_cloexec(int listen_fd) +{ + int fd; +#if defined(SOCK_CLOEXEC) && (defined(__linux__) || defined(__FreeBSD__)) + do { + fd = accept4(listen_fd, NULL, NULL, SOCK_CLOEXEC); + } while (fd < 0 && errno == EINTR); +#else + do { + fd = accept(listen_fd, NULL, NULL); + } while (fd < 0 && errno == EINTR); + if (fd >= 0) { + (void)fcntl(fd, F_SETFD, FD_CLOEXEC); + uds_set_nosigpipe(fd); } +#endif + return fd; +} - /* Bind path sockets as 0600 without a post-bind permission window. */ - // Flawfinder: ignore - mode_t old_mask = umask(S_IXUSR | S_IRWXG | S_IRWXO); // nosemgrep // NOSONAR - int bind_rc = bind(fd, (const struct sockaddr *)addr, sizeof(*addr)); - int saved_errno = errno; - // Flawfinder: ignore - umask(old_mask); // nosemgrep // NOSONAR +static int fill_sockaddr_path(struct sockaddr_un *addr, const char *path); - rc = pthread_mutex_unlock(&bind_umask_lock); - if (bind_rc < 0) { - errno = saved_errno; +#if defined(__APPLE__) +/* Remove a bind directory a crashed server left behind. The caller holds + * the endpoint lock, so no live server uses it. Anything but a real + * directory is unlinked without following it. */ +static int remove_bind_dir(const char *dir, const char *tmp) +{ + struct stat st; + if (lstat(dir, &st) < 0) + return errno == ENOENT ? 0 : -1; + if (!S_ISDIR(st.st_mode)) + return unlink(dir); + if (unlink(tmp) < 0 && errno != ENOENT) return -1; - } - if (rc != 0) { - errno = rc; + return rmdir(dir); +} + +/* macOS rejects fchmod() on an unbound socket and creates the socket file + * 0777 & ~umask. The socket is bound inside a private 0700 directory, + * narrowed to owner-only there, then renamed into place, so it is never + * reachable with a wider mode and the process-global umask is untouched. + * fchmodat() and open() resolve the directory by path without following a + * symlink, and the owner check on the opened descriptor refuses a directory + * another account swapped in. After that check every step but bind() goes + * through the descriptor, so a later swap makes the listen fail instead of + * placing a socket someone else can reach. + * Returns 0, -1 on a socket error, -2 if the bind path is too long. */ +static int bind_owner_only_socket(int fd, const char *run_dir, + const char *service_name, const char *path) +{ + char dir[sizeof(((struct sockaddr_un *)0)->sun_path)]; + char tmp[sizeof(((struct sockaddr_un *)0)->sun_path)]; + int n = snprintf(dir, sizeof(dir), "%s/%s.d", run_dir, service_name); + if (n < 0 || (size_t)n >= sizeof(dir)) + return -2; + n = snprintf(tmp, sizeof(tmp), "%s/s", dir); + if (n < 0 || (size_t)n >= sizeof(tmp)) + return -2; + + struct sockaddr_un addr; + if (fill_sockaddr_path(&addr, tmp) != 0) + return -2; + + if (remove_bind_dir(dir, tmp) < 0) + return -1; + if (mkdir(dir, S_IRWXU) < 0) return -1; + + int saved_errno; + struct stat st; + /* fchmodat() ignores the umask, so a umask without owner bits cannot + * make the directory unopenable or unusable. */ + if (fchmodat(AT_FDCWD, dir, S_IRWXU, AT_SYMLINK_NOFOLLOW) < 0) + goto fail_dir; + int dfd = open(dir, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (dfd < 0) + goto fail_dir; + if (fstat(dfd, &st) < 0) + goto fail_dfd; + if (st.st_uid != geteuid()) { + errno = EPERM; + goto fail_dfd; } + /* The descriptor is the directory every later step uses; keep it 0700 + * even if another one of ours replaced the path. */ + if (fchmod(dfd, S_IRWXU) < 0 || + bind(fd, (const struct sockaddr *)&addr, sizeof(addr)) < 0) + goto fail_dfd; + /* The bound file carries 0777 & ~umask; keep the umask narrowing. */ + if (fstatat(dfd, "s", &st, AT_SYMLINK_NOFOLLOW) < 0 || + fchmodat(dfd, "s", (st.st_mode & 0777) & (S_IRUSR | S_IWUSR), 0) < 0 || + renameat(dfd, "s", AT_FDCWD, path) < 0) + goto fail_file; + close(dfd); + (void)rmdir(dir); return 0; + +fail_file: + saved_errno = errno; + (void)unlinkat(dfd, "s", 0); + errno = saved_errno; +fail_dfd: + saved_errno = errno; + close(dfd); + errno = saved_errno; +fail_dir: + saved_errno = errno; + (void)rmdir(dir); + errno = saved_errno; + return -1; +} +#else +/* Bind as owner-only (0600, narrowed by the umask). Linux and FreeBSD give + * the socket file the mode of the unbound socket, so fchmod() before bind() + * sets it without a post-bind window and without touching the + * process-global umask, which other threads of the embedding process use. + * Returns 0 or -1. */ +static int bind_owner_only_socket(int fd, const char *run_dir, + const char *service_name, const char *path) +{ + (void)run_dir; + (void)service_name; + struct sockaddr_un addr; + if (fill_sockaddr_path(&addr, path) != 0) + return -2; + if (fchmod(fd, S_IRUSR | S_IWUSR) < 0) + return -1; + return bind(fd, (const struct sockaddr *)&addr, sizeof(addr)); } +#endif static int copy_cstr_checked(char *dst, size_t dst_size, const char *src) { @@ -100,6 +253,10 @@ int nipc_uds_build_socket_path(char *dst, size_t dst_len, const char *run_dir, const char *service_name) { + /* An empty run_dir would put the socket at "/". */ + if (!run_dir || run_dir[0] == '\0') + return -2; + char socket_name[sizeof(((struct sockaddr_un *)0)->sun_path)]; int name_rc = nipc_uds_build_socket_name(socket_name, sizeof(socket_name), service_name); @@ -127,11 +284,13 @@ static int unlink_stale_socket_path(const char *run_dir, /* Whatever sits at the endpoint name is not a live server (the connect * probe already failed): a dead server's socket or a foreign file. - * Reclaim the name; directories are junk too. */ + * Reclaim the name; directories are junk too. Unlinking a directory + * fails with EISDIR on Linux and EPERM on FreeBSD and macOS. */ int ret = -1; if (unlinkat(dir_fd, socket_name, 0) == 0 || errno == ENOENT) ret = 0; - else if (errno == EISDIR && unlinkat(dir_fd, socket_name, AT_REMOVEDIR) == 0) + else if ((errno == EISDIR || errno == EPERM) && + unlinkat(dir_fd, socket_name, AT_REMOVEDIR) == 0) ret = 0; closedir(dir); @@ -142,7 +301,7 @@ int nipc_uds_check_and_recover_stale(const char *run_dir, const char *socket_name, const char *path) { - int probe = socket(AF_UNIX, SOCK_SEQPACKET, 0); + int probe = uds_socket_cloexec(); if (probe < 0) { /* Cannot probe liveness (fd exhaustion) — keep the endpoint and * report it in use rather than risk deleting a live socket. */ @@ -171,13 +330,122 @@ int nipc_uds_check_and_recover_stale(const char *run_dir, return ret; } +static void (*g_listen_hook)(void *user); +static void *g_listen_hook_user; + +void nipc_uds_set_listen_hook_for_tests(void (*hook)(void *user), void *user) +{ + g_listen_hook_user = user; + g_listen_hook = hook; +} + +static void (*g_lock_publish_hook)(const char *tmp_path, void *user); +static void *g_lock_publish_hook_user; + +void nipc_uds_set_lock_publish_hook_for_tests( + void (*hook)(const char *tmp_path, void *user), void *user) +{ + g_lock_publish_hook_user = user; + g_lock_publish_hook = hook; +} + +/* A file created under a umask without owner bits stays unopenable if the + * process dies before fchmod(), so a missing lock gets its name only once it + * is 0600. Best effort: the caller's creating open covers every failure, + * such as a filesystem without hard links, and a losing link() means another + * starter published the file first. */ +static void publish_endpoint_lock(const char *lock_path) +{ + static unsigned seq; + char tmp[sizeof(((struct sockaddr_un *)0)->sun_path) + 64]; + int n = snprintf(tmp, sizeof(tmp), "%s.%ld.%u.tmp", lock_path, + (long)getpid(), + __atomic_fetch_add(&seq, 1u, __ATOMIC_RELAXED)); + if (n < 0 || (size_t)n >= sizeof(tmp)) + return; + + int fd = open(tmp, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + 0600); + if (fd < 0) + return; + int restricted = fchmod(fd, S_IRUSR | S_IWUSR) == 0; + close(fd); + if (restricted) { + if (g_lock_publish_hook) + g_lock_publish_hook(tmp, g_lock_publish_hook_user); + (void)link(tmp, lock_path); + } + unlink(tmp); +} + +/* Take the endpoint lock {run_dir}/{service_name}.lock. A server's socket + * file exists from bind() until listen(), and connects in that window are + * refused just as for a dead server's leftover file, so a connect probe + * alone cannot tell a starting server from a stale one. The lock can: its + * holder owns the endpoint, and the kernel drops it when that process + * dies. The file stays on disk; removing it would let a starter that + * already opened it lock an orphaned inode. + * Returns 0 with *fd_out locked, 1 if another listener holds the lock, + * -1 on error. */ +static int acquire_endpoint_lock(const char *run_dir, const char *service_name, + int *fd_out) +{ + char lock_path[sizeof(((struct sockaddr_un *)0)->sun_path) + 8]; + int n = snprintf(lock_path, sizeof(lock_path), "%s/%s.lock", run_dir, + service_name); + if (n < 0 || (size_t)n >= sizeof(lock_path)) + return -1; + + /* A file left with mode bits missing, by an older build or a fallback + * create under a umask without owner read or write, may not reopen + * read-write, so a narrower open follows (flock() works on any access + * mode) and fchmod() restores 0600 for the next starter. fchmod() fails + * only for a file another account owns, which the lock still works on. */ + int fd = open(lock_path, O_RDWR | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0 && errno == ENOENT) { + publish_endpoint_lock(lock_path); + fd = open(lock_path, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0600); + } + if (fd < 0 && errno == EACCES) + fd = open(lock_path, O_WRONLY | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0 && errno == EACCES) + fd = open(lock_path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) + return -1; + (void)fchmod(fd, S_IRUSR | S_IWUSR); + + int rc; + do { + rc = flock(fd, LOCK_EX | LOCK_NB); + } while (rc < 0 && errno == EINTR); + if (rc < 0) { + int saved_errno = errno; + close(fd); + return (saved_errno == EWOULDBLOCK) ? 1 : -1; + } + *fd_out = fd; + return 0; +} + +/* Unlock before closing: a child forked by another thread shares the open + * file description until it execs, and its copy would keep the lock held. */ +static void release_endpoint_lock(int fd) +{ + flock(fd, LOCK_UN); + close(fd); +} + nipc_uds_error_t nipc_uds_listen(const char *run_dir, const char *service_name, const nipc_uds_server_config_t *config, nipc_uds_listener_t *out) { + if (!out) + return NIPC_UDS_ERR_BAD_PARAM; memset(out, 0, sizeof(*out)); out->fd = -1; + if (!config) + return NIPC_UDS_ERR_BAD_PARAM; char path[sizeof(((struct sockaddr_un *)0)->sun_path)]; int path_rc = nipc_uds_build_socket_path(path, sizeof(path), run_dir, @@ -195,29 +463,42 @@ nipc_uds_error_t nipc_uds_listen(const char *run_dir, if (name_rc < 0) return NIPC_UDS_ERR_PATH_TOO_LONG; + int lock_fd = -1; + int lock_rc = acquire_endpoint_lock(run_dir, service_name, &lock_fd); + if (lock_rc == 1) + return NIPC_UDS_ERR_ADDR_IN_USE; + if (lock_rc < 0) + return NIPC_UDS_ERR_SOCKET; + + /* The probe still guards against a live server that takes no lock (an + * older library version). */ int stale = nipc_uds_check_and_recover_stale(run_dir, socket_name, path); - if (stale == 1) + if (stale == 1) { + release_endpoint_lock(lock_fd); return NIPC_UDS_ERR_ADDR_IN_USE; + } - int fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); - if (fd < 0) + int fd = uds_socket_cloexec(); + if (fd < 0) { + release_endpoint_lock(lock_fd); return NIPC_UDS_ERR_SOCKET; - - struct sockaddr_un addr; - if (fill_sockaddr_path(&addr, path) != 0) { - close(fd); - return NIPC_UDS_ERR_PATH_TOO_LONG; } - if (bind_owner_only_socket(fd, &addr) < 0) { + int bind_rc = bind_owner_only_socket(fd, run_dir, service_name, path); + if (bind_rc < 0) { close(fd); - return NIPC_UDS_ERR_SOCKET; + release_endpoint_lock(lock_fd); + return bind_rc == -2 ? NIPC_UDS_ERR_PATH_TOO_LONG : NIPC_UDS_ERR_SOCKET; } + if (g_listen_hook) + g_listen_hook(g_listen_hook_user); + int backlog = config->backlog > 0 ? config->backlog : UDS_DEFAULT_BACKLOG; if (listen(fd, backlog) < 0) { close(fd); unlink(path); + release_endpoint_lock(lock_fd); return NIPC_UDS_ERR_SOCKET; } @@ -226,29 +507,59 @@ nipc_uds_error_t nipc_uds_listen(const char *run_dir, if (copy_cstr_checked(out->path, sizeof(out->path), path) != 0) { close(fd); unlink(path); + release_endpoint_lock(lock_fd); memset(out, 0, sizeof(*out)); out->fd = -1; return NIPC_UDS_ERR_PATH_TOO_LONG; } + out->lock_fd = lock_fd; + out->has_lock = true; return NIPC_UDS_OK; } -nipc_uds_error_t nipc_uds_accept(nipc_uds_listener_t *listener, - uint64_t session_id, - nipc_uds_session_t *out) +nipc_uds_error_t nipc_uds_accept_pending(nipc_uds_listener_t *listener, + int *fd_out) +{ + if (!listener || !fd_out) + return NIPC_UDS_ERR_BAD_PARAM; + + *fd_out = accept_cloexec(listener->fd); + return *fd_out < 0 ? NIPC_UDS_ERR_ACCEPT : NIPC_UDS_OK; +} + +nipc_uds_error_t nipc_uds_accept_handshake(int fd, + const nipc_uds_server_config_t *config, + uint64_t session_id, + uint32_t timeout_ms, + nipc_uds_session_t *out) { + return nipc_uds_accept_handshake_prepared(fd, config, session_id, + timeout_ms, NULL, NULL, out); +} + +nipc_uds_error_t nipc_uds_accept_handshake_prepared( + int fd, const nipc_uds_server_config_t *config, uint64_t session_id, + uint32_t timeout_ms, nipc_uds_profile_prepare_fn prepare, void *user, + nipc_uds_session_t *out) +{ + if (!out || !config || fd < 0) { + if (fd >= 0) + close(fd); + return NIPC_UDS_ERR_BAD_PARAM; + } + memset(out, 0, sizeof(*out)); out->fd = -1; - int client_fd = accept(listener->fd, NULL, NULL); - if (client_fd < 0) - return NIPC_UDS_ERR_ACCEPT; - - nipc_uds_error_t err = nipc_uds_server_handshake( - client_fd, &listener->config, session_id, out); + /* One deadline covers the wait for HELLO and, on a stream, its bytes. */ + nipc_uds_wait_t wait = nipc_uds_wait_init(timeout_ms, -1); + nipc_uds_error_t err = nipc_uds_wait_for_readable(fd, &wait); + if (err == NIPC_UDS_OK) + err = nipc_uds_server_handshake(fd, config, session_id, prepare, user, + &wait, out); if (err != NIPC_UDS_OK) { - close(client_fd); + close(fd); out->fd = -1; return err; } @@ -256,13 +567,112 @@ nipc_uds_error_t nipc_uds_accept(nipc_uds_listener_t *listener, return NIPC_UDS_OK; } -nipc_uds_error_t nipc_uds_connect(const char *run_dir, - const char *service_name, - const nipc_uds_client_config_t *config, - nipc_uds_session_t *out) +nipc_uds_error_t nipc_uds_accept(nipc_uds_listener_t *listener, + uint64_t session_id, + nipc_uds_session_t *out) +{ + memset(out, 0, sizeof(*out)); + out->fd = -1; + + int client_fd; + nipc_uds_error_t err = nipc_uds_accept_pending(listener, &client_fd); + if (err != NIPC_UDS_OK) + return err; + + return nipc_uds_accept_handshake(client_fd, &listener->config, session_id, + NIPC_SERVER_HANDSHAKE_TIMEOUT_MS, out); +} + +static uint64_t connect_monotonic_ms(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000ULL + (uint64_t)ts.tv_nsec / 1000000ULL; +} + +static bool connect_abort_raised(int abort_fd) +{ + if (abort_fd < 0) + return false; + struct pollfd pfd = { .fd = abort_fd, .events = POLLIN, .revents = 0 }; + return poll(&pfd, 1, 0) > 0; +} + +/* Remaining time until deadline_ms (0 = no deadline) as a timeout for + * the next wait; false once the deadline passed. */ +static bool connect_remaining_ms(uint64_t deadline_ms, uint32_t *remaining) { + *remaining = 0; + if (deadline_ms == 0) + return true; + uint64_t now = connect_monotonic_ms(); + if (now >= deadline_ms) + return false; + uint64_t left = deadline_ms - now; + *remaining = left > UINT32_MAX ? UINT32_MAX : (uint32_t)left; + return true; +} + +/* A blocking AF_UNIX connect waits while the listener's backlog is full. + * On Linux SO_SNDTIMEO bounds that wait and the connect fails with EAGAIN + * when it expires; waiting in slices lets an abort end it. Other systems + * refuse a full backlog at once. */ +static nipc_uds_error_t connect_bounded(int fd, const struct sockaddr_un *addr, + uint64_t deadline_ms, int abort_fd) +{ + nipc_uds_error_t result = NIPC_UDS_OK; + for (;;) { + if (connect_abort_raised(abort_fd)) { + result = NIPC_UDS_ERR_ABORTED; + break; + } + uint32_t slice = UDS_CONNECT_SLICE_MS; + uint32_t remaining; + if (!connect_remaining_ms(deadline_ms, &remaining)) { + result = NIPC_UDS_ERR_TIMEOUT; + break; + } + if (deadline_ms != 0 && remaining < slice) + slice = remaining; + + struct timeval tv = { + .tv_sec = (time_t)(slice / 1000u), + .tv_usec = (suseconds_t)((slice % 1000u) * 1000u), + }; + (void)setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + + if (connect(fd, (const struct sockaddr *)addr, sizeof(*addr)) == 0) + break; + if (errno == EISCONN) + break; + if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR && + errno != EALREADY) { + result = NIPC_UDS_ERR_CONNECT; + break; + } + } + + /* Session sends block without a timeout. */ + struct timeval none = { 0, 0 }; + (void)setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &none, sizeof(none)); + return result; +} + +nipc_uds_error_t nipc_uds_connect_timeout(const char *run_dir, + const char *service_name, + const nipc_uds_client_config_t *config, + uint32_t timeout_ms, + int abort_fd, + nipc_uds_session_t *out) +{ + if (!out) + return NIPC_UDS_ERR_BAD_PARAM; memset(out, 0, sizeof(*out)); out->fd = -1; + if (!config) + return NIPC_UDS_ERR_BAD_PARAM; + + uint64_t deadline_ms = timeout_ms ? connect_monotonic_ms() + timeout_ms : 0; char path[sizeof(((struct sockaddr_un *)0)->sun_path)]; int path_rc = nipc_uds_build_socket_path(path, sizeof(path), run_dir, @@ -272,7 +682,7 @@ nipc_uds_error_t nipc_uds_connect(const char *run_dir, if (path_rc < 0) return NIPC_UDS_ERR_PATH_TOO_LONG; - int fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); + int fd = uds_socket_cloexec(); if (fd < 0) return NIPC_UDS_ERR_SOCKET; @@ -282,12 +692,19 @@ nipc_uds_error_t nipc_uds_connect(const char *run_dir, return NIPC_UDS_ERR_PATH_TOO_LONG; } - if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + nipc_uds_error_t err = connect_bounded(fd, &addr, deadline_ms, abort_fd); + if (err != NIPC_UDS_OK) { + close(fd); + return err; + } + + uint32_t remaining; + if (!connect_remaining_ms(deadline_ms, &remaining)) { close(fd); - return NIPC_UDS_ERR_CONNECT; + return NIPC_UDS_ERR_TIMEOUT; } - nipc_uds_error_t err = nipc_uds_client_handshake(fd, config, out); + err = nipc_uds_client_handshake(fd, config, remaining, abort_fd, out); if (err != NIPC_UDS_OK) { close(fd); out->fd = -1; @@ -297,6 +714,14 @@ nipc_uds_error_t nipc_uds_connect(const char *run_dir, return NIPC_UDS_OK; } +nipc_uds_error_t nipc_uds_connect(const char *run_dir, + const char *service_name, + const nipc_uds_client_config_t *config, + nipc_uds_session_t *out) +{ + return nipc_uds_connect_timeout(run_dir, service_name, config, 0, -1, out); +} + void nipc_uds_close_session(nipc_uds_session_t *session) { if (!session) @@ -315,9 +740,11 @@ void nipc_uds_close_session(nipc_uds_session_t *session) session->inflight_ids = NULL; session->inflight_count = 0; session->inflight_capacity = 0; + + nipc_uds_pending_free(session); } -void nipc_uds_close_listener(nipc_uds_listener_t *listener) +void nipc_uds_stop_listening(nipc_uds_listener_t *listener) { if (!listener) return; @@ -332,3 +759,18 @@ void nipc_uds_close_listener(nipc_uds_listener_t *listener) listener->path[0] = '\0'; } } + +void nipc_uds_close_listener(nipc_uds_listener_t *listener) +{ + if (!listener) + return; + + nipc_uds_stop_listening(listener); + + /* Release the endpoint only after its socket file is gone. */ + if (listener->has_lock) { + release_endpoint_lock(listener->lock_fd); + listener->has_lock = false; + listener->lock_fd = -1; + } +} diff --git a/src/libnetdata/netipc/src/transport/posix/netipc_uds_receive.c b/src/libnetdata/netipc/src/transport/posix/netipc_uds_receive.c index 657d6cf8..fb965b91 100644 --- a/src/libnetdata/netipc/src/transport/posix/netipc_uds_receive.c +++ b/src/libnetdata/netipc/src/transport/posix/netipc_uds_receive.c @@ -5,14 +5,9 @@ #include #include #include +#include #include -typedef struct { - bool infinite; - uint64_t deadline_ms; - int abort_fd; -} receive_wait_t; - static uint64_t monotonic_ms(void) { struct timespec ts; @@ -20,9 +15,9 @@ static uint64_t monotonic_ms(void) return (uint64_t)ts.tv_sec * 1000ULL + (uint64_t)ts.tv_nsec / 1000000ULL; } -static receive_wait_t receive_wait_init(uint32_t timeout_ms, int abort_fd) +nipc_uds_wait_t nipc_uds_wait_init(uint32_t timeout_ms, int abort_fd) { - receive_wait_t wait = { + nipc_uds_wait_t wait = { .infinite = (timeout_ms == 0), .deadline_ms = 0, .abort_fd = abort_fd, @@ -32,7 +27,7 @@ static receive_wait_t receive_wait_init(uint32_t timeout_ms, int abort_fd) return wait; } -static int receive_wait_timeout(const receive_wait_t *wait) +int nipc_uds_wait_timeout(const nipc_uds_wait_t *wait) { if (wait->infinite) return -1; @@ -45,7 +40,7 @@ static int receive_wait_timeout(const receive_wait_t *wait) return remaining > (uint64_t)INT_MAX ? INT_MAX : (int)remaining; } -static nipc_uds_error_t wait_for_readable(int fd, const receive_wait_t *wait) +nipc_uds_error_t nipc_uds_wait_for_readable(int fd, const nipc_uds_wait_t *wait) { for (;;) { struct pollfd fds[2]; @@ -62,7 +57,7 @@ static nipc_uds_error_t wait_for_readable(int fd, const receive_wait_t *wait) nfds = 2; } - int timeout = receive_wait_timeout(wait); + int timeout = nipc_uds_wait_timeout(wait); if (timeout == 0) return NIPC_UDS_ERR_TIMEOUT; @@ -85,6 +80,12 @@ static nipc_uds_error_t wait_for_readable(int fd, const receive_wait_t *wait) } } +nipc_uds_error_t nipc_uds_wait_readable(int fd, uint32_t timeout_ms, int abort_fd) +{ + nipc_uds_wait_t wait = nipc_uds_wait_init(timeout_ms, abort_fd); + return nipc_uds_wait_for_readable(fd, &wait); +} + static nipc_uds_error_t ensure_recv_buf(nipc_uds_session_t *session, size_t needed) { @@ -154,22 +155,109 @@ static nipc_uds_error_t track_inbound_response(nipc_uds_session_t *session, return NIPC_UDS_OK; } +#if NIPC_UDS_STREAM +/* Reads len bytes, from the pending queue first, then the socket. When + * consumed is set, it records whether any byte was taken, also on failure. */ +static nipc_uds_error_t session_read_exact(nipc_uds_session_t *session, + void *dst, size_t len, + const nipc_uds_wait_t *wait, + bool mid_packet, bool *consumed) +{ + size_t got = nipc_uds_pending_take(session, dst, len); + size_t read = 0; + nipc_uds_error_t err = NIPC_UDS_OK; + if (got < len) + err = nipc_uds_stream_read_exact(session->fd, (uint8_t *)dst + got, + len - got, wait, mid_packet || got > 0, + &read); + if (consumed) + *consumed = got + read > 0; + return err; +} + +/* nipc_uds_stream_recv_packet() through the pending queue. */ +static nipc_uds_error_t session_recv_packet(nipc_uds_session_t *session, + uint8_t *buf, size_t buf_len, + const nipc_uds_wait_t *wait, + size_t *len_out) +{ + *len_out = 0; + if (buf_len < NIPC_HEADER_LEN) + return NIPC_UDS_ERR_BAD_PARAM; + + nipc_uds_error_t err = session_read_exact(session, buf, NIPC_HEADER_LEN, + wait, false, NULL); + if (err != NIPC_UDS_OK) + return err; + + size_t body = nipc_uds_stream_body_len(buf, buf_len, session->packet_size); + err = session_read_exact(session, buf + NIPC_HEADER_LEN, body, wait, true, + NULL); + if (err != NIPC_UDS_OK) + return err; + + *len_out = NIPC_HEADER_LEN + body; + return NIPC_UDS_OK; +} +#else +/* One datagram, from the pending queue first, then the socket. */ +static ssize_t session_recv_packet(nipc_uds_session_t *session, void *buf, + size_t buf_len, const nipc_uds_wait_t *wait, + nipc_uds_error_t *err) +{ + *err = NIPC_UDS_OK; + if (nipc_uds_pending_queued(session)) + return (ssize_t)nipc_uds_pending_pop_packet(session, buf, buf_len); + + *err = nipc_uds_wait_for_readable(session->fd, wait); + if (*err != NIPC_UDS_OK) + return -1; + return nipc_uds_raw_recv(session->fd, buf, buf_len); +} +#endif + static nipc_uds_error_t receive_first_packet(nipc_uds_session_t *session, void *buf, size_t buf_size, nipc_header_t *hdr_out, ssize_t *received_out, - const receive_wait_t *wait) + const nipc_uds_wait_t *wait, + bool *consumed) { - nipc_uds_error_t werr = wait_for_readable(session->fd, wait); +#if NIPC_UDS_STREAM + /* The header is checked before its body is read: a stream peer that + * declares too much is rejected without waiting for those bytes. */ + nipc_uds_error_t serr = session_read_exact( + session, buf, NIPC_HEADER_LEN, wait, false, consumed); + if (serr == NIPC_UDS_ERR_RECV) + nipc_uds_inflight_fail_all(session); + if (serr != NIPC_UDS_OK) + return serr; + if (nipc_header_decode(buf, NIPC_HEADER_LEN, hdr_out) != NIPC_OK) + return NIPC_UDS_ERR_PROTOCOL; + serr = validate_inbound_limits(session, hdr_out); + if (serr != NIPC_UDS_OK) + return serr; + + size_t body = nipc_uds_stream_body_len(buf, buf_size, session->packet_size); + serr = session_read_exact(session, (uint8_t *)buf + NIPC_HEADER_LEN, + body, wait, true, NULL); + if (serr == NIPC_UDS_ERR_RECV) + nipc_uds_inflight_fail_all(session); + if (serr != NIPC_UDS_OK) + return serr; + ssize_t n = (ssize_t)(NIPC_HEADER_LEN + body); +#else + (void)consumed; + nipc_uds_error_t werr; + ssize_t n = session_recv_packet(session, buf, buf_size, wait, &werr); if (werr != NIPC_UDS_OK) return werr; - - ssize_t n = nipc_uds_raw_recv(session->fd, buf, buf_size); if (n <= 0) { nipc_uds_inflight_fail_all(session); return NIPC_UDS_ERR_RECV; } +#endif if ((size_t)n < NIPC_HEADER_LEN) return NIPC_UDS_ERR_PROTOCOL; @@ -227,17 +315,31 @@ static nipc_uds_error_t receive_one_chunk(nipc_uds_session_t *session, uint32_t chunk_count, size_t total_msg, size_t *assembled, - const receive_wait_t *wait) + const nipc_uds_wait_t *wait) { - nipc_uds_error_t werr = wait_for_readable(session->fd, wait); +#if NIPC_UDS_STREAM + size_t len = 0; + nipc_uds_error_t serr = session_recv_packet( + session, pkt_buf, pkt_buf_size, wait, &len); + if (serr == NIPC_UDS_ERR_RECV) + nipc_uds_inflight_fail_all(session); + /* A truncated continuation is a chunk error, as on SEQPACKET. */ + if (serr == NIPC_UDS_ERR_PROTOCOL) + return NIPC_UDS_ERR_CHUNK; + if (serr != NIPC_UDS_OK) + return serr; + ssize_t cn = (ssize_t)len; +#else + nipc_uds_error_t werr; + ssize_t cn = session_recv_packet(session, pkt_buf, pkt_buf_size, wait, + &werr); if (werr != NIPC_UDS_OK) return werr; - - ssize_t cn = nipc_uds_raw_recv(session->fd, pkt_buf, pkt_buf_size); if (cn <= 0) { nipc_uds_inflight_fail_all(session); return NIPC_UDS_ERR_RECV; } +#endif if ((size_t)cn < NIPC_HEADER_LEN) return NIPC_UDS_ERR_CHUNK; @@ -275,7 +377,7 @@ static nipc_uds_error_t receive_chunked_payload(nipc_uds_session_t *session, size_t total_msg, const void **payload_out, size_t *payload_len_out, - const receive_wait_t *wait) + const nipc_uds_wait_t *wait) { size_t first_payload_bytes = (size_t)first_packet_len - NIPC_HEADER_LEN; if (first_payload_bytes > hdr->payload_len) @@ -314,32 +416,17 @@ static nipc_uds_error_t receive_chunked_payload(nipc_uds_session_t *session, return validate_batch(hdr, *payload_out, *payload_len_out); } -nipc_uds_error_t nipc_uds_receive(nipc_uds_session_t *session, - void *buf, size_t buf_size, - nipc_header_t *hdr_out, - const void **payload_out, - size_t *payload_len_out) -{ - return nipc_uds_receive_timeout(session, buf, buf_size, hdr_out, - payload_out, payload_len_out, 0, -1); -} - -nipc_uds_error_t nipc_uds_receive_timeout(nipc_uds_session_t *session, - void *buf, size_t buf_size, - nipc_header_t *hdr_out, - const void **payload_out, - size_t *payload_len_out, - uint32_t timeout_ms, - int abort_fd) +static nipc_uds_error_t receive_message(nipc_uds_session_t *session, + void *buf, size_t buf_size, + nipc_header_t *hdr_out, + const void **payload_out, + size_t *payload_len_out, + const nipc_uds_wait_t *wait, + bool *consumed) { - if (!session || session->fd < 0) - return NIPC_UDS_ERR_BAD_PARAM; - - receive_wait_t wait = receive_wait_init(timeout_ms, abort_fd); - ssize_t n; nipc_uds_error_t err = receive_first_packet(session, buf, buf_size, - hdr_out, &n, &wait); + hdr_out, &n, wait, consumed); if (err != NIPC_UDS_OK) return err; @@ -364,5 +451,62 @@ nipc_uds_error_t nipc_uds_receive_timeout(nipc_uds_session_t *session, } return receive_chunked_payload(session, buf, n, hdr_out, total_msg, - payload_out, payload_len_out, &wait); + payload_out, payload_len_out, wait); +} + +nipc_uds_error_t nipc_uds_receive(nipc_uds_session_t *session, + void *buf, size_t buf_size, + nipc_header_t *hdr_out, + const void **payload_out, + size_t *payload_len_out) +{ + return nipc_uds_receive_timeout(session, buf, buf_size, hdr_out, + payload_out, payload_len_out, 0, -1); +} + +nipc_uds_error_t nipc_uds_receive_timeout(nipc_uds_session_t *session, + void *buf, size_t buf_size, + nipc_header_t *hdr_out, + const void **payload_out, + size_t *payload_len_out, + uint32_t timeout_ms, + int abort_fd) +{ + /* A buffer too small for a header is rejected before any byte is + * read, so the session stays usable. */ + if (!session || session->fd < 0 || !buf || buf_size < NIPC_HEADER_LEN) + return NIPC_UDS_ERR_BAD_PARAM; + + nipc_uds_wait_t wait = nipc_uds_wait_init(timeout_ms, abort_fd); + +#if NIPC_UDS_STREAM + /* A failure before the first byte (a timeout or abort, also one that + * fires after the wait below) leaves the stream on a packet boundary. + * A failure after it does not: the rest of the message is still queued, + * so every later receive fails. The session records that instead + * of shutting the read side, whose effect differs by OS (FreeBSD 15 fails + * the peer's sends, FreeBSD 14 still returns queued bytes). Sends stay + * valid; they always write whole messages. */ + if (session->recv_failed) + return NIPC_UDS_ERR_RECV; + if (!nipc_uds_pending_queued(session)) { + nipc_uds_error_t werr = nipc_uds_wait_for_readable(session->fd, &wait); + if (werr != NIPC_UDS_OK) + return werr; + } + + bool consumed = true; + nipc_uds_error_t err = receive_message(session, buf, buf_size, hdr_out, + payload_out, payload_len_out, + &wait, &consumed); + if (err != NIPC_UDS_OK && consumed) { + session->recv_failed = true; + /* Queued bytes are part of the abandoned stream too. */ + nipc_uds_pending_free(session); + } + return err; +#else + return receive_message(session, buf, buf_size, hdr_out, payload_out, + payload_len_out, &wait, NULL); +#endif } diff --git a/src/libnetdata/netipc/src/transport/posix/netipc_uds_send.c b/src/libnetdata/netipc/src/transport/posix/netipc_uds_send.c index 6a9fe3ee..e6b55ee9 100644 --- a/src/libnetdata/netipc/src/transport/posix/netipc_uds_send.c +++ b/src/libnetdata/netipc/src/transport/posix/netipc_uds_send.c @@ -1,7 +1,12 @@ #include "netipc_uds_internal.h" #include +#include +#include +#include #include +#include +#include static size_t min_of_size_t(size_t a, size_t b) { @@ -97,17 +102,184 @@ static void fill_envelope(nipc_header_t *hdr, size_t payload_len) hdr->payload_len = (uint32_t)payload_len; } +/* Wire bytes the in-flight responses can occupy: the queue bound. A bounded + * send of an untracked message can run with nothing in flight; it still + * gets one response's room, as the peer may be mid-response. */ +static size_t pending_bound(const nipc_uds_session_t *session) +{ + size_t inflight = session->inflight_count ? session->inflight_count : 1; + size_t payload = session->max_response_payload_bytes; + size_t budget = session->packet_size > NIPC_HEADER_LEN + ? (size_t)session->packet_size - NIPC_HEADER_LEN : 1; + size_t packets = payload == 0 ? 1 : 1 + (payload - 1) / budget; + size_t per_packet = NIPC_HEADER_LEN + NIPC_UDS_PENDING_RECORD_LEN; + if (packets > (SIZE_MAX - payload) / per_packet) + return SIZE_MAX; + size_t per_response = payload + packets * per_packet; + if (per_response > SIZE_MAX / inflight) + return SIZE_MAX; + return per_response * inflight; +} + +/* A failure inside a progress send leaves a partial message on the wire or + * inbound bytes dropped: the session cannot continue. */ +static nipc_uds_error_t fail_session(nipc_uds_session_t *session, + nipc_uds_error_t err) +{ + (void)shutdown(session->fd, SHUT_RDWR); + nipc_uds_inflight_fail_all(session); + return err; +} + +/* Sends one packet without blocking; while the socket is full, reads what + * the peer sent into the pending queue, so both peers keep making progress. + * Without a wait it waits as long as a blocking send would; with one it ends + * at the deadline or abort and breaks the session, since part of the message + * may already be on the wire. */ +static nipc_uds_error_t send_with_progress_loop(nipc_uds_session_t *session, + const void *hdr, size_t hdr_len, + const void *payload, + size_t payload_len, + const nipc_uds_wait_t *wait) +{ + struct iovec iov[2]; + int iovcnt = 1; + iov[0].iov_base = (void *)hdr; + iov[0].iov_len = hdr_len; + if (payload && payload_len > 0) { + iov[1].iov_base = (void *)payload; + iov[1].iov_len = payload_len; + iovcnt = 2; + } + struct iovec *cur = iov; + size_t bound = pending_bound(session); + /* After a failed stream receive the inbound bytes belong to an + * abandoned message: never queue them. */ + bool eof = session->recv_failed; + + for (;;) { + struct msghdr msg; + memset(&msg, 0, sizeof(msg)); + msg.msg_iov = cur; + msg.msg_iovlen = iovcnt; + + ssize_t n = sendmsg(session->fd, &msg, + NIPC_UDS_SEND_FLAGS | MSG_DONTWAIT); + if (n >= 0) { +#if NIPC_UDS_STREAM + if (n == 0) + return NIPC_UDS_ERR_SEND; + size_t left = (size_t)n; + while (iovcnt > 0 && left >= cur->iov_len) { + left -= cur->iov_len; + cur++; + iovcnt--; + } + if (iovcnt == 0) + return NIPC_UDS_OK; + cur->iov_base = (uint8_t *)cur->iov_base + left; + cur->iov_len -= left; + continue; +#else + return (size_t)n == hdr_len + payload_len ? NIPC_UDS_OK + : NIPC_UDS_ERR_SEND; +#endif + } + if (errno == EINTR) + continue; + if (errno != EAGAIN && errno != EWOULDBLOCK) + return NIPC_UDS_ERR_SEND; + + /* After EOF only writability matters; the retried send reports a + * closed peer. */ + struct pollfd fds[2] = { + { + .fd = session->fd, + .events = (short)(POLLOUT | (eof ? 0 : POLLIN)), + .revents = 0, + }, + {.fd = -1, .events = POLLIN, .revents = 0}, + }; + nfds_t nfds = 1; + if (wait && wait->abort_fd >= 0) { + fds[1].fd = wait->abort_fd; + nfds = 2; + } + int timeout = wait ? nipc_uds_wait_timeout(wait) : -1; + if (timeout == 0) + return fail_session(session, NIPC_UDS_ERR_TIMEOUT); + int rc = poll(fds, nfds, timeout); + if (rc < 0) { + if (errno == EINTR) + continue; + return NIPC_UDS_ERR_SEND; + } + if (rc == 0) + continue; /* nipc_uds_wait_timeout() reports the deadline */ + if (nfds == 2 && (fds[1].revents & (POLLIN | POLLHUP | POLLERR))) + return fail_session(session, NIPC_UDS_ERR_ABORTED); + if (!eof && (fds[0].revents & (POLLIN | POLLHUP | POLLERR))) { + nipc_uds_error_t err = nipc_uds_pending_drain(session, bound, &eof); + if (err != NIPC_UDS_OK) + return fail_session(session, err); + } + } +} + +/* macOS ignores MSG_DONTWAIT on a stream send that lacks buffer space and + * blocks, so on the stream baseline (macOS, FreeBSD) the descriptor is made + * non-blocking for the send instead. */ +static nipc_uds_error_t send_with_progress(nipc_uds_session_t *session, + const void *hdr, size_t hdr_len, + const void *payload, + size_t payload_len, + const nipc_uds_wait_t *wait) +{ +#if NIPC_UDS_STREAM + int flags = fcntl(session->fd, F_GETFL); + if (flags < 0 || fcntl(session->fd, F_SETFL, flags | O_NONBLOCK) < 0) + return NIPC_UDS_ERR_SEND; + nipc_uds_error_t err = send_with_progress_loop(session, hdr, hdr_len, + payload, payload_len, wait); + /* A descriptor left non-blocking would break later blocking calls. */ + if (fcntl(session->fd, F_SETFL, flags) < 0) + return fail_session(session, NIPC_UDS_ERR_SEND); + return err; +#else + return send_with_progress_loop(session, hdr, hdr_len, payload, + payload_len, wait); +#endif +} + +/* A client send with a deadline or abort, or with another request in + * flight (a pipelining client can deadlock with a server blocked sending), + * waits in the progress loop; anything else keeps the plain blocking send. */ +static nipc_uds_error_t session_send_packet(nipc_uds_session_t *session, + const void *hdr, size_t hdr_len, + const void *payload, + size_t payload_len, + const nipc_uds_wait_t *wait) +{ + if (session->role == NIPC_UDS_ROLE_CLIENT && + (wait || session->inflight_count > 1)) + return send_with_progress(session, hdr, hdr_len, payload, + payload_len, wait); + return nipc_uds_raw_send_iov(session->fd, hdr, hdr_len, payload, + payload_len); +} + static nipc_uds_error_t send_single_packet(nipc_uds_session_t *session, nipc_header_t *hdr, const void *payload, size_t payload_len, - bool tracked) + bool tracked, + const nipc_uds_wait_t *wait) { uint8_t hdr_buf[NIPC_HEADER_LEN]; nipc_header_encode(hdr, hdr_buf, sizeof(hdr_buf)); - nipc_uds_error_t err = nipc_uds_raw_send_iov( - session->fd, hdr_buf, NIPC_HEADER_LEN, payload, payload_len); + nipc_uds_error_t err = session_send_packet( + session, hdr_buf, NIPC_HEADER_LEN, payload, payload_len, wait); update_inflight_after_send(session, hdr, tracked, err); return err; } @@ -116,13 +288,15 @@ static nipc_uds_error_t send_first_chunk(nipc_uds_session_t *session, nipc_header_t *hdr, const void *payload, size_t first_chunk_payload, - bool tracked) + bool tracked, + const nipc_uds_wait_t *wait) { uint8_t hdr_buf[NIPC_HEADER_LEN]; nipc_header_encode(hdr, hdr_buf, sizeof(hdr_buf)); - nipc_uds_error_t err = nipc_uds_raw_send_iov( - session->fd, hdr_buf, NIPC_HEADER_LEN, payload, first_chunk_payload); + nipc_uds_error_t err = session_send_packet( + session, hdr_buf, NIPC_HEADER_LEN, payload, first_chunk_payload, + wait); update_inflight_after_send(session, hdr, tracked, err); return err; } @@ -134,7 +308,8 @@ static nipc_uds_error_t send_continuation_chunk(nipc_uds_session_t *session, uint32_t chunk_index, uint32_t chunk_count, uint32_t total_msg, - bool tracked) + bool tracked, + const nipc_uds_wait_t *wait) { /* packet_size is negotiated as uint32_t, so continuation chunks fit u32. */ assert(this_chunk <= UINT32_MAX); @@ -152,8 +327,8 @@ static nipc_uds_error_t send_continuation_chunk(nipc_uds_session_t *session, uint8_t chk_buf[NIPC_HEADER_LEN]; nipc_chunk_header_encode(&chk, chk_buf, sizeof(chk_buf)); - nipc_uds_error_t err = nipc_uds_raw_send_iov( - session->fd, chk_buf, NIPC_HEADER_LEN, src, this_chunk); + nipc_uds_error_t err = session_send_packet( + session, chk_buf, NIPC_HEADER_LEN, src, this_chunk, wait); update_inflight_after_send(session, hdr, tracked, err); return err; } @@ -163,7 +338,8 @@ static nipc_uds_error_t send_chunked(nipc_uds_session_t *session, const void *payload, size_t payload_len, size_t total_msg, - bool tracked) + bool tracked, + const nipc_uds_wait_t *wait) { if (session->packet_size <= NIPC_HEADER_LEN) return NIPC_UDS_ERR_BAD_PARAM; @@ -185,7 +361,8 @@ static nipc_uds_error_t send_chunked(nipc_uds_session_t *session, uint32_t chunk_count = 1 + continuation_chunks; nipc_uds_error_t err = send_first_chunk(session, hdr, payload, - first_chunk_payload, tracked); + first_chunk_payload, tracked, + wait); if (err != NIPC_UDS_OK) return err; @@ -197,7 +374,7 @@ static nipc_uds_error_t send_chunked(nipc_uds_session_t *session, err = send_continuation_chunk(session, hdr, src, this_chunk, ci, chunk_count, total_msg_u32, - tracked); + tracked, wait); if (err != NIPC_UDS_OK) return err; @@ -208,10 +385,11 @@ static nipc_uds_error_t send_chunked(nipc_uds_session_t *session, return NIPC_UDS_OK; } -nipc_uds_error_t nipc_uds_send(nipc_uds_session_t *session, - nipc_header_t *hdr, - const void *payload, - size_t payload_len) +static nipc_uds_error_t uds_send(nipc_uds_session_t *session, + nipc_header_t *hdr, + const void *payload, + size_t payload_len, + const nipc_uds_wait_t *wait) { if (!session || session->fd < 0) return NIPC_UDS_ERR_BAD_PARAM; @@ -236,9 +414,30 @@ nipc_uds_error_t nipc_uds_send(nipc_uds_session_t *session, if (total_msg <= session->packet_size) { return send_single_packet(session, hdr, payload, payload_len, - tracked); + tracked, wait); } return send_chunked(session, hdr, payload, payload_len, total_msg, - tracked); + tracked, wait); +} + +nipc_uds_error_t nipc_uds_send(nipc_uds_session_t *session, + nipc_header_t *hdr, + const void *payload, + size_t payload_len) +{ + return uds_send(session, hdr, payload, payload_len, NULL); +} + +nipc_uds_error_t nipc_uds_send_timeout(nipc_uds_session_t *session, + nipc_header_t *hdr, + const void *payload, + size_t payload_len, + uint32_t timeout_ms, + int abort_fd) +{ + if (timeout_ms == 0 && abort_fd < 0) + return uds_send(session, hdr, payload, payload_len, NULL); + nipc_uds_wait_t wait = nipc_uds_wait_init(timeout_ms, abort_fd); + return uds_send(session, hdr, payload, payload_len, &wait); } diff --git a/src/libnetdata/netipc/src/transport/windows/netipc_named_pipe.c b/src/libnetdata/netipc/src/transport/windows/netipc_named_pipe.c index 5e784fe3..a1459e96 100644 --- a/src/libnetdata/netipc/src/transport/windows/netipc_named_pipe.c +++ b/src/libnetdata/netipc/src/transport/windows/netipc_named_pipe.c @@ -10,6 +10,7 @@ #include "netipc/netipc_named_pipe.h" #include "netipc/netipc_protocol.h" +#include "netipc_win_security.h" #include #include @@ -210,12 +211,19 @@ static HANDLE create_pipe_instance(const wchar_t *pipe_name, uint32_t buf_size, if (first_instance) open_mode |= FILE_FLAG_FIRST_PIPE_INSTANCE; - HANDLE h = CreateNamedPipeW( - pipe_name, open_mode, - PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, - NIPC_NP_MAX_INSTANCES, buf_size, buf_size, 0, /* default timeout */ - NULL /* default security */ - ); + SECURITY_ATTRIBUTES sa; + if (!nipc_win_security_attrs_init(&sa)) + return INVALID_HANDLE_VALUE; + + HANDLE h = CreateNamedPipeW(pipe_name, open_mode, + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | + PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + NIPC_NP_MAX_INSTANCES, buf_size, buf_size, + 0, /* default timeout */ + &sa); + DWORD err = GetLastError(); + nipc_win_security_attrs_free(&sa); + SetLastError(err); return h; } @@ -244,10 +252,15 @@ static nipc_np_error_t raw_send(HANDLE pipe, const void *data, size_t len) { return NIPC_NP_OK; } +typedef struct receive_wait receive_wait_t; +static nipc_np_error_t pipe_send(nipc_np_session_t *s, const void *data, + size_t len, const receive_wait_t *wait); + /* Send header + payload as one pipe message (concatenated). */ -static nipc_np_error_t raw_send_msg(HANDLE pipe, const void *hdr, +static nipc_np_error_t raw_send_msg(nipc_np_session_t *session, const void *hdr, size_t hdr_len, const void *payload, - size_t payload_len) { + size_t payload_len, + const receive_wait_t *wait) { /* Named Pipes don't support scatter-gather like sendmsg. * Concatenate into one buffer for a single WriteFile call. */ size_t total = hdr_len + payload_len; @@ -268,7 +281,7 @@ static nipc_np_error_t raw_send_msg(HANDLE pipe, const void *hdr, if (payload && payload_len > 0) memcpy(buf + hdr_len, payload, payload_len); - nipc_np_error_t err = raw_send(pipe, buf, total); + nipc_np_error_t err = pipe_send(session, buf, total, wait); if (allocated) free(buf); @@ -296,6 +309,349 @@ static int raw_recv(HANDLE pipe, void *buf, size_t buf_len, DWORD *bytes_read) { return 1; /* success */ } +/* Client pipe handles are overlapped (FILE_FLAG_OVERLAPPED), so a pipelining + * send can read responses while its write waits for buffer space. Every read + * and write on such a handle passes an OVERLAPPED and is waited to completion + * before the call returns, so the OVERLAPPED lives on that call's stack. + * Server handles stay synchronous. */ + +static nipc_np_error_t send_error(DWORD err) { + return is_disconnect_error(err) ? NIPC_NP_ERR_DISCONNECTED : NIPC_NP_ERR_SEND; +} + +/* Read one message on an overlapped handle and wait for it; same returns as + * raw_recv(). A message larger than buf (ERROR_MORE_DATA) fails, as on a + * synchronous handle. */ +static int overlapped_recv(HANDLE pipe, HANDLE event, void *buf, size_t buf_len, + DWORD *bytes_read) { + OVERLAPPED ov = {0}; + ov.hEvent = event; + DWORD read = 0; + *bytes_read = 0; + /* Overlapped I/O takes no byte-count pointer (Win32 docs); the count + * comes from GetOverlappedResult. */ + if (!ReadFile(pipe, buf, (DWORD)buf_len, NULL, &ov)) { + DWORD err = GetLastError(); + if (err != ERROR_IO_PENDING) + return is_disconnect_error(err) ? 0 : -1; + } + if (!GetOverlappedResult(pipe, &ov, &read, TRUE)) + return is_disconnect_error(GetLastError()) ? 0 : -1; + if (read == 0) + return 0; + *bytes_read = read; + return 1; +} + +/* Read one message with the session's handle kind. */ +static int pipe_recv(const nipc_np_session_t *s, void *buf, size_t buf_len, + DWORD *bytes_read) { + if (s->read_event) + return overlapped_recv(s->pipe, s->read_event, buf, buf_len, bytes_read); + return raw_recv(s->pipe, buf, buf_len, bytes_read); +} + +/* Post one message; finish_overlapped_send() collects its completion, also + * when WriteFile completed at once. */ +static nipc_np_error_t start_overlapped_send(HANDLE pipe, OVERLAPPED *ov, + const void *data, size_t len) { + /* No byte-count pointer: the write may still be pending when this + * returns; finish_overlapped_send() gets the count. */ + if (!WriteFile(pipe, data, (DWORD)len, NULL, ov)) { + DWORD err = GetLastError(); + if (err != ERROR_IO_PENDING) + return send_error(err); + } + return NIPC_NP_OK; +} + +static nipc_np_error_t finish_overlapped_send(HANDLE pipe, OVERLAPPED *ov, + size_t len) { + DWORD written = 0; + if (!GetOverlappedResult(pipe, ov, &written, TRUE)) + return send_error(GetLastError()); + return written == (DWORD)len ? NIPC_NP_OK : NIPC_NP_ERR_SEND; +} + +/* Cancel a posted write and wait until the kernel no longer uses its + * OVERLAPPED and buffer. */ +static void cancel_overlapped_send(HANDLE pipe, OVERLAPPED *ov) { + DWORD written = 0; + CancelIoEx(pipe, ov); + GetOverlappedResult(pipe, ov, &written, TRUE); +} + +#define NP_PENDING_RECORD_LEN 4u + +static void pending_compact(nipc_np_session_t *s) { + if (s->pending_head == 0) + return; + size_t left = s->pending_len - s->pending_head; + memmove(s->pending_buf, s->pending_buf + s->pending_head, left); + s->pending_len = left; + s->pending_head = 0; +} + +/* Pop the oldest queued message into buf; same returns as raw_recv(). A + * message larger than buf fails, as a pipe read would with + * ERROR_MORE_DATA. */ +static int pending_pop(nipc_np_session_t *s, void *buf, size_t buf_len, + DWORD *bytes_read) { + uint32_t rec; + memcpy(&rec, s->pending_buf + s->pending_head, sizeof(rec)); + size_t start = s->pending_head + NP_PENDING_RECORD_LEN; + int rc = -1; + *bytes_read = 0; + if ((size_t)rec <= buf_len) { + memcpy(buf, s->pending_buf + start, rec); + *bytes_read = rec; + rc = 1; + } + s->pending_head = start + rec; + if (s->pending_head == s->pending_len) + s->pending_head = s->pending_len = 0; + return rc; +} + +static int peek_available(HANDLE pipe, DWORD *available) { + *available = 0; + return PeekNamedPipe(pipe, NULL, 0, NULL, available, NULL) ? 0 : -1; +} + +/* Read every message available now into the pending queue. Sets *drained if + * it read any. A disconnected pipe sets *eof: the posted write reports it. Queued bytes beyond progress_bound fail with + * NIPC_NP_ERR_PROTOCOL. */ +static nipc_np_error_t pending_drain(nipc_np_session_t *s, bool *drained, + bool *eof) { + size_t read_len = s->packet_size; + for (;;) { + DWORD available = 0; + if (peek_available(s->pipe, &available) < 0) { + if (is_disconnect_error(GetLastError())) { + *eof = true; + return NIPC_NP_OK; + } + /* Stopping the drain on a live pipe would bring the deadlock back. */ + return NIPC_NP_ERR_PROTOCOL; + } + if (available == 0) + return NIPC_NP_OK; + + pending_compact(s); + size_t need = s->pending_len + NP_PENDING_RECORD_LEN + read_len; + if (need < s->pending_len) + return NIPC_NP_ERR_PROTOCOL; + if (need > s->pending_cap) { + size_t cap = s->pending_cap ? s->pending_cap : 4096; + while (cap < need) { + if (cap > SIZE_MAX / 2) + return NIPC_NP_ERR_ALLOC; + cap *= 2; + } + uint8_t *p = realloc(s->pending_buf, cap); + if (!p) + return NIPC_NP_ERR_ALLOC; + s->pending_buf = p; + s->pending_cap = cap; + } + + size_t start = s->pending_len; + DWORD n = 0; + int rc = overlapped_recv(s->pipe, s->read_event, + s->pending_buf + start + NP_PENDING_RECORD_LEN, + read_len, &n); + if (rc == 0) { + *eof = true; + return NIPC_NP_OK; + } + if (rc < 0) + return NIPC_NP_ERR_PROTOCOL; /* larger than a packet, or a read failure */ + uint32_t rec = (uint32_t)n; + memcpy(s->pending_buf + start, &rec, sizeof(rec)); + s->pending_len = start + NP_PENDING_RECORD_LEN + n; + if (s->pending_len > s->progress_bound) + return NIPC_NP_ERR_PROTOCOL; + *drained = true; + } +} + +static void inflight_fail_all(nipc_np_session_t *s); + +/* A cancelled write leaves the message stream in an unknown state, and a + * drain failure drops inbound messages: close the pipe, so later calls fail + * with NIPC_NP_ERR_BAD_PARAM, and fail every in-flight request. + * nipc_np_close_session() still releases the rest. */ +static void break_session(nipc_np_session_t *s) { + CloseHandle(s->pipe); + s->pipe = INVALID_HANDLE_VALUE; + s->pending_len = s->pending_head = 0; + inflight_fail_all(s); +} + +/* Wire bytes `inflight` responses can occupy: the pending queue bound. */ +static size_t pending_bound(uint32_t max_response_payload, uint32_t packet_size, + uint32_t inflight) { + size_t payload = max_response_payload; + size_t budget = + packet_size > NIPC_HEADER_LEN ? packet_size - NIPC_HEADER_LEN : 1; + size_t packets = payload == 0 ? 1 : 1 + (payload - 1) / budget; + size_t per_packet = NIPC_HEADER_LEN + NP_PENDING_RECORD_LEN; + if (packets > (SIZE_MAX - payload) / per_packet) + return SIZE_MAX; + size_t per_response = payload + packets * per_packet; + if (per_response > SIZE_MAX / inflight) + return SIZE_MAX; + return per_response * inflight; +} + +struct receive_wait { + bool infinite; + ULONGLONG deadline; + HANDLE abort_event; +}; + + +static receive_wait_t receive_wait_init(uint32_t timeout_ms, + HANDLE abort_event) { + receive_wait_t wait = { + .infinite = (timeout_ms == 0), + .deadline = 0, + .abort_event = abort_event, + }; + if (!wait.infinite) + wait.deadline = GetTickCount64() + timeout_ms; + return wait; +} + +static bool receive_wait_expired(const receive_wait_t *wait) { + return !wait->infinite && GetTickCount64() >= wait->deadline; +} + +static bool receive_wait_aborted(const receive_wait_t *wait) { + return wait->abort_event && + WaitForSingleObject(wait->abort_event, 0) == WAIT_OBJECT_0; +} + +/* How long a stalled pipelined send keeps yielding before it sleeps. */ +#define NP_PROGRESS_YIELD_US 1000u + +static ULONGLONG progress_now_us(void) { + LARGE_INTEGER count, freq; + QueryPerformanceCounter(&count); + QueryPerformanceFrequency(&freq); + ULONGLONG c = (ULONGLONG)count.QuadPart, f = (ULONGLONG)freq.QuadPart; + return c / f * 1000000u + c % f * 1000000u / f; +} + +/* A posted write that ends on the deadline or abort is cancelled and the + * session broken, since part of the message may be on the wire. */ +static nipc_np_error_t send_wait_ended(nipc_np_session_t *s, OVERLAPPED *ov, + const receive_wait_t *wait) { + nipc_np_error_t err = NIPC_NP_OK; + if (receive_wait_aborted(wait)) + err = NIPC_NP_ERR_ABORTED; + else if (receive_wait_expired(wait)) + err = NIPC_NP_ERR_TIMEOUT; + if (err != NIPC_NP_OK) { + cancel_overlapped_send(s->pipe, ov); + break_session(s); + } + return err; +} + +/* Wait for a posted write until it completes, the deadline passes or the + * abort event is signaled. */ +static nipc_np_error_t finish_overlapped_send_wait(nipc_np_session_t *s, + OVERLAPPED *ov, size_t len, + const receive_wait_t *wait) { + for (;;) { + DWORD written = 0; + if (GetOverlappedResult(s->pipe, ov, &written, FALSE)) + return written == (DWORD)len ? NIPC_NP_OK : NIPC_NP_ERR_SEND; + DWORD gle = GetLastError(); + if (gle != ERROR_IO_INCOMPLETE) + return send_error(gle); + nipc_np_error_t err = send_wait_ended(s, ov, wait); + if (err != NIPC_NP_OK) + return err; + + HANDLE handles[2] = {s->write_event, wait->abort_event}; + DWORD count = wait->abort_event ? 2 : 1; + DWORD ms = INFINITE; + if (!wait->infinite) { + ULONGLONG now = GetTickCount64(); + ULONGLONG left = wait->deadline > now ? wait->deadline - now : 0; + ms = left >= INFINITE ? INFINITE - 1 : (DWORD)left; + } + if (WaitForMultipleObjects(count, handles, FALSE, ms) == WAIT_FAILED) { + cancel_overlapped_send(s->pipe, ov); + break_session(s); + return NIPC_NP_ERR_SEND; + } + } +} + +/* Write one packet with the session's handle kind. A client with + * progress_bound set (more than one request in flight) reads what the server + * sent into the pending queue until its write completes, so both sides keep + * making progress. Without a wait it waits as long as a blocking write + * would; with one it also ends at the deadline or abort. */ +static nipc_np_error_t pipe_send(nipc_np_session_t *s, const void *data, + size_t len, const receive_wait_t *wait) { + if (!s->write_event) + return raw_send(s->pipe, data, len); + + OVERLAPPED ov = {0}; + ov.hEvent = s->write_event; + nipc_np_error_t err = start_overlapped_send(s->pipe, &ov, data, len); + if (err != NIPC_NP_OK) + return err; + if (s->progress_bound == 0) + return wait ? finish_overlapped_send_wait(s, &ov, len, wait) + : finish_overlapped_send(s->pipe, &ov, len); + + bool eof = false; + ULONGLONG yield_until = progress_now_us() + NP_PROGRESS_YIELD_US; + for (;;) { + DWORD w = WaitForSingleObject(s->write_event, 0); + if (w == WAIT_OBJECT_0) + return finish_overlapped_send(s->pipe, &ov, len); + if (w != WAIT_TIMEOUT) { + cancel_overlapped_send(s->pipe, &ov); + break_session(s); + return NIPC_NP_ERR_SEND; + } + if (wait) { + err = send_wait_ended(s, &ov, wait); + if (err != NIPC_NP_OK) + return err; + } + if (!eof) { + bool drained = false; + err = pending_drain(s, &drained, &eof); + if (err != NIPC_NP_OK) { + cancel_overlapped_send(s->pipe, &ov); + break_session(s); + return err; + } + if (drained) { + yield_until = progress_now_us() + NP_PROGRESS_YIELD_US; + continue; + } + } + /* The server is busy or its reply is still being written: yield for up + * to NP_PROGRESS_YIELD_US after the last progress, then wait for the + * write in short slices. A slice lasts a system timer tick and only the + * write completing ends it early, so slicing at once stalls a server + * blocked on its reply to us. */ + if (progress_now_us() < yield_until) { + SwitchToThread(); + continue; + } + WaitForSingleObject(s->write_event, 1); + } +} + /* Poll a synchronous pipe handle until bytes are available. Go/Rust already * use PeekNamedPipe here; waiting on the pipe HANDLE itself is not reliable * for request readability. */ @@ -356,35 +712,6 @@ static nipc_np_error_t wait_readable(HANDLE pipe, uint32_t timeout_ms, } } -typedef struct { - bool infinite; - ULONGLONG deadline; - HANDLE abort_event; -} receive_wait_t; - -static void inflight_fail_all(nipc_np_session_t *s); - -static receive_wait_t receive_wait_init(uint32_t timeout_ms, - HANDLE abort_event) { - receive_wait_t wait = { - .infinite = (timeout_ms == 0), - .deadline = 0, - .abort_event = abort_event, - }; - if (!wait.infinite) - wait.deadline = GetTickCount64() + timeout_ms; - return wait; -} - -static bool receive_wait_expired(const receive_wait_t *wait) { - return !wait->infinite && GetTickCount64() >= wait->deadline; -} - -static bool receive_wait_aborted(const receive_wait_t *wait) { - return wait->abort_event && - WaitForSingleObject(wait->abort_event, 0) == WAIT_OBJECT_0; -} - static nipc_np_error_t wait_readable_for_receive(HANDLE pipe, const receive_wait_t *wait, bool *readable_out) { @@ -452,6 +779,13 @@ static nipc_np_error_t raw_recv_with_wait(nipc_np_session_t *session, void *buf, size_t buf_size, const receive_wait_t *wait, DWORD *bytes_read) { + if (nipc_np_session_has_pending(session)) { + if (pending_pop(session, buf, buf_size, bytes_read) <= 0) { + inflight_fail_all(session); + return NIPC_NP_ERR_RECV; + } + return NIPC_NP_OK; + } bool readable = false; nipc_np_error_t werr = wait_readable_for_receive(session->pipe, wait, &readable); @@ -460,7 +794,7 @@ static nipc_np_error_t raw_recv_with_wait(nipc_np_session_t *session, void *buf, if (!readable) return NIPC_NP_ERR_TIMEOUT; - int rc = raw_recv(session->pipe, buf, buf_size, bytes_read); + int rc = pipe_recv(session, buf, buf_size, bytes_read); if (rc <= 0) { inflight_fail_all(session); return NIPC_NP_ERR_RECV; @@ -470,7 +804,9 @@ static nipc_np_error_t raw_recv_with_wait(nipc_np_session_t *session, void *buf, static nipc_np_error_t raw_recv_blocking(nipc_np_session_t *session, void *buf, size_t buf_size, DWORD *bytes_read) { - int rc = raw_recv(session->pipe, buf, buf_size, bytes_read); + int rc = nipc_np_session_has_pending(session) + ? pending_pop(session, buf, buf_size, bytes_read) + : pipe_recv(session, buf, buf_size, bytes_read); if (rc <= 0) { inflight_fail_all(session); return NIPC_NP_ERR_RECV; @@ -529,6 +865,7 @@ static void inflight_fail_all(nipc_np_session_t *s) { static nipc_np_error_t client_handshake(HANDLE pipe, const nipc_np_client_config_t *cfg, + const receive_wait_t *wait, nipc_np_session_t *session) { uint8_t buf[128]; @@ -575,14 +912,25 @@ static nipc_np_error_t client_handshake(HANDLE pipe, memcpy(buf + NIPC_HEADER_LEN, hello_buf, sizeof(hello_buf)); /* Send HELLO */ - nipc_np_error_t err = - raw_send(pipe, buf, NIPC_HEADER_LEN + sizeof(hello_buf)); + OVERLAPPED ov = {0}; + ov.hEvent = session->write_event; + nipc_np_error_t err = start_overlapped_send(pipe, &ov, buf, + NIPC_HEADER_LEN + sizeof(hello_buf)); + if (err == NIPC_NP_OK) + err = finish_overlapped_send(pipe, &ov, NIPC_HEADER_LEN + sizeof(hello_buf)); if (err != NIPC_NP_OK) return err; - /* Receive HELLO_ACK */ + /* Receive HELLO_ACK; a server that never answers must not hold the + * client past its deadline. A server that rejects the client writes its + * reply and closes, so a disconnected pipe may still hold that reply. */ + err = wait_readable_for_receive(pipe, wait, NULL); + if (err != NIPC_NP_OK && err != NIPC_NP_ERR_DISCONNECTED) + return err; + DWORD bytes_read = 0; - int rc = raw_recv(pipe, buf, sizeof(buf), &bytes_read); + int rc = overlapped_recv(pipe, session->read_event, buf, sizeof(buf), + &bytes_read); if (rc <= 0) return NIPC_NP_ERR_RECV; @@ -619,6 +967,23 @@ static nipc_np_error_t client_handshake(HANDLE pipe, return NIPC_NP_ERR_INCOMPATIBLE; if (perr != NIPC_OK) return NIPC_NP_ERR_PROTOCOL; + /* HELLO_ACK is exactly one 48-byte payload; extra bytes would otherwise + * be ignored. */ + if (ack_hdr.payload_len != sizeof(nipc_hello_ack_t) || + (size_t)bytes_read != NIPC_HEADER_LEN + sizeof(nipc_hello_ack_t)) + return NIPC_NP_ERR_PROTOCOL; + + /* The server must pick exactly one of the profiles this client offered. */ + if (ack.selected_profile == 0 || + (ack.selected_profile & (ack.selected_profile - 1)) != 0 || + (ack.selected_profile & hello.supported_profiles) == 0) + return NIPC_NP_ERR_PROTOCOL; + + /* The agreed packet size is at most what this client proposed, and must + * leave room for a payload after the header. */ + if (ack.agreed_packet_size <= NIPC_HEADER_LEN || + ack.agreed_packet_size > pkt_size) + return NIPC_NP_ERR_PROTOCOL; /* Fill session */ session->pipe = pipe; @@ -644,6 +1009,8 @@ static nipc_np_error_t client_handshake(HANDLE pipe, static nipc_np_error_t server_handshake(HANDLE pipe, const nipc_np_server_config_t *cfg, uint64_t session_id, + nipc_np_profile_prepare_fn prepare, + void *prepare_user, nipc_np_session_t *session) { uint8_t buf[128]; @@ -687,6 +1054,10 @@ static nipc_np_error_t server_handshake(HANDLE pipe, } if (perr != NIPC_OK) return NIPC_NP_ERR_PROTOCOL; + /* HELLO is exactly one 44-byte payload, as HELLO_ACK is 48. */ + if (hdr.payload_len != NIPC_HELLO_WIRE_SIZE || + (size_t)bytes_read != NIPC_HEADER_LEN + NIPC_HELLO_WIRE_SIZE) + return NIPC_NP_ERR_PROTOCOL; /* Compute intersection */ uint32_t intersection = hello.supported_profiles & s_profiles; @@ -733,6 +1104,21 @@ static nipc_np_error_t server_handshake(HANDLE pipe, return NIPC_NP_ERR_INCOMPATIBLE; } + /* Set up the selected profile before the ACK promises it. A profile the + * caller cannot prepare is dropped, as if the server never offered it. */ + while (prepare && + !prepare(prepare_user, selected, agreed_req_pay, agreed_resp_pay)) { + s_profiles &= ~selected; + intersection &= ~selected; + if (intersection == 0) { + send_rejection_ack(pipe, NIPC_STATUS_UNSUPPORTED); + return NIPC_NP_ERR_NO_PROFILE; + } + preferred_intersection = intersection & hello.preferred_profiles & s_preferred; + selected = preferred_intersection != 0 ? highest_bit(preferred_intersection) + : highest_bit(intersection); + } + /* Send HELLO_ACK (success) */ nipc_hello_ack_t ack = { .layout_version = 1, @@ -831,16 +1217,31 @@ nipc_np_error_t nipc_np_listen(const char *run_dir, const char *service_name, /* Public API: accept */ /* ------------------------------------------------------------------ */ -nipc_np_error_t nipc_np_accept(nipc_np_listener_t *listener, - uint64_t session_id, nipc_np_session_t *out) { - if (!listener || !out) +static volatile LONG g_fail_next_instances; + +void nipc_np_fail_next_instances_for_tests(int count) { + InterlockedExchange(&g_fail_next_instances, count); +} + +static bool fail_next_instance(void) { + for (;;) { + LONG left = g_fail_next_instances; + if (left <= 0) + return false; + if (InterlockedCompareExchange(&g_fail_next_instances, left - 1, left) == + left) + return true; + } +} + +nipc_np_error_t nipc_np_accept_pending(nipc_np_listener_t *listener, + HANDLE *pipe_out) { + if (!listener || !pipe_out) return NIPC_NP_ERR_BAD_PARAM; + *pipe_out = INVALID_HANDLE_VALUE; if (listener->pipe == INVALID_HANDLE_VALUE || listener->pipe == NULL) return NIPC_NP_ERR_ACCEPT; - memset(out, 0, sizeof(*out)); - out->pipe = INVALID_HANDLE_VALUE; - /* Wait for client to connect to the current pipe instance */ BOOL connected = ConnectNamedPipe(listener->pipe, NULL); if (!connected) { @@ -857,21 +1258,59 @@ nipc_np_error_t nipc_np_accept(nipc_np_listener_t *listener, /* Create a new pipe instance for the next client. */ uint32_t buf_size = pipe_buffer_size(listener->config.packet_size); - HANDLE next = create_pipe_instance(listener->pipe_name, buf_size, FALSE); + HANDLE next = fail_next_instance() + ? INVALID_HANDLE_VALUE + : create_pipe_instance(listener->pipe_name, buf_size, FALSE); if (next == INVALID_HANDLE_VALUE) { - /* Cannot create next instance — close the session pipe too. */ + /* Without a next instance, drop this client and keep its instance as + * the listener's, so a later accept listens on it again. */ DisconnectNamedPipe(session_pipe); - CloseHandle(session_pipe); return NIPC_NP_ERR_CREATE_PIPE; } listener->pipe = next; - /* Perform handshake */ - nipc_np_error_t herr = - server_handshake(session_pipe, &listener->config, session_id, out); + *pipe_out = session_pipe; + return NIPC_NP_OK; +} + +nipc_np_error_t nipc_np_accept_handshake(HANDLE pipe, + const nipc_np_server_config_t *config, + uint64_t session_id, + uint32_t timeout_ms, + nipc_np_session_t *out) { + return nipc_np_accept_handshake_prepared(pipe, config, session_id, + timeout_ms, NULL, NULL, out); +} + +nipc_np_error_t nipc_np_accept_handshake_prepared( + HANDLE pipe, const nipc_np_server_config_t *config, uint64_t session_id, + uint32_t timeout_ms, nipc_np_profile_prepare_fn prepare, void *user, + nipc_np_session_t *out) { + if (!out || !config || pipe == INVALID_HANDLE_VALUE || pipe == NULL) { + if (pipe != INVALID_HANDLE_VALUE && pipe != NULL) + CloseHandle(pipe); + return NIPC_NP_ERR_BAD_PARAM; + } + + memset(out, 0, sizeof(*out)); + out->pipe = INVALID_HANDLE_VALUE; + + bool readable = false; + nipc_np_error_t herr; + do { + herr = wait_readable(pipe, timeout_ms ? timeout_ms : 1000u, &readable); + } while (herr == NIPC_NP_OK && !readable && timeout_ms == 0); + if (herr == NIPC_NP_OK && !readable) + herr = NIPC_NP_ERR_TIMEOUT; + /* A peer gone before HELLO is a failed HELLO read, as without the wait. */ + if (herr == NIPC_NP_ERR_DISCONNECTED) + herr = NIPC_NP_ERR_RECV; + if (herr == NIPC_NP_OK) + herr = server_handshake(pipe, config, session_id, prepare, user, out); if (herr != NIPC_NP_OK) { - DisconnectNamedPipe(session_pipe); - CloseHandle(session_pipe); + /* Close only: DisconnectNamedPipe would discard a rejection reply the + * client has not read yet. */ + CloseHandle(pipe); out->pipe = INVALID_HANDLE_VALUE; return herr; } @@ -879,19 +1318,130 @@ nipc_np_error_t nipc_np_accept(nipc_np_listener_t *listener, return NIPC_NP_OK; } +nipc_np_error_t nipc_np_accept(nipc_np_listener_t *listener, + uint64_t session_id, nipc_np_session_t *out) { + if (!listener || !out) + return NIPC_NP_ERR_BAD_PARAM; + + memset(out, 0, sizeof(*out)); + out->pipe = INVALID_HANDLE_VALUE; + + HANDLE session_pipe; + nipc_np_error_t err = nipc_np_accept_pending(listener, &session_pipe); + if (err != NIPC_NP_OK) + return err; + + return nipc_np_accept_handshake(session_pipe, &listener->config, session_id, + NIPC_SERVER_HANDSHAKE_TIMEOUT_MS, out); +} + /* ------------------------------------------------------------------ */ /* Public API: connect */ /* ------------------------------------------------------------------ */ -nipc_np_error_t nipc_np_connect(const char *run_dir, const char *service_name, - const nipc_np_client_config_t *config, - nipc_np_session_t *out) { +static void (*g_connect_busy_hook)(void *user); +static void *g_connect_busy_user; + +void nipc_np_set_connect_busy_hook_for_tests(void (*hook)(void *user), + void *user) { + g_connect_busy_hook = hook; + g_connect_busy_user = user; +} + +static void (*g_connect_waited_hook)(void *user); +static void *g_connect_waited_user; + +void nipc_np_set_connect_waited_hook_for_tests(void (*hook)(void *user), + void *user) { + g_connect_waited_hook = hook; + g_connect_waited_user = user; +} + +/* SECURITY_IDENTIFICATION lets the server identify the client but never act + * as it. Overlapped, so a pipelining send can read while its write waits. */ +static HANDLE create_client_pipe(const wchar_t *pipe_name) { + return CreateFileW(pipe_name, GENERIC_READ | GENERIC_WRITE, 0, /* no sharing */ + NULL, /* default security */ + OPEN_EXISTING, + SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION | + FILE_FLAG_OVERLAPPED, + NULL /* no template */ + ); +} + +/* Longest single busy-pipe wait, so an abort ends it promptly. */ +#define NP_CONNECT_BUSY_SLICE_MS 100u + +/* A server has no free pipe instance between accepting one client and + * creating the next instance, and connects in that window fail with + * ERROR_PIPE_BUSY. WaitNamedPipeW does not reserve the instance it reports, + * so another client can take it first: wait and retry, bounded by + * NIPC_NP_CONNECT_BUSY_WAIT_MS and the caller's deadline. An abort or an + * expired deadline ends the connect before the pipe is opened. */ +static HANDLE open_client_pipe(const wchar_t *pipe_name, + const receive_wait_t *wait, + nipc_np_error_t *err_out) { + *err_out = NIPC_NP_ERR_CONNECT; + if (receive_wait_aborted(wait)) { + *err_out = NIPC_NP_ERR_ABORTED; + return INVALID_HANDLE_VALUE; + } + if (receive_wait_expired(wait)) { + *err_out = NIPC_NP_ERR_TIMEOUT; + return INVALID_HANDLE_VALUE; + } + HANDLE h = create_client_pipe(pipe_name); + if (h != INVALID_HANDLE_VALUE || GetLastError() != ERROR_PIPE_BUSY) + return h; + + if (g_connect_busy_hook) + g_connect_busy_hook(g_connect_busy_user); + + ULONGLONG busy_deadline = GetTickCount64() + NIPC_NP_CONNECT_BUSY_WAIT_MS; + if (!wait->infinite && wait->deadline < busy_deadline) + busy_deadline = wait->deadline; + for (;;) { + if (receive_wait_aborted(wait)) { + *err_out = NIPC_NP_ERR_ABORTED; + return INVALID_HANDLE_VALUE; + } + ULONGLONG now = GetTickCount64(); + if (now >= busy_deadline) { + if (receive_wait_expired(wait)) + *err_out = NIPC_NP_ERR_TIMEOUT; + return INVALID_HANDLE_VALUE; + } + ULONGLONG left = busy_deadline - now; + DWORD slice = left < NP_CONNECT_BUSY_SLICE_MS ? (DWORD)left + : NP_CONNECT_BUSY_SLICE_MS; + if (!WaitNamedPipeW(pipe_name, slice)) { + if (GetLastError() != ERROR_SEM_TIMEOUT) + return INVALID_HANDLE_VALUE; + continue; + } + if (g_connect_waited_hook) + g_connect_waited_hook(g_connect_waited_user); + h = create_client_pipe(pipe_name); + if (h != INVALID_HANDLE_VALUE || GetLastError() != ERROR_PIPE_BUSY) + return h; + /* Another client took the instance first. */ + } +} + +nipc_np_error_t nipc_np_connect_timeout(const char *run_dir, + const char *service_name, + const nipc_np_client_config_t *config, + uint32_t timeout_ms, + HANDLE abort_event, + nipc_np_session_t *out) { if (!config || !out) return NIPC_NP_ERR_BAD_PARAM; memset(out, 0, sizeof(*out)); out->pipe = INVALID_HANDLE_VALUE; + receive_wait_t wait = receive_wait_init(timeout_ms, abort_event); + /* Build pipe name */ wchar_t pipe_name[NIPC_NP_MAX_PIPE_NAME]; if (nipc_np_build_pipe_name(pipe_name, NIPC_NP_MAX_PIPE_NAME, run_dir, @@ -899,15 +1449,18 @@ nipc_np_error_t nipc_np_connect(const char *run_dir, const char *service_name, return NIPC_NP_ERR_BAD_PARAM; /* Connect to the server pipe */ - HANDLE h = - CreateFileW(pipe_name, GENERIC_READ | GENERIC_WRITE, 0, /* no sharing */ - NULL, /* default security */ - OPEN_EXISTING, 0, /* default attributes */ - NULL /* no template */ - ); - + nipc_np_error_t err; + HANDLE h = open_client_pipe(pipe_name, &wait, &err); if (h == INVALID_HANDLE_VALUE) - return NIPC_NP_ERR_CONNECT; + return err; + + /* Whoever created the pipe name first owns it. Send HELLO and the auth + * token only to a server running as this account, SYSTEM or Administrators; + * other accounts cannot take those as owner. */ + if (!nipc_win_owner_trusted(h)) { + CloseHandle(h); + return NIPC_NP_ERR_UNTRUSTED_SERVER; + } /* Set read mode to message mode */ DWORD mode = PIPE_READMODE_MESSAGE; @@ -916,17 +1469,32 @@ nipc_np_error_t nipc_np_connect(const char *run_dir, const char *service_name, return NIPC_NP_ERR_CONNECT; } + out->read_event = CreateEventW(NULL, TRUE, FALSE, NULL); + out->write_event = CreateEventW(NULL, TRUE, FALSE, NULL); + if (!out->read_event || !out->write_event) { + CloseHandle(h); + nipc_np_close_session(out); + return NIPC_NP_ERR_CONNECT; + } + /* Perform handshake */ - nipc_np_error_t err = client_handshake(h, config, out); + err = client_handshake(h, config, &wait, out); if (err != NIPC_NP_OK) { CloseHandle(h); out->pipe = INVALID_HANDLE_VALUE; + nipc_np_close_session(out); return err; } return NIPC_NP_OK; } +nipc_np_error_t nipc_np_connect(const char *run_dir, const char *service_name, + const nipc_np_client_config_t *config, + nipc_np_session_t *out) { + return nipc_np_connect_timeout(run_dir, service_name, config, 0, NULL, out); +} + /* ------------------------------------------------------------------ */ /* Public API: close */ /* ------------------------------------------------------------------ */ @@ -936,12 +1504,10 @@ void nipc_np_close_session(nipc_np_session_t *session) { return; if (session->pipe != INVALID_HANDLE_VALUE && session->pipe != NULL) { - /* Only the server side should flush before disconnecting. - * Client-side flush on a broken session can block shutdown. */ - if (session->role == NIPC_NP_ROLE_SERVER) { - FlushFileBuffers(session->pipe); - DisconnectNamedPipe(session->pipe); - } + /* Close only. The peer still reads every message written before the + * close, then sees ERROR_BROKEN_PIPE. FlushFileBuffers would block on a + * peer that stopped reading, and neither CancelSynchronousIo nor + * DisconnectNamedPipe from another thread releases it. */ CloseHandle(session->pipe); } session->pipe = INVALID_HANDLE_VALUE; @@ -954,6 +1520,18 @@ void nipc_np_close_session(nipc_np_session_t *session) { session->inflight_ids = NULL; session->inflight_count = 0; session->inflight_capacity = 0; + + /* No I/O stays posted between calls, so the events can go. */ + if (session->read_event) + CloseHandle(session->read_event); + if (session->write_event) + CloseHandle(session->write_event); + session->read_event = NULL; + session->write_event = NULL; + free(session->pending_buf); + session->pending_buf = NULL; + session->pending_len = session->pending_head = session->pending_cap = 0; + session->progress_bound = 0; } void nipc_np_close_listener(nipc_np_listener_t *listener) { @@ -966,7 +1544,8 @@ void nipc_np_close_listener(nipc_np_listener_t *listener) { if (listener->pipe_name[0] != L'\0') { HANDLE wake = CreateFileW(listener->pipe_name, GENERIC_READ | GENERIC_WRITE, 0, - NULL, OPEN_EXISTING, 0, NULL); + NULL, OPEN_EXISTING, + SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION, NULL); if (wake != INVALID_HANDLE_VALUE) CloseHandle(wake); } @@ -979,8 +1558,9 @@ void nipc_np_close_listener(nipc_np_listener_t *listener) { /* Public API: send */ /* ------------------------------------------------------------------ */ -nipc_np_error_t nipc_np_send(nipc_np_session_t *session, nipc_header_t *hdr, - const void *payload, size_t payload_len) { +static nipc_np_error_t np_send(nipc_np_session_t *session, nipc_header_t *hdr, + const void *payload, size_t payload_len, + const receive_wait_t *wait) { if (!session || session->pipe == INVALID_HANDLE_VALUE) return NIPC_NP_ERR_BAD_PARAM; @@ -996,6 +1576,14 @@ nipc_np_error_t nipc_np_send(nipc_np_session_t *session, nipc_header_t *hdr, return NIPC_NP_ERR_LIMIT_EXCEEDED; } + /* One request in flight (every typed call) writes without draining; only a + * pipelining client can deadlock with a server blocked writing to it. */ + session->progress_bound = 0; + if (session->write_event && session->inflight_count > 1) + session->progress_bound = + pending_bound(session->max_response_payload_bytes, + session->packet_size, session->inflight_count); + uint32_t max_payload = 0; uint32_t max_batch = 0; if (session->role == NIPC_NP_ROLE_CLIENT && hdr->kind == NIPC_KIND_REQUEST) { @@ -1033,8 +1621,8 @@ nipc_np_error_t nipc_np_send(nipc_np_session_t *session, nipc_header_t *hdr, if (total_msg <= session->packet_size) { uint8_t hdr_buf[NIPC_HEADER_LEN]; nipc_header_encode(hdr, hdr_buf, sizeof(hdr_buf)); - nipc_np_error_t err = raw_send_msg(session->pipe, hdr_buf, NIPC_HEADER_LEN, - payload, payload_len); + nipc_np_error_t err = raw_send_msg(session, hdr_buf, NIPC_HEADER_LEN, + payload, payload_len, wait); if (err != NIPC_NP_OK && tracked) { if (err == NIPC_NP_ERR_SEND || err == NIPC_NP_ERR_DISCONNECTED) inflight_fail_all(session); @@ -1072,8 +1660,8 @@ nipc_np_error_t nipc_np_send(nipc_np_session_t *session, nipc_header_t *hdr, uint8_t hdr_buf[NIPC_HEADER_LEN]; nipc_header_encode(hdr, hdr_buf, sizeof(hdr_buf)); - nipc_np_error_t err = raw_send_msg(session->pipe, hdr_buf, NIPC_HEADER_LEN, - payload, first_chunk_payload); + nipc_np_error_t err = raw_send_msg(session, hdr_buf, NIPC_HEADER_LEN, + payload, first_chunk_payload, wait); if (err != NIPC_NP_OK) { if (tracked) { if (err == NIPC_NP_ERR_SEND || err == NIPC_NP_ERR_DISCONNECTED) @@ -1109,7 +1697,7 @@ nipc_np_error_t nipc_np_send(nipc_np_session_t *session, nipc_header_t *hdr, nipc_chunk_header_encode(&chk, chk_buf, sizeof(chk_buf)); err = - raw_send_msg(session->pipe, chk_buf, NIPC_HEADER_LEN, src, this_chunk); + raw_send_msg(session, chk_buf, NIPC_HEADER_LEN, src, this_chunk, wait); if (err != NIPC_NP_OK) { if (tracked) { if (err == NIPC_NP_ERR_SEND || err == NIPC_NP_ERR_DISCONNECTED) @@ -1127,6 +1715,21 @@ nipc_np_error_t nipc_np_send(nipc_np_session_t *session, nipc_header_t *hdr, return NIPC_NP_OK; } +nipc_np_error_t nipc_np_send(nipc_np_session_t *session, nipc_header_t *hdr, + const void *payload, size_t payload_len) { + return np_send(session, hdr, payload, payload_len, NULL); +} + +nipc_np_error_t nipc_np_send_timeout(nipc_np_session_t *session, + nipc_header_t *hdr, const void *payload, + size_t payload_len, uint32_t timeout_ms, + HANDLE abort_event) { + if (timeout_ms == 0 && !abort_event) + return np_send(session, hdr, payload, payload_len, NULL); + receive_wait_t wait = receive_wait_init(timeout_ms, abort_event); + return np_send(session, hdr, payload, payload_len, &wait); +} + /* ------------------------------------------------------------------ */ /* Public API: receive */ /* ------------------------------------------------------------------ */ @@ -1337,6 +1940,10 @@ nipc_np_error_t nipc_np_wait_readable(nipc_np_session_t *session, uint32_t timeout_ms, bool *readable_out) { if (!session || !readable_out || session->pipe == INVALID_HANDLE_VALUE) return NIPC_NP_ERR_BAD_PARAM; + if (nipc_np_session_has_pending(session)) { + *readable_out = true; + return NIPC_NP_OK; + } nipc_np_error_t err = wait_readable(session->pipe, timeout_ms, readable_out); if (err == NIPC_NP_ERR_DISCONNECTED || err == NIPC_NP_ERR_RECV) inflight_fail_all(session); diff --git a/src/libnetdata/netipc/src/transport/windows/netipc_win_security.c b/src/libnetdata/netipc/src/transport/windows/netipc_win_security.c new file mode 100644 index 00000000..f949c7dc --- /dev/null +++ b/src/libnetdata/netipc/src/transport/windows/netipc_win_security.c @@ -0,0 +1,106 @@ +/* + * netipc_win_security.c - Access policy shared by the Windows transports. + */ + +#if defined(_WIN32) || defined(__MSYS__) + +#include "netipc_win_security.h" +#include "netipc/netipc_named_pipe.h" + +#include +#include +#include +#include + +static PSID g_test_trusted_owner; + +void nipc_np_set_trusted_owner_for_tests(const char *sid) { + if (g_test_trusted_owner) + LocalFree(g_test_trusted_owner); + g_test_trusted_owner = NULL; + if (sid) + ConvertStringSidToSidA(sid, &g_test_trusted_owner); +} + +/* The process token user, malloc'd. */ +static TOKEN_USER *process_token_user(void) { + HANDLE token; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) + return NULL; + + DWORD len = 0; + TOKEN_USER *user = NULL; + if (!GetTokenInformation(token, TokenUser, NULL, 0, &len) && + GetLastError() == ERROR_INSUFFICIENT_BUFFER) { + user = malloc(len); + if (user && !GetTokenInformation(token, TokenUser, user, len, &len)) { + free(user); + user = NULL; + } + } + CloseHandle(token); + return user; +} + +bool nipc_win_security_attrs_init(SECURITY_ATTRIBUTES *sa) { + sa->nLength = sizeof(*sa); + sa->lpSecurityDescriptor = NULL; + sa->bInheritHandle = FALSE; + + TOKEN_USER *user = process_token_user(); + if (!user) + return false; + + char *sid = NULL; + bool ok = ConvertSidToStringSidA(user->User.Sid, &sid) != 0; + free(user); + if (!ok) + return false; + + /* Protected DACL: nothing is inherited, and no ACE for Everyone or + * Anonymous, so other accounts cannot even open an instance read-only. */ + char sddl[256]; + int n = snprintf(sddl, sizeof(sddl), + "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;%s)", sid); + LocalFree(sid); + if (n < 0 || (size_t)n >= sizeof(sddl)) { + SetLastError(ERROR_INSUFFICIENT_BUFFER); + return false; + } + + return ConvertStringSecurityDescriptorToSecurityDescriptorA( + sddl, SDDL_REVISION_1, &sa->lpSecurityDescriptor, NULL) != 0; +} + +void nipc_win_security_attrs_free(SECURITY_ATTRIBUTES *sa) { + if (sa->lpSecurityDescriptor) + LocalFree(sa->lpSecurityDescriptor); + sa->lpSecurityDescriptor = NULL; +} + +bool nipc_win_owner_trusted(HANDLE object) { + PSID owner = NULL; + PSECURITY_DESCRIPTOR sd = NULL; + if (GetSecurityInfo(object, SE_KERNEL_OBJECT, OWNER_SECURITY_INFORMATION, + &owner, NULL, NULL, NULL, &sd) != ERROR_SUCCESS) + return false; + + bool trusted; + if (g_test_trusted_owner) { + trusted = owner && EqualSid(owner, g_test_trusted_owner); + LocalFree(sd); + return trusted; + } + + trusted = owner && (IsWellKnownSid(owner, WinLocalSystemSid) || + IsWellKnownSid(owner, WinBuiltinAdministratorsSid)); + if (owner && !trusted) { + TOKEN_USER *user = process_token_user(); + trusted = user && EqualSid(owner, user->User.Sid); + free(user); + } + LocalFree(sd); + return trusted; +} + +#endif /* _WIN32 || __MSYS__ */ diff --git a/src/libnetdata/netipc/src/transport/windows/netipc_win_security.h b/src/libnetdata/netipc/src/transport/windows/netipc_win_security.h new file mode 100644 index 00000000..9ac5641a --- /dev/null +++ b/src/libnetdata/netipc/src/transport/windows/netipc_win_security.h @@ -0,0 +1,28 @@ +/* + * netipc_win_security.h - Access policy shared by the Windows transports. + * + * Every NetIPC pipe, SHM mapping and event grants access only to SYSTEM, + * Administrators and the account of the process that creates it. Clients + * accept a server only when the pipe owner is one of those same principals. + */ + +#ifndef NETIPC_WIN_SECURITY_H +#define NETIPC_WIN_SECURITY_H + +#if defined(_WIN32) || defined(__MSYS__) + +#include +#include + +/* Fill sa with the owner-only descriptor. Returns false with the Win32 error + * set on failure. Release with nipc_win_security_attrs_free(). */ +bool nipc_win_security_attrs_init(SECURITY_ATTRIBUTES *sa); +void nipc_win_security_attrs_free(SECURITY_ATTRIBUTES *sa); + +/* True when the object's owner is this process's account, SYSTEM or + * Administrators. */ +bool nipc_win_owner_trusted(HANDLE object); + +#endif /* _WIN32 || __MSYS__ */ + +#endif /* NETIPC_WIN_SECURITY_H */ diff --git a/src/libnetdata/netipc/src/transport/windows/netipc_win_shm.c b/src/libnetdata/netipc/src/transport/windows/netipc_win_shm.c index ded9985b..507b0c56 100644 --- a/src/libnetdata/netipc/src/transport/windows/netipc_win_shm.c +++ b/src/libnetdata/netipc/src/transport/windows/netipc_win_shm.c @@ -12,6 +12,7 @@ #include "netipc/netipc_win_shm.h" #include "netipc/netipc_named_pipe.h" /* nipc_fnv1a_64 */ +#include "netipc_win_security.h" #include #include @@ -119,6 +120,42 @@ static HANDLE test_create_event(LPSECURITY_ATTRIBUTES attrs, return CreateEventW(attrs, manual_reset, initial_state, name); } +/* Session objects take the same owner-only DACL as the pipe, so other + * accounts cannot open them. On failure the Win32 error is preserved. */ +static HANDLE create_secure_mapping(size_t size, LPCWSTR name) +{ + SECURITY_ATTRIBUTES sa; + if (!nipc_win_security_attrs_init(&sa)) + return NULL; + + /* Building the descriptor leaves stale errors; ERROR_ALREADY_EXISTS + * must come from the create alone. */ + SetLastError(ERROR_SUCCESS); + HANDLE h = test_create_file_mapping(INVALID_HANDLE_VALUE, /* page file */ + &sa, PAGE_READWRITE, + (DWORD)(size >> 32), + (DWORD)(size & 0xFFFFFFFF), name); + DWORD err = GetLastError(); + nipc_win_security_attrs_free(&sa); + SetLastError(err); + return h; +} + +/* Auto-reset event with the owner-only DACL. */ +static HANDLE create_secure_event(LPCWSTR name) +{ + SECURITY_ATTRIBUTES sa; + if (!nipc_win_security_attrs_init(&sa)) + return NULL; + + SetLastError(ERROR_SUCCESS); + HANDLE h = test_create_event(&sa, FALSE, FALSE, name); + DWORD err = GetLastError(); + nipc_win_security_attrs_free(&sa); + SetLastError(err); + return h; +} + static HANDLE test_open_event(DWORD access, BOOL inherit, LPCWSTR name) @@ -345,14 +382,7 @@ nipc_win_shm_error_t nipc_win_shm_server_create( size_t region_size = (size_t)resp_off + resp_capacity; /* Create file mapping backed by page file */ - SetLastError(ERROR_SUCCESS); - HANDLE mapping = test_create_file_mapping( - INVALID_HANDLE_VALUE, /* page file backed */ - NULL, /* default security */ - PAGE_READWRITE, - (DWORD)(region_size >> 32), - (DWORD)(region_size & 0xFFFFFFFF), - mapping_name); + HANDLE mapping = create_secure_mapping(region_size, mapping_name); if (!mapping) return NIPC_WIN_SHM_ERR_CREATE_MAPPING; if (GetLastError() == ERROR_ALREADY_EXISTS) { @@ -404,9 +434,7 @@ nipc_win_shm_error_t nipc_win_shm_server_create( return NIPC_WIN_SHM_ERR_BAD_PARAM; } - /* Auto-reset events (bManualReset = FALSE) */ - SetLastError(ERROR_SUCCESS); - req_event = test_create_event(NULL, FALSE, FALSE, req_event_name); + req_event = create_secure_event(req_event_name); if (!req_event) { UnmapViewOfFile(base); CloseHandle(mapping); @@ -419,8 +447,7 @@ nipc_win_shm_error_t nipc_win_shm_server_create( return NIPC_WIN_SHM_ERR_ADDR_IN_USE; } - SetLastError(ERROR_SUCCESS); - resp_event = test_create_event(NULL, FALSE, FALSE, resp_event_name); + resp_event = create_secure_event(resp_event_name); if (!resp_event) { CloseHandle(req_event); UnmapViewOfFile(base); diff --git a/tests/fixtures/c/interop_cache.c b/tests/fixtures/c/interop_cache.c index 429dcfeb..b7df9044 100644 --- a/tests/fixtures/c/interop_cache.c +++ b/tests/fixtures/c/interop_cache.c @@ -130,14 +130,7 @@ static int run_client(const char *run_dir, const char *service) nipc_cgroups_cache_init(&cache, run_dir, service, &ccfg); /* Refresh to populate cache */ - bool updated = false; - for (int i = 0; i < 200; i++) { - if (nipc_cgroups_cache_refresh(&cache)) { - updated = true; - break; - } - usleep(10000); - } + bool updated = nipc_cgroups_cache_refresh(&cache); if (!updated || !nipc_cgroups_cache_ready(&cache)) { fprintf(stderr, "client: cache not ready after refresh\n"); nipc_cgroups_cache_close(&cache); diff --git a/tests/fixtures/c/interop_cache_win.c b/tests/fixtures/c/interop_cache_win.c index a822da3f..721ce153 100644 --- a/tests/fixtures/c/interop_cache_win.c +++ b/tests/fixtures/c/interop_cache_win.c @@ -13,6 +13,7 @@ #if defined(_WIN32) || defined(__MSYS__) #include "netipc/netipc_service.h" +#include "test_sync.h" #include "netipc/netipc_protocol.h" #include "netipc/netipc_named_pipe.h" #include "netipc/netipc_win_shm.h" @@ -142,7 +143,7 @@ static int run_server(const char *run_dir, const char *service) return 1; } - HANDLE server_thread = CreateThread(NULL, 0, server_thread_main, &state, 0, NULL); + HANDLE server_thread = test_create_thread(NULL, 0, server_thread_main, &state, 0, NULL); if (!server_thread) { fprintf(stderr, "server thread create failed: %lu\n", (unsigned long)GetLastError()); nipc_server_destroy(&state.server); @@ -200,16 +201,12 @@ static int run_client(const char *run_dir, const char *service) nipc_cgroups_cache_t cache; nipc_cgroups_cache_init(&cache, run_dir, service, &ccfg); - bool updated = false; - for (int i = 0; i < 200; i++) { - if (nipc_cgroups_cache_refresh(&cache)) { - updated = true; - break; - } - Sleep(10); - } + bool updated = nipc_cgroups_cache_refresh(&cache); if (!updated || !nipc_cgroups_cache_ready(&cache)) { - fprintf(stderr, "client: cache not ready after refresh\n"); + nipc_cgroups_cache_status_t status; + nipc_cgroups_cache_status(&cache, &status); + fprintf(stderr, "client: cache not ready after refresh (connection %d, failures %u)\n", + (int)status.connection_state, status.refresh_failure_count); nipc_cgroups_cache_close(&cache); printf("FAIL\n"); return 1; diff --git a/tests/fixtures/c/interop_named_pipe.c b/tests/fixtures/c/interop_named_pipe.c index 05a0c8e2..95498a6e 100644 --- a/tests/fixtures/c/interop_named_pipe.c +++ b/tests/fixtures/c/interop_named_pipe.c @@ -7,6 +7,13 @@ * * interop_named_pipe client * Connects, sends 1 message, verifies echo, exits 0 on success. + * + * interop_named_pipe pipeline-server + * Like server, but echoes PIPELINE_REQUESTS messages one at a time. + * + * interop_named_pipe pipeline-client + * Sends PIPELINE_REQUESTS messages before reading any response (more + * than the pipe buffers hold), then verifies the echoes in order. */ #if defined(_WIN32) || defined(__MSYS__) @@ -20,6 +27,8 @@ #include #define AUTH_TOKEN 0xDEADBEEFCAFEBABEull +#define PIPELINE_PAYLOAD (16u * 1024u) +#define PIPELINE_REQUESTS 32u /* 512 KiB each way, 8x the 64 KiB buffers */ static nipc_np_server_config_t server_config(void) { @@ -175,10 +184,115 @@ static int run_client(const char *run_dir, const char *service) return ok ? 0 : 1; } +static void pipeline_fill(uint8_t *p, uint64_t id) +{ + for (size_t i = 0; i < PIPELINE_PAYLOAD; i++) + p[i] = (uint8_t)(id + i); +} + +static int run_pipeline_server(const char *run_dir, const char *service) +{ + nipc_np_server_config_t cfg = server_config(); + nipc_np_listener_t listener; + + nipc_np_error_t err = nipc_np_listen(run_dir, service, &cfg, &listener); + if (err != NIPC_NP_OK) { + fprintf(stderr, "server: listen failed: %d\n", err); + return 1; + } + + printf("READY\n"); + fflush(stdout); + + nipc_np_session_t session; + err = nipc_np_accept(&listener, 1, &session); + if (err != NIPC_NP_OK) { + fprintf(stderr, "server: accept failed: %d\n", err); + nipc_np_close_listener(&listener); + return 1; + } + + static uint8_t buf[65536 + 64]; + for (uint32_t i = 0; i < PIPELINE_REQUESTS && err == NIPC_NP_OK; i++) { + nipc_header_t hdr; + const void *payload; + size_t payload_len; + err = nipc_np_receive(&session, buf, sizeof(buf), &hdr, &payload, &payload_len); + if (err != NIPC_NP_OK) { + fprintf(stderr, "server: receive %u failed: %d\n", i, err); + break; + } + nipc_header_t resp = hdr; + resp.kind = NIPC_KIND_RESPONSE; + resp.transport_status = NIPC_STATUS_OK; + err = nipc_np_send(&session, &resp, payload, payload_len); + if (err != NIPC_NP_OK) + fprintf(stderr, "server: send %u failed: %d\n", i, err); + } + + nipc_np_close_session(&session); + nipc_np_close_listener(&listener); + return err == NIPC_NP_OK ? 0 : 1; +} + +static int run_pipeline_client(const char *run_dir, const char *service) +{ + nipc_np_client_config_t cfg = client_config(); + nipc_np_session_t session; + + nipc_np_error_t err = nipc_np_connect(run_dir, service, &cfg, &session); + if (err != NIPC_NP_OK) { + fprintf(stderr, "client: connect failed: %d\n", err); + return 1; + } + + static uint8_t payload[PIPELINE_PAYLOAD]; + for (uint64_t id = 1; id <= PIPELINE_REQUESTS; id++) { + nipc_header_t hdr = { + .kind = NIPC_KIND_REQUEST, + .code = NIPC_METHOD_INCREMENT, + .item_count = 1, + .message_id = id, + }; + pipeline_fill(payload, id); + err = nipc_np_send(&session, &hdr, payload, sizeof(payload)); + if (err != NIPC_NP_OK) { + fprintf(stderr, "client: send %llu failed: %d\n", (unsigned long long)id, err); + nipc_np_close_session(&session); + return 1; + } + } + + static uint8_t rbuf[65536 + 64]; + int ok = 1; + for (uint64_t id = 1; id <= PIPELINE_REQUESTS && ok; id++) { + nipc_header_t rhdr; + const void *rpayload; + size_t rpayload_len; + err = nipc_np_receive(&session, rbuf, sizeof(rbuf), &rhdr, &rpayload, &rpayload_len); + if (err != NIPC_NP_OK) { + fprintf(stderr, "client: receive %llu failed: %d\n", (unsigned long long)id, err); + ok = 0; + break; + } + pipeline_fill(payload, id); + if (rhdr.kind != NIPC_KIND_RESPONSE || rhdr.message_id != id || + rpayload_len != sizeof(payload) || memcmp(rpayload, payload, sizeof(payload)) != 0) { + fprintf(stderr, "client: response %llu out of order or corrupted\n", + (unsigned long long)id); + ok = 0; + } + } + + nipc_np_close_session(&session); + printf(ok ? "PASS\n" : "FAIL\n"); + return ok ? 0 : 1; +} + int main(int argc, char **argv) { if (argc != 4) { - fprintf(stderr, "Usage: %s \n", argv[0]); + fprintf(stderr, "Usage: %s \n", argv[0]); return 1; } @@ -190,6 +304,10 @@ int main(int argc, char **argv) return run_server(run_dir, service); else if (strcmp(mode, "client") == 0) return run_client(run_dir, service); + else if (strcmp(mode, "pipeline-server") == 0) + return run_pipeline_server(run_dir, service); + else if (strcmp(mode, "pipeline-client") == 0) + return run_pipeline_client(run_dir, service); else { fprintf(stderr, "Unknown mode: %s\n", mode); return 1; diff --git a/tests/fixtures/c/interop_service.c b/tests/fixtures/c/interop_service.c index 5419fa0f..f5cd5342 100644 --- a/tests/fixtures/c/interop_service.c +++ b/tests/fixtures/c/interop_service.c @@ -33,10 +33,26 @@ /* Snapshot handler */ /* ------------------------------------------------------------------ */ +/* NIPC_HANDLER_DELAY_MS makes every snapshot handler slow, so interop runs + * prove that clients wait past their internal 100 ms abort-poll slice. */ +static void handler_delay(void) +{ + const char *env = getenv("NIPC_HANDLER_DELAY_MS"); + if (!env || !*env) + return; + + char *end = NULL; + unsigned long ms = strtoul(env, &end, 10); + if (end && *end == '\0' && ms > 0 && ms <= 10000ul) + usleep((useconds_t)(ms * 1000ul)); +} + static nipc_error_t handle_cgroups(const uint8_t *request_payload, size_t request_len, uint8_t *response_buf, size_t response_buf_size, size_t *response_len_out) { + handler_delay(); + nipc_cgroups_req_t req; nipc_error_t err = nipc_cgroups_req_decode(request_payload, request_len, &req); if (err != NIPC_OK) @@ -412,16 +428,11 @@ static int run_cgroups_mixed_server(const char *run_dir, const char *service) return 0; } +/* The server printed READY after binding, so one refresh connects. */ static int wait_client_ready(nipc_client_ctx_t *client) { - for (int i = 0; i < 200; i++) { - nipc_client_refresh(client); - if (nipc_client_ready(client)) - return 1; - usleep(10000); - } - - return 0; + nipc_client_refresh(client); + return nipc_client_ready(client); } static int run_apps_client(const char *run_dir, const char *service) @@ -711,12 +722,7 @@ static int run_client(const char *run_dir, const char *service) nipc_client_ctx_t client; nipc_client_init(&client, run_dir, service, &ccfg); - for (int i = 0; i < 200; i++) { - nipc_client_refresh(&client); - if (nipc_client_ready(&client)) - break; - usleep(10000); - } + nipc_client_refresh(&client); if (!nipc_client_ready(&client)) { fprintf(stderr, "client: not ready\n"); diff --git a/tests/fixtures/c/interop_service_win.c b/tests/fixtures/c/interop_service_win.c index 2170340a..02105ea7 100644 --- a/tests/fixtures/c/interop_service_win.c +++ b/tests/fixtures/c/interop_service_win.c @@ -13,6 +13,7 @@ #if defined(_WIN32) || defined(__MSYS__) #include "netipc/netipc_service.h" +#include "test_sync.h" #include "netipc/netipc_protocol.h" #include "netipc/netipc_named_pipe.h" #include "netipc/netipc_win_shm.h" @@ -35,10 +36,26 @@ typedef struct { volatile LONG request_count; } test_server_state_t; +/* NIPC_HANDLER_DELAY_MS makes every snapshot handler slow, so interop runs + * prove that clients wait past their internal 100 ms abort-poll slice. */ +static void handler_delay(void) +{ + const char *env = getenv("NIPC_HANDLER_DELAY_MS"); + if (!env || !*env) + return; + + char *end = NULL; + unsigned long ms = strtoul(env, &end, 10); + if (end && *end == '\0' && ms > 0 && ms <= 10000ul) + Sleep((DWORD)ms); +} + static nipc_error_t handle_cgroups(const uint8_t *request_payload, size_t request_len, uint8_t *response_buf, size_t response_buf_size, size_t *response_len_out) { + handler_delay(); + nipc_cgroups_req_t req; nipc_error_t err = nipc_cgroups_req_decode(request_payload, request_len, &req); if (err != NIPC_OK) @@ -351,7 +368,7 @@ static int run_server(const char *run_dir, const char *service) return 1; } - HANDLE server_thread = CreateThread(NULL, 0, server_thread_main, &state, 0, NULL); + HANDLE server_thread = test_create_thread(NULL, 0, server_thread_main, &state, 0, NULL); if (!server_thread) { fprintf(stderr, "server thread create failed: %lu\n", (unsigned long)GetLastError()); nipc_server_destroy(&state.server); @@ -493,16 +510,11 @@ static int run_cgroups_mixed_server(const char *run_dir, const char *service) /* Client mode */ /* ------------------------------------------------------------------ */ +/* The server printed READY after binding, so one refresh connects. */ static int wait_client_ready(nipc_client_ctx_t *client) { - for (int i = 0; i < 200; i++) { - nipc_client_refresh(client); - if (nipc_client_ready(client)) - return 1; - Sleep(10); - } - - return 0; + nipc_client_refresh(client); + return nipc_client_ready(client); } static int run_apps_client(const char *run_dir, const char *service) @@ -752,12 +764,7 @@ static int run_client(const char *run_dir, const char *service) nipc_client_ctx_t client; nipc_client_init(&client, run_dir, service, &ccfg); - for (int i = 0; i < 200; i++) { - nipc_client_refresh(&client); - if (nipc_client_ready(&client)) - break; - Sleep(10); - } + nipc_client_refresh(&client); if (!nipc_client_ready(&client)) { fprintf(stderr, "client: not ready\n"); diff --git a/tests/fixtures/c/interop_shm.c b/tests/fixtures/c/interop_shm.c index 95fe76ac..087a55f0 100644 --- a/tests/fixtures/c/interop_shm.c +++ b/tests/fixtures/c/interop_shm.c @@ -81,19 +81,8 @@ static int run_client(const char *run_dir, const char *service) { nipc_shm_ctx_t shm; - /* Retry attach -- server may not be fully ready yet. */ - nipc_shm_error_t err = NIPC_SHM_ERR_NOT_READY; - for (int i = 0; i < 500; i++) { - err = nipc_shm_client_attach(run_dir, service, 1, &shm); - if (err == NIPC_SHM_OK) - break; - if (err == NIPC_SHM_ERR_NOT_READY || - err == NIPC_SHM_ERR_OPEN || - err == NIPC_SHM_ERR_BAD_MAGIC) - usleep(10000); - else - break; - } + /* The server prints READY only after creating the region. */ + nipc_shm_error_t err = nipc_shm_client_attach(run_dir, service, 1, &shm); if (err != NIPC_SHM_OK) { fprintf(stderr, "client: attach failed: %d\n", err); return 1; diff --git a/tests/fixtures/c/interop_win_shm.c b/tests/fixtures/c/interop_win_shm.c index 56188ef3..2f6a5eb6 100644 --- a/tests/fixtures/c/interop_win_shm.c +++ b/tests/fixtures/c/interop_win_shm.c @@ -82,19 +82,9 @@ static int run_client(const char *run_dir, const char *service) { nipc_win_shm_ctx_t ctx; - /* Retry attach -- server may not be fully ready yet. */ - nipc_win_shm_error_t err = NIPC_WIN_SHM_ERR_OPEN_MAPPING; - for (int i = 0; i < 500; i++) { - err = nipc_win_shm_client_attach(run_dir, service, AUTH_TOKEN, - 1, PROFILE, &ctx); - if (err == NIPC_WIN_SHM_OK) - break; - if (err == NIPC_WIN_SHM_ERR_OPEN_MAPPING || - err == NIPC_WIN_SHM_ERR_BAD_MAGIC) - Sleep(10); - else - break; - } + /* The server prints READY only after creating the region. */ + nipc_win_shm_error_t err = nipc_win_shm_client_attach(run_dir, service, AUTH_TOKEN, + 1, PROFILE, &ctx); if (err != NIPC_WIN_SHM_OK) { fprintf(stderr, "client: attach failed: %d\n", err); return 1; diff --git a/tests/fixtures/c/shm_fallocate_wrap.c b/tests/fixtures/c/shm_fallocate_wrap.c index 28e96495..fdcba509 100644 --- a/tests/fixtures/c/shm_fallocate_wrap.c +++ b/tests/fixtures/c/shm_fallocate_wrap.c @@ -2,12 +2,14 @@ #define _GNU_SOURCE #endif -/* Test-only linker wrapper for deterministic Linux fallocate failures. */ +/* Test-only linker wrapper for deterministic SHM allocation failures: + * Linux fallocate, FreeBSD posix_fallocate. */ #include "netipc/netipc_shm.h" #include #include +#include #include #include @@ -26,17 +28,51 @@ void nipc_shm_test_fault_clear(void) __atomic_store_n(&g_fault_site, NIPC_SHM_TEST_FAULT_NONE, __ATOMIC_RELEASE); } -int __real_fallocate(int fd, int mode, off_t offset, off_t length); - -int __wrap_fallocate(int fd, int mode, off_t offset, off_t length) +static bool take_allocate_fault(void) { uint32_t expected = NIPC_SHM_TEST_FAULT_ALLOCATE; if (__atomic_compare_exchange_n(&g_fault_site, &expected, NIPC_SHM_TEST_FAULT_NONE, false, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE)) { errno = (int)__atomic_load_n(&g_fault_error, __ATOMIC_RELAXED); - return -1; + return true; } + return false; +} + +#if defined(__FreeBSD__) +int __real_posix_fallocate(int fd, off_t offset, off_t length); + +/* posix_fallocate returns the error instead of setting errno. */ +int __wrap_posix_fallocate(int fd, off_t offset, off_t length) +{ + if (take_allocate_fault()) + return errno; + + return __real_posix_fallocate(fd, offset, length); +} +#else +int __real_fallocate(int fd, int mode, off_t offset, off_t length); + +int __wrap_fallocate(int fd, int mode, off_t offset, off_t length) +{ + if (take_allocate_fault()) + return -1; return __real_fallocate(fd, mode, offset, length); } + +#if defined(__GLIBC__) +/* The library builds its SHM code with _FILE_OFFSET_BITS=64, so on 32-bit + * glibc it references fallocate64 whatever this file's own layout is. */ +int __real_fallocate64(int fd, int mode, off64_t offset, off64_t length); + +int __wrap_fallocate64(int fd, int mode, off64_t offset, off64_t length) +{ + if (take_allocate_fault()) + return -1; + + return __real_fallocate64(fd, mode, offset, length); +} +#endif +#endif /* __FreeBSD__ */ diff --git a/tests/fixtures/c/test_cache.c b/tests/fixtures/c/test_cache.c index 752526c2..29bb22ec 100644 --- a/tests/fixtures/c/test_cache.c +++ b/tests/fixtures/c/test_cache.c @@ -10,6 +10,7 @@ #include "netipc/netipc_service.h" #include "netipc/netipc_protocol.h" #include "netipc/netipc_uds.h" +#include "test_sync.h" #include #include @@ -198,14 +199,15 @@ static void *server_thread_fn(void *arg) if (err != NIPC_OK) { fprintf(stderr, "server init failed: %d\n", err); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_server_use_test_timings(&ctx->server, NULL); + test_flag_set(&ctx->ready); nipc_server_run(&ctx->server); nipc_server_destroy(&ctx->server); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -223,10 +225,7 @@ static void start_server(server_thread_ctx_t *sctx, const char *service, pthread_create(tid, NULL, server_thread_fn, sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx->ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx->done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx->ready, &sctx->done); } static void stop_server(server_thread_ctx_t *sctx, pthread_t tid) @@ -349,7 +348,6 @@ static void test_refresh_failure_preserves(void) /* Kill server */ stop_server(&sctx, tid); cleanup_all(svc); - usleep(50000); /* Refresh fails, old cache preserved */ bool updated = nipc_cgroups_cache_refresh(&cache); @@ -394,7 +392,6 @@ static void test_reconnect_rebuilds(void) /* Kill and restart */ stop_server(&sctx1, tid1); cleanup_all(svc); - usleep(50000); server_thread_ctx_t sctx2; pthread_t tid2; diff --git a/tests/fixtures/c/test_chaos.c b/tests/fixtures/c/test_chaos.c index f099722d..ebf34a0f 100644 --- a/tests/fixtures/c/test_chaos.c +++ b/tests/fixtures/c/test_chaos.c @@ -10,7 +10,7 @@ * 3. Valid header with wrong magic * 4. Valid header with payload_len exceeding negotiated limit * 5. Partial chunk (start chunking, don't send all chunks) - * 6. Connect, send nothing, disconnect after 100ms + * 6. Connect, send nothing, disconnect once the server waits for HELLO * 7. Connect, send half a header, disconnect * 8. SHM chaos: random bytes in request area * @@ -24,6 +24,8 @@ #include "netipc/netipc_protocol.h" #include "netipc/netipc_uds.h" #include "netipc/netipc_shm.h" +#include "test_sync.h" +#include "uds_test_socket.h" #include #include @@ -137,6 +139,7 @@ static nipc_error_t test_cgroups_handler(void *user, typedef struct { const char *service; nipc_managed_server_t server; + test_server_events_t events; int ready; /* __atomic */ int done; /* __atomic */ } chaos_server_ctx_t; @@ -163,14 +166,15 @@ static void *chaos_server_fn(void *arg) if (err != NIPC_OK) { fprintf(stderr, "chaos server init failed: %d\n", err); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_server_use_test_timings(&ctx->server, &ctx->events); + test_flag_set(&ctx->ready); nipc_server_run(&ctx->server); nipc_server_destroy(&ctx->server); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -183,11 +187,7 @@ static void start_chaos_server(chaos_server_ctx_t *sctx, const char *service, __atomic_store_n(&sctx->done, 0, __ATOMIC_RELAXED); pthread_create(tid, NULL, chaos_server_fn, sctx); - - for (int i = 0; i < 4000 - && !__atomic_load_n(&sctx->ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx->done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx->ready, &sctx->done); } static void stop_chaos_server(chaos_server_ctx_t *sctx, pthread_t tid) @@ -212,18 +212,8 @@ static bool verify_server_alive(const char *service) nipc_client_ctx_t client; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - /* Try connecting with retries (server may be processing disconnect) */ - bool connected = false; - for (int r = 0; r < 200; r++) { - nipc_client_refresh(&client); - if (nipc_client_ready(&client)) { - connected = true; - break; - } - usleep(5000); - } - - if (!connected) { + nipc_client_refresh(&client); + if (!nipc_client_ready(&client)) { nipc_client_close(&client); return false; } @@ -271,7 +261,7 @@ static int raw_connect(const char *service) char path[256]; snprintf(path, sizeof(path), "%s/%s.sock", TEST_RUN_DIR, service); - int fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); + int fd = socket(AF_UNIX, TEST_UDS_SOCK_TYPE, 0); if (fd < 0) return -1; struct sockaddr_un addr; @@ -359,10 +349,12 @@ static void test_truncated_payload(void) start_chaos_server(&sctx, svc, &tid); check("server started", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); + int attacks = 0; for (int i = 0; i < 10; i++) { nipc_uds_session_t session; int fd = chaos_connect(svc, &session); - if (fd < 0) { usleep(10000); continue; } + if (fd < 0) + continue; /* Build a valid header claiming 1000 bytes of payload */ nipc_header_t hdr = {0}; @@ -387,10 +379,12 @@ static void test_truncated_payload(void) session.fd = -1; free(session.recv_buf); - usleep(5000); + /* The worker must notice the attack and exit. */ + if (test_server_wait_session_exits(&sctx.events, 1)) + attacks++; } - usleep(100000); + check("all 10 attacked sessions exited", attacks == 10); bool alive = verify_server_alive(svc); check("server alive after truncated payload attacks", alive); @@ -413,10 +407,12 @@ static void test_wrong_magic(void) start_chaos_server(&sctx, svc, &tid); check("server started", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); + int attacks = 0; for (int i = 0; i < 10; i++) { nipc_uds_session_t session; int fd = chaos_connect(svc, &session); - if (fd < 0) { usleep(10000); continue; } + if (fd < 0) + continue; /* Build a header with bad magic */ nipc_header_t hdr = {0}; @@ -444,10 +440,12 @@ static void test_wrong_magic(void) session.fd = -1; free(session.recv_buf); - usleep(5000); + /* The worker must notice the attack and exit. */ + if (test_server_wait_session_exits(&sctx.events, 1)) + attacks++; } - usleep(100000); + check("all 10 attacked sessions exited", attacks == 10); bool alive = verify_server_alive(svc); check("server alive after wrong-magic attacks", alive); @@ -470,10 +468,12 @@ static void test_payload_exceeds_limit(void) start_chaos_server(&sctx, svc, &tid); check("server started", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); + int attacks = 0; for (int i = 0; i < 10; i++) { nipc_uds_session_t session; int fd = chaos_connect(svc, &session); - if (fd < 0) { usleep(10000); continue; } + if (fd < 0) + continue; /* Claim a huge payload_len (exceeds negotiated max) */ nipc_header_t hdr = {0}; @@ -494,10 +494,12 @@ static void test_payload_exceeds_limit(void) session.fd = -1; free(session.recv_buf); - usleep(5000); + /* The worker must notice the attack and exit. */ + if (test_server_wait_session_exits(&sctx.events, 1)) + attacks++; } - usleep(100000); + check("all 10 attacked sessions exited", attacks == 10); bool alive = verify_server_alive(svc); check("server alive after limit-exceeding attacks", alive); @@ -520,10 +522,12 @@ static void test_partial_chunk(void) start_chaos_server(&sctx, svc, &tid); check("server started", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); + int attacks = 0; for (int i = 0; i < 10; i++) { nipc_uds_session_t session; int fd = chaos_connect(svc, &session); - if (fd < 0) { usleep(10000); continue; } + if (fd < 0) + continue; /* Send chunk 0 of 3 (then disconnect without sending chunks 1,2) */ nipc_chunk_header_t chk = {0}; @@ -549,10 +553,12 @@ static void test_partial_chunk(void) session.fd = -1; free(session.recv_buf); - usleep(5000); + /* The worker must notice the attack and exit. */ + if (test_server_wait_session_exits(&sctx.events, 1)) + attacks++; } - usleep(100000); + check("all 10 attacked sessions exited", attacks == 10); bool alive = verify_server_alive(svc); check("server alive after partial-chunk attacks", alive); @@ -579,21 +585,20 @@ static void test_silent_connect_disconnect(void) for (int i = 0; i < 20; i++) { /* Raw connect (no handshake) */ int fd = raw_connect(svc); - if (fd < 0) { - usleep(10000); + if (fd < 0) continue; - } - connected++; - /* Wait 100ms, then disconnect without sending anything */ - usleep(100000); + /* Disconnect without sending anything once a worker waits for + * HELLO, then wait for that worker to exit. */ + bool waiting = test_server_wait_handshakes(&sctx.events, 1); close(fd); + if (waiting && test_server_wait_session_exits(&sctx.events, 1)) + connected++; } printf(" %d silent connects\n", connected); - check("silent connects succeeded", connected >= 10); + check("every silent connect was accepted and released", connected == 20); - usleep(200000); bool alive = verify_server_alive(svc); check("server alive after silent connect/disconnect", alive); @@ -620,25 +625,21 @@ static void test_half_header(void) for (int i = 0; i < 20; i++) { /* Raw connect (no handshake) */ int fd = raw_connect(svc); - if (fd < 0) { - usleep(10000); + if (fd < 0) continue; - } /* Send only 12 bytes of a 32-byte header */ uint8_t partial[12]; fill_random(partial, sizeof(partial)); ssize_t n = send(fd, partial, sizeof(partial), MSG_NOSIGNAL); - if (n > 0) sent_count++; - - usleep(50000); close(fd); + if (n > 0 && test_server_wait_session_exits(&sctx.events, 1)) + sent_count++; } printf(" %d half-header sends\n", sent_count); - check("half-header sends succeeded", sent_count >= 10); + check("every half-header session was released", sent_count == 20); - usleep(200000); bool alive = verify_server_alive(svc); check("server alive after half-header attacks", alive); @@ -677,17 +678,8 @@ static void test_shm_chaos(void) nipc_client_init(&client, TEST_RUN_DIR, svc, &ccfg); /* Connect and do one normal call to ensure SHM is set up */ - bool connected = false; - for (int r = 0; r < 200; r++) { - nipc_client_refresh(&client); - if (nipc_client_ready(&client)) { - connected = true; - break; - } - usleep(5000); - } - - if (connected) { + nipc_client_refresh(&client); + if (nipc_client_ready(&client)) { nipc_cgroups_resp_view_t view; nipc_error_t err = nipc_client_call_cgroups_snapshot(&client, &view); check("initial SHM call succeeded", err == NIPC_OK); @@ -893,24 +885,32 @@ static void test_rapid_chaos_mix(void) /* Main */ /* ------------------------------------------------------------------ */ -int main(void) +int main(int argc, char **argv) { + /* Randomized attacks are not correctness tests: they run only with + * --extended, from tests/run-extended-stress.sh. */ + bool extended = argc > 1 && strcmp(argv[1], "--extended") == 0; + signal(SIGPIPE, SIG_IGN); srand((unsigned int)time(NULL)); ensure_run_dir(); setbuf(stdout, NULL); - printf("=== Transport-Level Chaos Tests (Phase H9) ===\n\n"); - - test_random_bytes_after_handshake(); printf("\n"); - test_truncated_payload(); printf("\n"); - test_wrong_magic(); printf("\n"); - test_payload_exceeds_limit(); printf("\n"); - test_partial_chunk(); printf("\n"); - test_silent_connect_disconnect(); printf("\n"); - test_half_header(); printf("\n"); - test_shm_chaos(); printf("\n"); - test_rapid_chaos_mix(); printf("\n"); + printf("=== Transport-Level Chaos Tests (Phase H9)%s ===\n\n", + extended ? " - randomized" : ""); + + if (extended) { + test_random_bytes_after_handshake(); printf("\n"); + test_shm_chaos(); printf("\n"); + test_rapid_chaos_mix(); printf("\n"); + } else { + test_truncated_payload(); printf("\n"); + test_wrong_magic(); printf("\n"); + test_payload_exceeds_limit(); printf("\n"); + test_partial_chunk(); printf("\n"); + test_silent_connect_disconnect(); printf("\n"); + test_half_header(); printf("\n"); + } printf("=== Results: %d passed, %d failed ===\n", g_pass, g_fail); return g_fail == 0 ? 0 : 1; diff --git a/tests/fixtures/c/test_hardening.c b/tests/fixtures/c/test_hardening.c index e1ef551f..767846d1 100644 --- a/tests/fixtures/c/test_hardening.c +++ b/tests/fixtures/c/test_hardening.c @@ -13,6 +13,7 @@ #include "netipc/netipc_protocol.h" #include "netipc/netipc_service.h" #include "netipc/netipc_uds.h" +#include "test_sync.h" #include #include @@ -184,14 +185,15 @@ static void *server_thread_fn(void *arg) if (err != NIPC_OK) { fprintf(stderr, "server init failed for %s: %d\n", ctx->service, err); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_server_use_test_timings(&ctx->server, NULL); + test_flag_set(&ctx->ready); nipc_server_run(&ctx->server); nipc_server_destroy(&ctx->server); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -212,13 +214,7 @@ static void start_typed_server(server_thread_ctx_t *ctx, pthread_create(tid, NULL, server_thread_fn, ctx); - for (int i = 0; - i < 2000 && - !__atomic_load_n(&ctx->ready, __ATOMIC_ACQUIRE) && - !__atomic_load_n(&ctx->done, __ATOMIC_ACQUIRE); - i++) { - usleep(500); - } + (void)test_flag_wait_any(&ctx->ready, &ctx->done); } static void start_typed_server_with_service_handler(server_thread_ctx_t *ctx, @@ -239,13 +235,7 @@ static void start_typed_server_with_service_handler(server_thread_ctx_t *ctx, pthread_create(tid, NULL, server_thread_fn, ctx); - for (int i = 0; - i < 2000 && - !__atomic_load_n(&ctx->ready, __ATOMIC_ACQUIRE) && - !__atomic_load_n(&ctx->done, __ATOMIC_ACQUIRE); - i++) { - usleep(500); - } + (void)test_flag_wait_any(&ctx->ready, &ctx->done); } static void start_raw_server(server_thread_ctx_t *ctx, @@ -268,13 +258,7 @@ static void start_raw_server(server_thread_ctx_t *ctx, pthread_create(tid, NULL, server_thread_fn, ctx); - for (int i = 0; - i < 2000 && - !__atomic_load_n(&ctx->ready, __ATOMIC_ACQUIRE) && - !__atomic_load_n(&ctx->done, __ATOMIC_ACQUIRE); - i++) { - usleep(500); - } + (void)test_flag_wait_any(&ctx->ready, &ctx->done); } static void stop_server(server_thread_ctx_t *ctx, pthread_t tid) @@ -357,8 +341,6 @@ static void run_bad_header_session_test(const char *service, snprintf(msg, sizeof(msg), "%s: send malformed header", prefix); check(msg, raw_send == (ssize_t)sizeof(bad_hdr)); - usleep(200000); - memset(&good_hdr, 0, sizeof(good_hdr)); good_hdr.kind = NIPC_KIND_REQUEST; good_hdr.code = NIPC_METHOD_CGROUPS_SNAPSHOT; @@ -553,7 +535,7 @@ static void test_typed_server_init_propagates_raw_init_error(void) static void test_internal_session_table_growth(void) { - printf("--- Internal session table growth under forced small capacity ---\n"); + printf("--- Internal session table at its smallest valid capacity ---\n"); const char *svc = "hard_growth"; nipc_managed_server_t server; @@ -567,24 +549,28 @@ static void test_internal_session_table_growth(void) cleanup_all(svc); - err = nipc_server_init_typed(&server, TEST_RUN_DIR, svc, &scfg, 4, + const int workers = 4; + err = nipc_server_init_typed(&server, TEST_RUN_DIR, svc, &scfg, workers, &g_service_handler); check("growth: server init", err == NIPC_OK); if (err != NIPC_OK) return; - nipc_session_ctx_t **shrunk = realloc(server.sessions, sizeof(*shrunk)); - check("growth: shrink session table to 1 slot", shrunk != NULL); + /* The table never grows: the worker limit keeps session_count below + * worker_count, so worker_count slots is the smallest valid table. */ + nipc_session_ctx_t **shrunk = + realloc(server.sessions, (size_t)workers * sizeof(*shrunk)); + check("growth: shrink session table to worker_count slots", shrunk != NULL); if (!shrunk) { nipc_server_destroy(&server); cleanup_all(svc); return; } server.sessions = shrunk; - server.session_capacity = 1; + server.session_capacity = workers; + test_server_use_test_timings(&server, NULL); pthread_create(&server_tid, NULL, (void *(*)(void *))nipc_server_run, &server); - usleep(50000); for (int i = 0; i < 3; i++) { nipc_client_config_t ccfg = default_client_config(); @@ -595,21 +581,13 @@ static void test_internal_session_table_growth(void) } check("growth: three clients connected", ready_clients == 3); - for (int i = 0; i < 200; i++) { - pthread_mutex_lock(&server.sessions_lock); - observed_capacity = server.session_capacity; - observed_count = server.session_count; - pthread_mutex_unlock(&server.sessions_lock); - if (observed_count >= 3 && observed_capacity >= 4) - break; - usleep(5000); - } + /* A ready client completed HELLO, so its session is in the table. */ + pthread_mutex_lock(&server.sessions_lock); + observed_capacity = server.session_capacity; + observed_count = server.session_count; + pthread_mutex_unlock(&server.sessions_lock); - /* The reaper may reclaim slots between accepts, so the table may - * not need to grow beyond the initial allocation. The important - * invariant is that all 3 clients were served, not that the - * internal capacity reached a specific number. */ - check("growth: session table expanded or reused", observed_capacity >= 1); + check("growth: session table kept its capacity", observed_capacity == workers); check("growth: server tracked three sessions", observed_count >= 3); for (int i = 0; i < 3; i++) diff --git a/tests/fixtures/c/test_lock_cleanup.h b/tests/fixtures/c/test_lock_cleanup.h new file mode 100644 index 00000000..6e78f4bc --- /dev/null +++ b/tests/fixtures/c/test_lock_cleanup.h @@ -0,0 +1,63 @@ +#ifndef TEST_LOCK_CLEANUP_H +#define TEST_LOCK_CLEANUP_H + +/* Test-only cleanup of endpoint lock files. Lock files stay after a server + * closes, by design (docs/level1-posix-uds.md, "Endpoint lock"). Test + * service names are unique per run, so without this every run would leave + * new locks behind: the test process removes the locks it named at exit. */ + +#include +#include +#include +#include +#include + +static pthread_mutex_t g_test_locks_mu = PTHREAD_MUTEX_INITIALIZER; +static char **g_test_locks = NULL; +static size_t g_test_locks_len = 0; +static size_t g_test_locks_cap = 0; +static pid_t g_test_locks_owner = 0; + +static void test_locks_remove(void) +{ + /* A forked child inherits the handler; only the process that named the + * services removes their locks. */ + if (getpid() != g_test_locks_owner) + return; + for (size_t i = 0; i < g_test_locks_len; i++) { + unlink(g_test_locks[i]); + free(g_test_locks[i]); + } + free(g_test_locks); + g_test_locks = NULL; + g_test_locks_len = 0; +} + +/* Records {dir}/{service}.lock for removal at process exit. */ +static void test_locks_track(const char *dir, const char *service) +{ + pthread_mutex_lock(&g_test_locks_mu); + if (g_test_locks_owner == 0) { + g_test_locks_owner = getpid(); + atexit(test_locks_remove); + } + if (g_test_locks_len == g_test_locks_cap) { + size_t cap = g_test_locks_cap ? g_test_locks_cap * 2 : 64; + char **grown = realloc(g_test_locks, cap * sizeof(*grown)); + if (!grown) { + pthread_mutex_unlock(&g_test_locks_mu); + return; + } + g_test_locks = grown; + g_test_locks_cap = cap; + } + size_t len = strlen(dir) + strlen(service) + sizeof("/.lock"); + char *path = malloc(len); + if (path) { + snprintf(path, len, "%s/%s.lock", dir, service); + g_test_locks[g_test_locks_len++] = path; + } + pthread_mutex_unlock(&g_test_locks_mu); +} + +#endif diff --git a/tests/fixtures/c/test_multi_server.c b/tests/fixtures/c/test_multi_server.c index 95b4bde0..1c5035e9 100644 --- a/tests/fixtures/c/test_multi_server.c +++ b/tests/fixtures/c/test_multi_server.c @@ -12,6 +12,7 @@ #include "netipc/netipc_service.h" #include "netipc/netipc_protocol.h" #include "netipc/netipc_uds.h" +#include "test_sync.h" #include #include @@ -137,17 +138,21 @@ static nipc_error_t echo_handler(void *user, return (*response_len_out > 0) ? NIPC_OK : NIPC_ERR_OVERFLOW; } -/* Handler that sleeps briefly to simulate work, allowing concurrency testing */ -static nipc_error_t slow_handler(void *user, - const nipc_header_t *request_hdr, - const uint8_t *request_payload, - size_t request_len, - uint8_t *response_buf, - size_t response_buf_size, - size_t *response_len_out) +/* Gated handler: reports entry, then holds the request until the test + * opens the gate, so sessions provably overlap. */ +static int g_gate_entered; +static int g_gate_open; + +static nipc_error_t gated_handler(void *user, + const nipc_header_t *request_hdr, + const uint8_t *request_payload, + size_t request_len, + uint8_t *response_buf, + size_t response_buf_size, + size_t *response_len_out) { - /* Sleep 100ms to ensure concurrent sessions overlap */ - usleep(100000); + test_counter_add(&g_gate_entered); + (void)test_flag_wait(&g_gate_open); return echo_handler(user, request_hdr, request_payload, request_len, response_buf, response_buf_size, response_len_out); } @@ -173,6 +178,7 @@ static nipc_error_t selective_handler(void *user, typedef struct { const char *service; nipc_managed_server_t server; + test_server_events_t events; nipc_server_handler_fn handler; int worker_count; int ready; /* use __atomic builtins for cross-thread access */ @@ -190,14 +196,15 @@ static void *server_thread_fn(void *arg) if (err != NIPC_OK) { fprintf(stderr, "server init failed: %d\n", err); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_server_use_test_timings(&ctx->server, &ctx->events); + test_flag_set(&ctx->ready); nipc_server_run(&ctx->server); nipc_server_destroy(&ctx->server); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -215,10 +222,7 @@ static void start_server_ex(server_thread_ctx_t *sctx, const char *service, pthread_create(tid, NULL, server_thread_fn, sctx); /* Wait for server to be ready */ - for (int i = 0; i < 4000 - && !__atomic_load_n(&sctx->ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx->done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx->ready, &sctx->done); } static void start_server(server_thread_ctx_t *sctx, const char *service, @@ -297,14 +301,7 @@ static void *client_thread_fn(void *arg) nipc_client_ctx_t client; nipc_client_init(&client, TEST_RUN_DIR, ctx->service, &ccfg); - /* Connect with retry */ - for (int i = 0; i < 100; i++) { - nipc_client_refresh(&client); - if (nipc_client_ready(&client)) - break; - usleep(10000); - } - + nipc_client_refresh(&client); if (!nipc_client_ready(&client)) { ctx->error_count = ctx->request_count; nipc_client_close(&client); @@ -388,49 +385,57 @@ static void test_basic_multi_client(void) static void test_worker_limit(void) { - printf("Test 2: Worker limit (server workers=2, 5 clients)\n"); + printf("Test 2: Worker limit (server workers=2, 3 clients)\n"); const char *svc = "msvc_wlimit"; cleanup_all(svc); - /* Server with only 2 workers */ + __atomic_store_n(&g_gate_entered, 0, __ATOMIC_RELAXED); + __atomic_store_n(&g_gate_open, 0, __ATOMIC_RELAXED); server_thread_ctx_t sctx; pthread_t stid; - start_server_ex(&sctx, svc, slow_handler, 2, &stid); + start_server_ex(&sctx, svc, gated_handler, 2, &stid); check("server started", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); - /* Try to connect 5 clients. With worker_count=2, only 2 can be - * served concurrently. The slow_handler sleeps 100ms per request, - * so we'll have clients that get rejected and retry. */ - const int num_clients = 5; + /* Two clients occupy both workers inside the gated handler. */ + const int busy_clients = 2; const int requests_per = 3; - pthread_t ctids[5]; - client_thread_ctx_t cctxs[5]; - - for (int i = 0; i < num_clients; i++) { + pthread_t ctids[2]; + client_thread_ctx_t cctxs[2]; + for (int i = 0; i < busy_clients; i++) { cctxs[i].client_id = i; cctxs[i].request_count = requests_per; cctxs[i].service = svc; pthread_create(&ctids[i], NULL, client_thread_fn, &cctxs[i]); } + check("both workers are inside the handler", + test_counter_wait(&g_gate_entered, busy_clients)); - for (int i = 0; i < num_clients; i++) - pthread_join(ctids[i], NULL); + /* A third client is refused while both workers are busy. */ + nipc_client_config_t ccfg = default_client_config(); + nipc_client_ctx_t third; + nipc_client_init(&third, TEST_RUN_DIR, svc, &ccfg); + nipc_client_refresh(&third); + check("third client refused at worker capacity", !nipc_client_ready(&third)); - /* With worker_count=2, not all 5 clients will succeed simultaneously. - * But the key test is that the server doesn't crash and the 2 slots - * that do get served return correct data. */ - int served_clients = 0; + test_flag_set(&g_gate_open); bool all_served_content_ok = true; - for (int i = 0; i < num_clients; i++) { - if (cctxs[i].success_count > 0) { - served_clients++; - if (!cctxs[i].content_verified) - all_served_content_ok = false; - } + int total_success = 0; + for (int i = 0; i < busy_clients; i++) { + pthread_join(ctids[i], NULL); + total_success += cctxs[i].success_count; + if (!cctxs[i].content_verified) + all_served_content_ok = false; } - - check("at least 2 clients were served", served_clients >= 2); + check("busy clients got all responses", + total_success == busy_clients * requests_per); check("served clients got correct data", all_served_content_ok); + + /* Once a worker frees its slot, the refused client is served. */ + check("a worker freed its slot", test_server_wait_session_exits(&sctx.events, 1)); + nipc_client_refresh(&third); + check("third client served once a worker is free", + nipc_client_ready(&third) && do_call_and_verify(&third, 2)); + nipc_client_close(&third); check("server did not crash", !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE)); stop_server(&sctx, stid); @@ -468,7 +473,7 @@ static void test_client_disconnect_recovery(void) nipc_client_close(&c1); } - usleep(50000); /* let the server notice the disconnect */ + check("client 1 session exited", test_server_wait_session_exits(&sctx.events, 1)); /* Client 2: should still be able to connect and get served */ { @@ -484,7 +489,7 @@ static void test_client_disconnect_recovery(void) nipc_client_close(&c2); } - usleep(50000); + check("client 2 session exited", test_server_wait_session_exits(&sctx.events, 1)); /* Client 3: verify server is still healthy */ { @@ -532,7 +537,7 @@ static void test_handler_failure_per_session(void) check("client 1 normal call succeeded", ok); nipc_client_close(&c1); - usleep(50000); + check("client 1 session exited", test_server_wait_session_exits(&sctx.events, 1)); /* Client 2: should still succeed after client 1's success */ nipc_client_ctx_t c2; @@ -574,20 +579,11 @@ static void test_rapid_connect_disconnect(void) nipc_client_ctx_t client; nipc_client_init(&client, TEST_RUN_DIR, svc, &ccfg); - /* Connect with small retry window */ - for (int r = 0; r < 50; r++) { - nipc_client_refresh(&client); - if (nipc_client_ready(&client)) - break; - usleep(5000); - } - - if (nipc_client_ready(&client)) { - if (do_call_and_verify(&client, i)) - success_count++; - } - + nipc_client_refresh(&client); + bool served = nipc_client_ready(&client) && do_call_and_verify(&client, i); nipc_client_close(&client); + if (served && test_server_wait_session_exits(&sctx.events, 1)) + success_count++; } char msg[128]; @@ -672,6 +668,8 @@ typedef struct { int connected; /* use __atomic builtins for cross-thread access */ int finished; /* use __atomic builtins for cross-thread access */ int calls_made; /* use __atomic builtins for cross-thread access */ + int *server_gone; /* raised once the server was destroyed */ + nipc_error_t after_err; } shutdown_client_ctx_t; static void *shutdown_client_fn(void *arg) @@ -683,34 +681,27 @@ static void *shutdown_client_fn(void *arg) nipc_client_ctx_t client; nipc_client_init(&client, TEST_RUN_DIR, ctx->service, &ccfg); - for (int i = 0; i < 100; i++) { - nipc_client_refresh(&client); - if (nipc_client_ready(&client)) - break; - usleep(10000); - } - + nipc_client_refresh(&client); if (!nipc_client_ready(&client)) { - __atomic_store_n(&ctx->finished, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->connected); + test_flag_set(&ctx->finished); nipc_client_close(&client); return NULL; } - __atomic_store_n(&ctx->connected, 1, __ATOMIC_RELEASE); - - /* Keep making calls until failure (server shutdown) */ nipc_cgroups_resp_view_t view; - - while (1) { - nipc_error_t err = nipc_client_call_cgroups_snapshot(&client, &view); - if (err != NIPC_OK) - break; - __atomic_fetch_add(&ctx->calls_made, 1, __ATOMIC_RELAXED); - usleep(10000); + for (int i = 0; i < 5; i++) { + if (nipc_client_call_cgroups_snapshot(&client, &view) == NIPC_OK) + __atomic_fetch_add(&ctx->calls_made, 1, __ATOMIC_RELAXED); } + /* Stay connected across the shutdown, then call once it completed. */ + test_flag_set(&ctx->connected); + (void)test_flag_wait(ctx->server_gone); + ctx->after_err = nipc_client_call_cgroups_snapshot(&client, &view); + nipc_client_close(&client); - __atomic_store_n(&ctx->finished, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->finished); return NULL; } @@ -730,41 +721,35 @@ static void test_graceful_shutdown(void) pthread_t ctids[3]; shutdown_client_ctx_t cctxs[3]; + int server_gone = 0; for (int i = 0; i < num_clients; i++) { cctxs[i].service = svc; + cctxs[i].server_gone = &server_gone; + cctxs[i].after_err = NIPC_OK; __atomic_store_n(&cctxs[i].connected, 0, __ATOMIC_RELAXED); __atomic_store_n(&cctxs[i].finished, 0, __ATOMIC_RELAXED); __atomic_store_n(&cctxs[i].calls_made, 0, __ATOMIC_RELAXED); pthread_create(&ctids[i], NULL, shutdown_client_fn, &cctxs[i]); } - /* Wait for all clients to connect */ - for (int w = 0; w < 2000; w++) { - int connected = 0; - for (int i = 0; i < num_clients; i++) - if (__atomic_load_n(&cctxs[i].connected, __ATOMIC_ACQUIRE)) - connected++; - if (connected == num_clients) - break; - usleep(1000); - } + /* Wait for all clients to connect and finish their calls */ + for (int i = 0; i < num_clients; i++) + (void)test_flag_wait(&cctxs[i].connected); int connected_count = 0; for (int i = 0; i < num_clients; i++) - if (__atomic_load_n(&cctxs[i].connected, __ATOMIC_ACQUIRE)) + if (__atomic_load_n(&cctxs[i].calls_made, __ATOMIC_ACQUIRE) == 5) connected_count++; check("all clients connected before shutdown", connected_count == num_clients); - /* Let them run for a bit */ - usleep(200000); - /* Signal shutdown */ nipc_server_stop(&sctx.server); /* Wait for server thread to finish */ pthread_join(stid, NULL); check("server thread exited cleanly", __atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE) == 1); + test_flag_set(&server_gone); /* Wait for client threads to detect disconnection */ for (int i = 0; i < num_clients; i++) @@ -783,7 +768,12 @@ static void test_graceful_shutdown(void) check("all clients finished after shutdown", finished_count == num_clients); check("clients made calls before shutdown", - min_calls > 0); + min_calls == 5); + bool all_failed = true; + for (int i = 0; i < num_clients; i++) + if (cctxs[i].after_err == NIPC_OK) + all_failed = false; + check("every client's call fails after shutdown", all_failed); cleanup_all(svc); } diff --git a/tests/fixtures/c/test_named_pipe.c b/tests/fixtures/c/test_named_pipe.c index f8b6f826..6decf430 100644 --- a/tests/fixtures/c/test_named_pipe.c +++ b/tests/fixtures/c/test_named_pipe.c @@ -9,6 +9,8 @@ #include "netipc/netipc_named_pipe.h" #include "netipc/netipc_protocol.h" +#include "test_sync.h" +#include "test_win_security.h" #include #include @@ -97,6 +99,13 @@ typedef enum { FAKE_ACK_GOOD_THEN_ZERO_MESSAGE, FAKE_ACK_GOOD_THEN_TRAILING_RESPONSE, FAKE_ACK_CLOSE_BEFORE_ACK, + FAKE_ACK_SELECTED_NOT_OFFERED, + FAKE_ACK_SELECTED_NONE, + FAKE_ACK_SELECTED_TWO, + FAKE_ACK_TRAILING_BYTES, + FAKE_ACK_LONG_PAYLOAD, + FAKE_ACK_PACKET_ABOVE_PROPOSAL, + FAKE_ACK_PACKET_HEADER_ONLY, } fake_ack_mode_t; typedef enum { @@ -120,6 +129,7 @@ typedef struct { typedef struct { const char *service; HANDLE ready_event; + HANDLE accepted_event; /* set once the server's accept returned */ int result; fake_hello_mode_t mode; } fake_client_thread_ctx_t; @@ -157,28 +167,24 @@ static HANDLE create_raw_pipe(const char *service, uint32_t buf_size, wchar_t *p NULL); } +/* The listener exists before callers connect; WaitNamedPipeW covers an + * instance that is momentarily busy. */ static HANDLE connect_raw_pipe(const wchar_t *pipe_name) { - for (int i = 0; i < 100; i++) { - HANDLE h = CreateFileW( - pipe_name, - GENERIC_READ | GENERIC_WRITE, - 0, - NULL, - OPEN_EXISTING, - 0, - NULL); - if (h != INVALID_HANDLE_VALUE) { - DWORD mode = PIPE_READMODE_MESSAGE; - if (!SetNamedPipeHandleState(h, &mode, NULL, NULL)) { - CloseHandle(h); - return INVALID_HANDLE_VALUE; - } - return h; - } - Sleep(10); + HANDLE h = CreateFileW(pipe_name, GENERIC_READ | GENERIC_WRITE, 0, NULL, + OPEN_EXISTING, 0, NULL); + if (h == INVALID_HANDLE_VALUE && GetLastError() == ERROR_PIPE_BUSY && + WaitNamedPipeW(pipe_name, TEST_WAIT_LIMIT_MS)) + h = CreateFileW(pipe_name, GENERIC_READ | GENERIC_WRITE, 0, NULL, + OPEN_EXISTING, 0, NULL); + if (h == INVALID_HANDLE_VALUE) + return INVALID_HANDLE_VALUE; + DWORD mode = PIPE_READMODE_MESSAGE; + if (!SetNamedPipeHandleState(h, &mode, NULL, NULL)) { + CloseHandle(h); + return INVALID_HANDLE_VALUE; } - return INVALID_HANDLE_VALUE; + return h; } static int connect_named_pipe_server(HANDLE pipe) @@ -378,17 +384,43 @@ static DWORD WINAPI fake_ack_server_thread(LPVOID arg) NIPC_KIND_CONTROL, NIPC_CODE_HELLO_ACK, NIPC_STATUS_UNSUPPORTED, payload, payload_len); } else { + uint32_t agreed = packet_size; + if (ctx->mode == FAKE_ACK_PACKET_ABOVE_PROPOSAL) + agreed = packet_size + 1; + if (ctx->mode == FAKE_ACK_PACKET_HEADER_ONLY) + agreed = NIPC_HEADER_LEN; size_t payload_len = build_valid_hello_ack_payload(payload, sizeof(payload), - packet_size, 1); + agreed, 1); if (ctx->mode == FAKE_ACK_BAD_LAYOUT) { nipc_hello_ack_t ack = { .layout_version = 2 }; payload_len = nipc_hello_ack_encode(&ack, payload, sizeof(payload)); } if (ctx->mode == FAKE_ACK_BAD_PAYLOAD) payload_len = 8; + if (ctx->mode == FAKE_ACK_LONG_PAYLOAD) { + /* A valid ACK, then 16 more payload bytes the header declares. */ + memset(payload + payload_len, 0, 16); + payload_len += 16; + } + if (ctx->mode == FAKE_ACK_SELECTED_NOT_OFFERED || + ctx->mode == FAKE_ACK_SELECTED_NONE || + ctx->mode == FAKE_ACK_SELECTED_TWO) { + /* The client offers baseline only; selected_profile is at offset 12. */ + uint32_t selected = + ctx->mode == FAKE_ACK_SELECTED_NOT_OFFERED ? NIPC_PROFILE_SHM_HYBRID + : ctx->mode == FAKE_ACK_SELECTED_NONE + ? 0 + : (NIPC_PROFILE_BASELINE | NIPC_PROFILE_SHM_HYBRID); + memcpy(payload + 12, &selected, sizeof(selected)); + } msg_len = build_control_packet(msg, sizeof(msg), NIPC_KIND_CONTROL, NIPC_CODE_HELLO_ACK, NIPC_STATUS_OK, payload, payload_len); + if (ctx->mode == FAKE_ACK_TRAILING_BYTES && msg_len > 0) { + /* A valid ACK message, then 16 bytes its header does not declare. */ + memset(msg + msg_len, 0, 16); + msg_len += 16; + } } if (!raw_pipe_write(pipe, msg, (DWORD)msg_len)) { @@ -877,8 +909,10 @@ static DWORD WINAPI fake_hello_client_thread(LPVOID arg) } ctx->result = raw_pipe_write(pipe, msg, (DWORD)msg_len); + /* Keep the pipe open until the server judged the HELLO, so it reads + * the bad packet rather than a disconnect. */ if (ctx->result) - Sleep(50); + WaitForSingleObject(ctx->accepted_event, TEST_WAIT_LIMIT_MS); CloseHandle(pipe); return ctx->result ? 0 : 1; } @@ -1028,7 +1062,7 @@ static void test_ping_pong(void) .ready_event = ready_event, }; - HANDLE thread = CreateThread(NULL, 0, server_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, server_thread, &ctx, 0, NULL); WaitForSingleObject(ready_event, 5000); CloseHandle(ready_event); @@ -1131,7 +1165,7 @@ static void test_preferred_profile_selection(void) .accepted_selected_profile = 0, }; - HANDLE thread = CreateThread(NULL, 0, preferred_profile_server_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, preferred_profile_server_thread, &ctx, 0, NULL); check("preferred-profile server thread created", thread != NULL); if (!thread) { CloseHandle(ready_event); @@ -1206,7 +1240,7 @@ static void test_auth_failure(void) .ready_event = ready_event, }; - HANDLE thread = CreateThread(NULL, 0, server_auth_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, server_auth_thread, &ctx, 0, NULL); WaitForSingleObject(ready_event, 5000); CloseHandle(ready_event); @@ -1304,7 +1338,7 @@ static void test_chunked_ping_pong(void) .ready_event = ready_event, }; - HANDLE thread = CreateThread(NULL, 0, chunked_server_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, chunked_server_thread, &ctx, 0, NULL); WaitForSingleObject(ready_event, 5000); CloseHandle(ready_event); @@ -1380,7 +1414,7 @@ static void test_chunked_receive_reuses_buffer(void) .ready_event = ready_event, }; - HANDLE thread = CreateThread(NULL, 0, chunked_server_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, chunked_server_thread, &ctx, 0, NULL); WaitForSingleObject(ready_event, 5000); CloseHandle(ready_event); @@ -1480,7 +1514,7 @@ static void test_chunked_pipeline(void) .ready_event = ready_event, }; - HANDLE thread = CreateThread(NULL, 0, chunked_server_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, chunked_server_thread, &ctx, 0, NULL); WaitForSingleObject(ready_event, 5000); CloseHandle(ready_event); @@ -1565,6 +1599,159 @@ static void test_chunked_pipeline(void) check("chunked pipeline server ok", ctx.server_ok); } +/* ------------------------------------------------------------------ */ +/* Test: pipelining beyond the pipe buffers */ +/* ------------------------------------------------------------------ */ + +#define PROGRESS_PAYLOAD (16u * 1024u) +#define PROGRESS_REQUESTS 32u /* 512 KiB each way, 8x the 64 KiB buffers */ + +static void progress_fill(uint8_t *p, uint64_t id) +{ + for (size_t i = 0; i < PROGRESS_PAYLOAD; i++) + p[i] = (uint8_t)(id + i); +} + +typedef struct { + const char *service; + HANDLE ready_event; + int server_ok; +} progress_server_ctx_t; + +/* Answers each request with blocking writes, one at a time: the server of + * the deadlock this guards against. */ +static DWORD WINAPI progress_server_thread(LPVOID arg) +{ + progress_server_ctx_t *ctx = (progress_server_ctx_t *)arg; + nipc_np_server_config_t cfg = default_server_config(); + cfg.max_request_payload_bytes = PROGRESS_PAYLOAD; + cfg.max_response_payload_bytes = PROGRESS_PAYLOAD; + + nipc_np_listener_t listener; + nipc_np_error_t err = nipc_np_listen(TEST_RUN_DIR, ctx->service, &cfg, &listener); + SetEvent(ctx->ready_event); + if (err != NIPC_NP_OK) + return 1; + + nipc_np_session_t session; + err = nipc_np_accept(&listener, 1, &session); + if (err == NIPC_NP_OK) { + static uint8_t buf[64 * 1024]; + for (uint32_t i = 0; i < PROGRESS_REQUESTS && err == NIPC_NP_OK; i++) { + nipc_header_t hdr; + const void *payload; + size_t payload_len; + err = nipc_np_receive(&session, buf, sizeof(buf), &hdr, &payload, &payload_len); + if (err != NIPC_NP_OK) + break; + nipc_header_t resp = hdr; + resp.kind = NIPC_KIND_RESPONSE; + resp.transport_status = NIPC_STATUS_OK; + err = nipc_np_send(&session, &resp, payload, payload_len); + } + nipc_np_close_session(&session); + } + ctx->server_ok = (err == NIPC_NP_OK); + nipc_np_close_listener(&listener); + return 0; +} + +typedef struct { + const char *service; + int sends_ok; + int queued_after_sends; + int readable_after_sends; + int responses_ok; + int drained_at_end; +} progress_client_ctx_t; + +static DWORD WINAPI progress_client_thread(LPVOID arg) +{ + progress_client_ctx_t *ctx = (progress_client_ctx_t *)arg; + nipc_np_client_config_t ccfg = default_client_config(); + ccfg.max_request_payload_bytes = PROGRESS_PAYLOAD; + ccfg.max_response_payload_bytes = PROGRESS_PAYLOAD; + + nipc_np_session_t session; + if (nipc_np_connect(TEST_RUN_DIR, ctx->service, &ccfg, &session) != NIPC_NP_OK) + return 1; + + static uint8_t payload[PROGRESS_PAYLOAD]; + nipc_np_error_t err = NIPC_NP_OK; + for (uint64_t id = 1; id <= PROGRESS_REQUESTS && err == NIPC_NP_OK; id++) { + nipc_header_t hdr = { + .kind = NIPC_KIND_REQUEST, + .code = NIPC_METHOD_INCREMENT, + .item_count = 1, + .message_id = id, + }; + progress_fill(payload, id); + err = nipc_np_send(&session, &hdr, payload, sizeof(payload)); + } + ctx->sends_ok = (err == NIPC_NP_OK); + + /* The server could read every request only because the sends drained + * responses it had written. */ + ctx->queued_after_sends = nipc_np_session_has_pending(&session); + bool readable = false; + ctx->readable_after_sends = + nipc_np_wait_readable(&session, 0, &readable) == NIPC_NP_OK && readable; + + static uint8_t rbuf[64 * 1024]; + static uint8_t expected[PROGRESS_PAYLOAD]; + int ok = ctx->sends_ok; + for (uint64_t id = 1; id <= PROGRESS_REQUESTS && ok; id++) { + nipc_header_t rhdr; + const void *rpayload; + size_t rlen = 0; + err = nipc_np_receive(&session, rbuf, sizeof(rbuf), &rhdr, &rpayload, &rlen); + progress_fill(expected, id); + ok = err == NIPC_NP_OK && rhdr.message_id == id && rlen == PROGRESS_PAYLOAD && + memcmp(rpayload, expected, PROGRESS_PAYLOAD) == 0; + } + ctx->responses_ok = ok; + ctx->drained_at_end = !nipc_np_session_has_pending(&session); + nipc_np_close_session(&session); + return 0; +} + +/* A client pipelining more than the pipe buffers before reading must not + * deadlock with a server blocked writing responses to it. */ +static void test_pipeline_beyond_pipe_buffers(void) +{ + printf("--- Pipelining beyond the pipe buffers ---\n"); + + char service[64]; + unique_service(service, sizeof(service)); + + progress_server_ctx_t sctx = { + .service = service, + .ready_event = CreateEvent(NULL, TRUE, FALSE, NULL), + }; + HANDLE server = test_create_thread(NULL, 0, progress_server_thread, &sctx, 0, NULL); + WaitForSingleObject(sctx.ready_event, 5000); + CloseHandle(sctx.ready_event); + + progress_client_ctx_t cctx = { .service = service }; + HANDLE client = test_create_thread(NULL, 0, progress_client_thread, &cctx, 0, NULL); + + /* Watchdog only: a deadlock never completes. */ + DWORD w = WaitForSingleObject(client, 30000); + check("pipeline beyond buffers completes", w == WAIT_OBJECT_0); + if (w != WAIT_OBJECT_0) + return; /* the threads are stuck; the run reports the failure */ + CloseHandle(client); + WaitForSingleObject(server, 5000); + CloseHandle(server); + + check("pipeline beyond buffers sends", cctx.sends_ok); + check("pipeline beyond buffers queued responses", cctx.queued_after_sends); + check("pipeline beyond buffers wait_readable sees queue", cctx.readable_after_sends); + check("pipeline beyond buffers responses", cctx.responses_ok); + check("pipeline beyond buffers queue drained", cctx.drained_at_end); + check("pipeline beyond buffers server ok", sctx.server_ok); +} + /* ------------------------------------------------------------------ */ /* Test: duplicate / unknown message_id */ /* ------------------------------------------------------------------ */ @@ -1625,7 +1812,7 @@ static void test_duplicate_message_id(void) .ready_event = ready_event, }; - HANDLE thread = CreateThread(NULL, 0, server_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, server_thread, &ctx, 0, NULL); WaitForSingleObject(ready_event, 5000); CloseHandle(ready_event); @@ -1679,7 +1866,7 @@ static void test_unknown_response_message_id(void) .ready_event = ready_event, }; - HANDLE thread = CreateThread(NULL, 0, wrong_id_server_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, wrong_id_server_thread, &ctx, 0, NULL); WaitForSingleObject(ready_event, 5000); CloseHandle(ready_event); @@ -1714,6 +1901,14 @@ static void test_unknown_response_message_id(void) check("unknown-id server ok", ctx.server_ok); } +/* NIPC_NP_OK in alternate_expected means "no alternate": a handshake that + * succeeds never matches a rejection case. */ +static bool handshake_err_matches(nipc_np_error_t err, nipc_np_error_t expected, + nipc_np_error_t alternate) +{ + return err == expected || (alternate != NIPC_NP_OK && err == alternate); +} + static void test_client_handshake_rejections(void) { printf("--- Client handshake protocol rejections ---\n"); @@ -1734,6 +1929,13 @@ static void test_client_handshake_rejections(void) { "bad HELLO_ACK layout", FAKE_ACK_BAD_LAYOUT, NIPC_NP_ERR_INCOMPATIBLE, NIPC_NP_OK, NIPC_NP_DEFAULT_PACKET_SIZE }, { "bad HELLO_ACK payload", FAKE_ACK_BAD_PAYLOAD, NIPC_NP_ERR_PROTOCOL, NIPC_NP_OK, NIPC_NP_DEFAULT_PACKET_SIZE }, { "peer closes before HELLO_ACK", FAKE_ACK_CLOSE_BEFORE_ACK, NIPC_NP_ERR_RECV, NIPC_NP_OK, NIPC_NP_DEFAULT_PACKET_SIZE }, + { "HELLO_ACK selecting an unoffered profile", FAKE_ACK_SELECTED_NOT_OFFERED, NIPC_NP_ERR_PROTOCOL, NIPC_NP_OK, NIPC_NP_DEFAULT_PACKET_SIZE }, + { "HELLO_ACK selecting no profile", FAKE_ACK_SELECTED_NONE, NIPC_NP_ERR_PROTOCOL, NIPC_NP_OK, NIPC_NP_DEFAULT_PACKET_SIZE }, + { "HELLO_ACK selecting two profiles", FAKE_ACK_SELECTED_TWO, NIPC_NP_ERR_PROTOCOL, NIPC_NP_OK, NIPC_NP_DEFAULT_PACKET_SIZE }, + { "HELLO_ACK with bytes after its payload", FAKE_ACK_TRAILING_BYTES, NIPC_NP_ERR_PROTOCOL, NIPC_NP_OK, NIPC_NP_DEFAULT_PACKET_SIZE }, + { "HELLO_ACK declaring a 64-byte payload", FAKE_ACK_LONG_PAYLOAD, NIPC_NP_ERR_PROTOCOL, NIPC_NP_OK, NIPC_NP_DEFAULT_PACKET_SIZE }, + { "HELLO_ACK packet size above the proposal", FAKE_ACK_PACKET_ABOVE_PROPOSAL, NIPC_NP_ERR_PROTOCOL, NIPC_NP_OK, NIPC_NP_DEFAULT_PACKET_SIZE }, + { "HELLO_ACK packet size without payload room", FAKE_ACK_PACKET_HEADER_ONLY, NIPC_NP_ERR_PROTOCOL, NIPC_NP_OK, NIPC_NP_DEFAULT_PACKET_SIZE }, }; for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { @@ -1749,7 +1951,7 @@ static void test_client_handshake_rejections(void) .packet_size = cases[i].packet_size, }; - HANDLE thread = CreateThread(NULL, 0, fake_ack_server_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, fake_ack_server_thread, &ctx, 0, NULL); check("fake ack server thread created", thread != NULL); if (!thread) { CloseHandle(ready_event); @@ -1763,10 +1965,11 @@ static void test_client_handshake_rejections(void) ccfg.packet_size = cases[i].packet_size; nipc_np_session_t session; nipc_np_error_t err = nipc_np_connect(TEST_RUN_DIR, service, &ccfg, &session); - if (err != cases[i].expected && err != cases[i].alternate_expected) + bool matched = handshake_err_matches(err, cases[i].expected, + cases[i].alternate_expected); + if (!matched) printf(" note: %s returned %d\n", cases[i].name, (int)err); - check(cases[i].name, - err == cases[i].expected || err == cases[i].alternate_expected); + check(cases[i].name, matched); if (err == NIPC_NP_OK) nipc_np_close_session(&session); @@ -1807,17 +2010,20 @@ static void test_server_handshake_rejections(void) continue; HANDLE ready_event = CreateEvent(NULL, TRUE, FALSE, NULL); + HANDLE accepted_event = CreateEvent(NULL, TRUE, FALSE, NULL); fake_client_thread_ctx_t ctx = { .service = service, .ready_event = ready_event, + .accepted_event = accepted_event, .result = 0, .mode = cases[i].mode, }; - HANDLE thread = CreateThread(NULL, 0, fake_hello_client_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, fake_hello_client_thread, &ctx, 0, NULL); check("fake hello client thread created", thread != NULL); if (!thread) { CloseHandle(ready_event); + CloseHandle(accepted_event); nipc_np_close_listener(&listener); continue; } @@ -1827,15 +2033,18 @@ static void test_server_handshake_rejections(void) nipc_np_session_t session; err = nipc_np_accept(&listener, 1, &session); - if (err != cases[i].expected && err != cases[i].alternate_expected) + SetEvent(accepted_event); + bool matched = handshake_err_matches(err, cases[i].expected, + cases[i].alternate_expected); + if (!matched) printf(" note: %s returned %d\n", cases[i].name, (int)err); - check(cases[i].name, - err == cases[i].expected || err == cases[i].alternate_expected); + check(cases[i].name, matched); if (err == NIPC_NP_OK) nipc_np_close_session(&session); WaitForSingleObject(thread, 5000); CloseHandle(thread); + CloseHandle(accepted_event); check("fake hello client completed", ctx.result == 1); nipc_np_close_listener(&listener); } @@ -1857,7 +2066,7 @@ static void test_receive_after_peer_disconnect(void) .packet_size = NIPC_NP_DEFAULT_PACKET_SIZE, }; - HANDLE thread = CreateThread(NULL, 0, fake_ack_server_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, fake_ack_server_thread, &ctx, 0, NULL); check("disconnect fake server thread created", thread != NULL); if (!thread) { CloseHandle(ready_event); @@ -1942,7 +2151,7 @@ static void test_disconnect_clears_all_inflight(void) .result = 0, }; - HANDLE thread = CreateThread(NULL, 0, disconnect_inflight_server_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, disconnect_inflight_server_thread, &ctx, 0, NULL); check("disconnect-inflight server thread created", thread != NULL); if (!thread) { CloseHandle(ready_event); @@ -2006,7 +2215,7 @@ static void test_receive_after_zero_byte_message(void) .packet_size = NIPC_NP_DEFAULT_PACKET_SIZE, }; - HANDLE thread = CreateThread(NULL, 0, fake_ack_server_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, fake_ack_server_thread, &ctx, 0, NULL); check("zero-message fake server thread created", thread != NULL); if (!thread) { CloseHandle(ready_event); @@ -2052,7 +2261,7 @@ static void test_chunk_validation_error(void) .packet_size = 128, }; - HANDLE thread = CreateThread(NULL, 0, fake_ack_server_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, fake_ack_server_thread, &ctx, 0, NULL); check("chunk fake server thread created", thread != NULL); if (!thread) { CloseHandle(ready_event); @@ -2129,7 +2338,7 @@ static void test_chunk_receive_error_paths(void) .packet_size = 128, }; - HANDLE thread = CreateThread(NULL, 0, fake_ack_server_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, fake_ack_server_thread, &ctx, 0, NULL); check("chunk recv fake server thread created", thread != NULL); if (!thread) { CloseHandle(ready_event); @@ -2209,7 +2418,7 @@ static void test_response_limit_validation(void) .packet_size = NIPC_NP_DEFAULT_PACKET_SIZE, }; - HANDLE thread = CreateThread(NULL, 0, fake_ack_server_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, fake_ack_server_thread, &ctx, 0, NULL); check("limit fake server thread created", thread != NULL); if (!thread) { CloseHandle(ready_event); @@ -2286,7 +2495,7 @@ static void test_response_protocol_validation(void) .packet_size = NIPC_NP_DEFAULT_PACKET_SIZE, }; - HANDLE thread = CreateThread(NULL, 0, fake_ack_server_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, fake_ack_server_thread, &ctx, 0, NULL); check("protocol fake server thread created", thread != NULL); if (!thread) { CloseHandle(ready_event); @@ -2345,10 +2554,10 @@ static void test_zero_chunk_budget_rejected(void) .ready_event = ready_event, .result = 0, .mode = FAKE_ACK_GOOD_THEN_CLOSE, - .packet_size = NIPC_HEADER_LEN, + .packet_size = NIPC_NP_DEFAULT_PACKET_SIZE, }; - HANDLE thread = CreateThread(NULL, 0, fake_ack_server_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, fake_ack_server_thread, &ctx, 0, NULL); check("zero-budget fake server thread created", thread != NULL); if (!thread) { CloseHandle(ready_event); @@ -2359,12 +2568,14 @@ static void test_zero_chunk_budget_rejected(void) CloseHandle(ready_event); nipc_np_client_config_t ccfg = default_client_config(); - ccfg.packet_size = NIPC_HEADER_LEN; nipc_np_session_t session; nipc_np_error_t err = nipc_np_connect(TEST_RUN_DIR, service, &ccfg, &session); check("zero-budget connect", err == NIPC_NP_OK); if (err == NIPC_NP_OK) { + /* The handshake rejects a packet size without payload room, so + * forge one to reach the send guard. */ + session.packet_size = NIPC_HEADER_LEN; uint8_t payload[1] = { 0x22 }; nipc_header_t hdr = { .kind = NIPC_KIND_REQUEST, @@ -2480,7 +2691,7 @@ static void test_wait_readable(void) .disconnect_ok = 0, }; - HANDLE thread = CreateThread(NULL, 0, wait_readable_server_thread, &ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, wait_readable_server_thread, &ctx, 0, NULL); check("wait-readable server thread created", thread != NULL); if (!thread) { CloseHandle(ready_event); @@ -2646,6 +2857,454 @@ static void test_accept_on_closed_listener(void) check("closed listener close becomes noop", listener.pipe == INVALID_HANDLE_VALUE); } +/* ------------------------------------------------------------------ */ +/* Test: connect while every pipe instance is busy */ +/* ------------------------------------------------------------------ */ + +typedef struct { + nipc_np_listener_t *listener; + int busy_seen; + int accept_ok; +} busy_accept_ctx_t; + +static void busy_hook(void *user) +{ + test_flag_set(&((busy_accept_ctx_t *)user)->busy_seen); +} + +/* Accept the client that holds the only instance once a second client + * found the pipe busy, then serve that second client's handshake. */ +static DWORD WINAPI busy_accept_thread(LPVOID arg) +{ + busy_accept_ctx_t *ctx = (busy_accept_ctx_t *)arg; + nipc_np_server_config_t cfg = default_server_config(); + HANDLE first = INVALID_HANDLE_VALUE; + HANDLE second = INVALID_HANDLE_VALUE; + + if (!test_flag_wait(&ctx->busy_seen)) + return 1; + if (nipc_np_accept_pending(ctx->listener, &first) != NIPC_NP_OK) + return 1; + if (nipc_np_accept_pending(ctx->listener, &second) == NIPC_NP_OK) { + nipc_np_session_t session; + if (nipc_np_accept_handshake(second, &cfg, 2, TEST_WAIT_LIMIT_MS, + &session) == NIPC_NP_OK) { + ctx->accept_ok = 1; + nipc_np_close_session(&session); + } + } + DisconnectNamedPipe(first); + CloseHandle(first); + return 0; +} + +static void test_connect_waits_for_busy_pipe(void) +{ + printf("--- Connect waits while every pipe instance is busy ---\n"); + + char service[64]; + unique_service(service, sizeof(service)); + + nipc_np_server_config_t scfg = default_server_config(); + nipc_np_listener_t listener; + nipc_np_error_t err = nipc_np_listen(TEST_RUN_DIR, service, &scfg, &listener); + check("listen for busy pipe", err == NIPC_NP_OK); + if (err != NIPC_NP_OK) + return; + + /* The first client takes the only instance; the server has not + * accepted it, so no next instance exists yet. */ + wchar_t pipe_name[NIPC_NP_MAX_PIPE_NAME]; + nipc_np_build_pipe_name(pipe_name, NIPC_NP_MAX_PIPE_NAME, TEST_RUN_DIR, service); + HANDLE holder = CreateFileW(pipe_name, GENERIC_READ | GENERIC_WRITE, 0, NULL, + OPEN_EXISTING, 0, NULL); + check("first client holds the only instance", holder != INVALID_HANDLE_VALUE); + + busy_accept_ctx_t ctx = { .listener = &listener }; + nipc_np_set_connect_busy_hook_for_tests(busy_hook, &ctx); + HANDLE thread = test_create_thread(NULL, 0, busy_accept_thread, &ctx, 0, NULL); + + nipc_np_client_config_t ccfg = default_client_config(); + nipc_np_session_t session; + err = nipc_np_connect(TEST_RUN_DIR, service, &ccfg, &session); + check("busy pipe connect waits and succeeds", err == NIPC_NP_OK); + if (err == NIPC_NP_OK) + nipc_np_close_session(&session); + + /* Release the accept thread when connect failed without waiting. */ + test_flag_set(&ctx.busy_seen); + if (err != NIPC_NP_OK) + nipc_np_close_listener(&listener); + WaitForSingleObject(thread, TEST_WAIT_LIMIT_MS); + CloseHandle(thread); + nipc_np_set_connect_busy_hook_for_tests(NULL, NULL); + check("server accepted the waiting client", ctx.accept_ok); + + if (holder != INVALID_HANDLE_VALUE) + CloseHandle(holder); + nipc_np_close_listener(&listener); +} + + +typedef struct { + nipc_np_listener_t *listener; + const wchar_t *pipe_name; + HANDLE thief; + int busy_seen; + int thief_ready; + int accept_ok; +} stolen_accept_ctx_t; + +static void stolen_busy_hook(void *user) +{ + test_flag_set(&((stolen_accept_ctx_t *)user)->busy_seen); +} + +/* Runs in the connecting thread once WaitNamedPipeW reported the instance + * the server created on accept: a third client takes it first, once. */ +static void stolen_waited_hook(void *user) +{ + stolen_accept_ctx_t *ctx = (stolen_accept_ctx_t *)user; + if (ctx->thief != INVALID_HANDLE_VALUE) + return; + ctx->thief = CreateFileW(ctx->pipe_name, GENERIC_READ | GENERIC_WRITE, 0, + NULL, OPEN_EXISTING, 0, NULL); + test_flag_set(&ctx->thief_ready); +} + +/* Accept the holder once the client found the pipe busy, then the thief + * that took the freed instance, then the waiting client's handshake. */ +static DWORD WINAPI stolen_accept_thread(LPVOID arg) +{ + stolen_accept_ctx_t *ctx = (stolen_accept_ctx_t *)arg; + nipc_np_server_config_t cfg = default_server_config(); + HANDLE first = INVALID_HANDLE_VALUE; + HANDLE second = INVALID_HANDLE_VALUE; + HANDLE third = INVALID_HANDLE_VALUE; + + if (!test_flag_wait(&ctx->busy_seen)) + return 1; + if (nipc_np_accept_pending(ctx->listener, &first) != NIPC_NP_OK) + return 1; + if (test_flag_wait(&ctx->thief_ready) && + nipc_np_accept_pending(ctx->listener, &second) == NIPC_NP_OK && + nipc_np_accept_pending(ctx->listener, &third) == NIPC_NP_OK) { + nipc_np_session_t session; + if (nipc_np_accept_handshake(third, &cfg, 3, TEST_WAIT_LIMIT_MS, + &session) == NIPC_NP_OK) { + ctx->accept_ok = 1; + nipc_np_close_session(&session); + } + } + if (second != INVALID_HANDLE_VALUE) { + DisconnectNamedPipe(second); + CloseHandle(second); + } + DisconnectNamedPipe(first); + CloseHandle(first); + return 0; +} + +static void test_connect_retries_stolen_instance(void) +{ + printf("--- Connect retries when another client takes the freed instance ---\n"); + + char service[64]; + unique_service(service, sizeof(service)); + + nipc_np_server_config_t scfg = default_server_config(); + nipc_np_listener_t listener; + nipc_np_error_t err = nipc_np_listen(TEST_RUN_DIR, service, &scfg, &listener); + check("listen for stolen instance", err == NIPC_NP_OK); + if (err != NIPC_NP_OK) + return; + + wchar_t pipe_name[NIPC_NP_MAX_PIPE_NAME]; + nipc_np_build_pipe_name(pipe_name, NIPC_NP_MAX_PIPE_NAME, TEST_RUN_DIR, service); + HANDLE holder = CreateFileW(pipe_name, GENERIC_READ | GENERIC_WRITE, 0, NULL, + OPEN_EXISTING, 0, NULL); + check("holder takes the only instance", holder != INVALID_HANDLE_VALUE); + + stolen_accept_ctx_t ctx = { .listener = &listener, .pipe_name = pipe_name, + .thief = INVALID_HANDLE_VALUE }; + nipc_np_set_connect_busy_hook_for_tests(stolen_busy_hook, &ctx); + nipc_np_set_connect_waited_hook_for_tests(stolen_waited_hook, &ctx); + HANDLE thread = test_create_thread(NULL, 0, stolen_accept_thread, &ctx, 0, NULL); + + nipc_np_client_config_t ccfg = default_client_config(); + nipc_np_session_t session; + err = nipc_np_connect(TEST_RUN_DIR, service, &ccfg, &session); + check("thief took the freed instance", ctx.thief != INVALID_HANDLE_VALUE); + check("connect retries after the instance was taken", err == NIPC_NP_OK); + if (err == NIPC_NP_OK) + nipc_np_close_session(&session); + + /* Release the accept thread when connect failed before its steps. */ + test_flag_set(&ctx.busy_seen); + test_flag_set(&ctx.thief_ready); + if (err != NIPC_NP_OK) + nipc_np_close_listener(&listener); + WaitForSingleObject(thread, TEST_WAIT_LIMIT_MS); + CloseHandle(thread); + nipc_np_set_connect_busy_hook_for_tests(NULL, NULL); + nipc_np_set_connect_waited_hook_for_tests(NULL, NULL); + check("server accepted the retrying client", ctx.accept_ok); + + if (ctx.thief != INVALID_HANDLE_VALUE) + CloseHandle(ctx.thief); + if (holder != INVALID_HANDLE_VALUE) + CloseHandle(holder); + nipc_np_close_listener(&listener); +} + +/* The disconnected instance takes no client until accept listens on it + * again, so the next client finds the pipe busy first. */ +static DWORD WINAPI failed_instance_accept_thread(LPVOID arg) +{ + busy_accept_ctx_t *ctx = (busy_accept_ctx_t *)arg; + nipc_np_server_config_t cfg = default_server_config(); + HANDLE pipe = INVALID_HANDLE_VALUE; + + if (!test_flag_wait(&ctx->busy_seen)) + return 1; + if (nipc_np_accept_pending(ctx->listener, &pipe) != NIPC_NP_OK) + return 1; + nipc_np_session_t session; + if (nipc_np_accept_handshake(pipe, &cfg, 1, TEST_WAIT_LIMIT_MS, + &session) == NIPC_NP_OK) { + ctx->accept_ok = 1; + nipc_np_close_session(&session); + } + return 0; +} + + +/* A failed replacement instance drops the accepted client and keeps the + * listener's instance, so the next client is still accepted. */ +static void test_accept_survives_failed_instance(void) +{ + printf("--- Accept keeps listening after a failed replacement instance ---\n"); + + char service[64]; + unique_service(service, sizeof(service)); + + nipc_np_server_config_t scfg = default_server_config(); + nipc_np_listener_t listener; + nipc_np_error_t err = nipc_np_listen(TEST_RUN_DIR, service, &scfg, &listener); + check("listen for failed instance", err == NIPC_NP_OK); + if (err != NIPC_NP_OK) + return; + + wchar_t pipe_name[NIPC_NP_MAX_PIPE_NAME]; + nipc_np_build_pipe_name(pipe_name, NIPC_NP_MAX_PIPE_NAME, TEST_RUN_DIR, service); + HANDLE dropped = CreateFileW(pipe_name, GENERIC_READ | GENERIC_WRITE, 0, NULL, + OPEN_EXISTING, 0, NULL); + check("first client connects", dropped != INVALID_HANDLE_VALUE); + + nipc_np_fail_next_instances_for_tests(1); + HANDLE session_pipe = INVALID_HANDLE_VALUE; + err = nipc_np_accept_pending(&listener, &session_pipe); + nipc_np_fail_next_instances_for_tests(0); + check("accept reports the failed instance", err == NIPC_NP_ERR_CREATE_PIPE); + check("no session pipe is returned", session_pipe == INVALID_HANDLE_VALUE); + check("listener keeps its instance", listener.pipe != INVALID_HANDLE_VALUE); + + DWORD avail = 0; + BOOL peek = PeekNamedPipe(dropped, NULL, 0, NULL, &avail, NULL); + check("first client was disconnected", !peek); + + busy_accept_ctx_t ctx = { .listener = &listener }; + nipc_np_set_connect_busy_hook_for_tests(busy_hook, &ctx); + HANDLE thread = test_create_thread(NULL, 0, failed_instance_accept_thread, + &ctx, 0, NULL); + + nipc_np_client_config_t ccfg = default_client_config(); + nipc_np_session_t session; + err = nipc_np_connect_timeout(TEST_RUN_DIR, service, &ccfg, + TEST_WAIT_LIMIT_MS, NULL, &session); + check("next client connects", err == NIPC_NP_OK); + if (err == NIPC_NP_OK) + nipc_np_close_session(&session); + + test_flag_set(&ctx.busy_seen); + if (err != NIPC_NP_OK) + nipc_np_close_listener(&listener); + WaitForSingleObject(thread, TEST_WAIT_LIMIT_MS); + CloseHandle(thread); + nipc_np_set_connect_busy_hook_for_tests(NULL, NULL); + check("server accepted the next client", ctx.accept_ok); + + if (dropped != INVALID_HANDLE_VALUE) + CloseHandle(dropped); + nipc_np_close_listener(&listener); +} + + +/* ------------------------------------------------------------------ */ +/* Test: access policy */ +/* ------------------------------------------------------------------ */ + +static void test_pipe_dacl_owner_only(void) +{ + printf("--- Pipe DACL grants only SYSTEM, Administrators and self ---\n"); + + char service[64]; + unique_service(service, sizeof(service)); + + nipc_np_server_config_t cfg = default_server_config(); + nipc_np_listener_t listener; + nipc_np_error_t err = nipc_np_listen(TEST_RUN_DIR, service, &cfg, &listener); + check("listen for DACL check", err == NIPC_NP_OK); + if (err != NIPC_NP_OK) + return; + + check("first instance DACL is owner-only", + test_object_dacl_owner_only(listener.pipe, FILE_ALL_ACCESS)); + + /* Instances created after an accept carry the same DACL. */ + wchar_t pipe_name[NIPC_NP_MAX_PIPE_NAME]; + nipc_np_build_pipe_name(pipe_name, NIPC_NP_MAX_PIPE_NAME, TEST_RUN_DIR, service); + HANDLE client = CreateFileW(pipe_name, GENERIC_READ | GENERIC_WRITE, 0, NULL, + OPEN_EXISTING, 0, NULL); + HANDLE session_pipe = INVALID_HANDLE_VALUE; + err = nipc_np_accept_pending(&listener, &session_pipe); + check("accept pending for DACL check", err == NIPC_NP_OK); + check("next instance DACL is owner-only", + err == NIPC_NP_OK && + test_object_dacl_owner_only(listener.pipe, FILE_ALL_ACCESS)); + + if (session_pipe != INVALID_HANDLE_VALUE) + CloseHandle(session_pipe); + if (client != INVALID_HANDLE_VALUE) + CloseHandle(client); + nipc_np_close_listener(&listener); +} + +static void test_remote_client_rejected(void) +{ + printf("--- Pipe rejects remote clients ---\n"); + + char service[64]; + unique_service(service, sizeof(service)); + + nipc_np_server_config_t cfg = default_server_config(); + nipc_np_listener_t listener; + nipc_np_error_t err = nipc_np_listen(TEST_RUN_DIR, service, &cfg, &listener); + check("listen for remote client check", err == NIPC_NP_OK); + if (err != NIPC_NP_OK) + return; + + /* The same pipe through the SMB loopback is a remote client. */ + wchar_t pipe_name[NIPC_NP_MAX_PIPE_NAME]; + wchar_t remote_name[NIPC_NP_MAX_PIPE_NAME + 16]; + nipc_np_build_pipe_name(pipe_name, NIPC_NP_MAX_PIPE_NAME, TEST_RUN_DIR, service); + wcscpy(remote_name, L"\\\\127.0.0.1\\"); + wcscat(remote_name, pipe_name + 4); + HANDLE remote = CreateFileW(remote_name, GENERIC_READ | GENERIC_WRITE, 0, NULL, + OPEN_EXISTING, 0, NULL); + check("remote open is refused", remote == INVALID_HANDLE_VALUE); + if (remote != INVALID_HANDLE_VALUE) + CloseHandle(remote); + + nipc_np_close_listener(&listener); +} + +typedef struct { + nipc_np_listener_t *listener; + int accept_ok; + SECURITY_IMPERSONATION_LEVEL level; +} identify_ctx_t; + +/* Accept one client and record the impersonation level it granted. */ +static DWORD WINAPI identify_accept_thread(LPVOID arg) +{ + identify_ctx_t *ctx = (identify_ctx_t *)arg; + ctx->level = SecurityAnonymous; + + nipc_np_session_t session; + if (nipc_np_accept(ctx->listener, 1, &session) != NIPC_NP_OK) + return 1; + ctx->accept_ok = 1; + + if (ImpersonateNamedPipeClient(session.pipe)) { + HANDLE token; + if (OpenThreadToken(GetCurrentThread(), TOKEN_QUERY, TRUE, &token)) { + DWORD len = 0; + GetTokenInformation(token, TokenImpersonationLevel, &ctx->level, + sizeof(ctx->level), &len); + CloseHandle(token); + } + RevertToSelf(); + } + nipc_np_close_session(&session); + return 0; +} + +static void test_untrusted_server_rejected(void) +{ + printf("--- Client refuses a pipe owned by an untrusted account ---\n"); + + char service[64]; + unique_service(service, sizeof(service)); + + nipc_np_server_config_t scfg = default_server_config(); + nipc_np_listener_t listener; + nipc_np_error_t err = nipc_np_listen(TEST_RUN_DIR, service, &scfg, &listener); + check("listen for owner check", err == NIPC_NP_OK); + if (err != NIPC_NP_OK) + return; + + nipc_np_client_config_t ccfg = default_client_config(); + nipc_np_session_t session; + + /* BUILTIN\Users never owns the test pipe. */ + nipc_np_set_trusted_owner_for_tests("S-1-5-32-545"); + /* Nobody accepts, so a client that skipped the check fails on the + * deadline instead of hanging. */ + err = nipc_np_connect_timeout(TEST_RUN_DIR, service, &ccfg, 2000, NULL, + &session); + check("connect refuses the untrusted owner", + err == NIPC_NP_ERR_UNTRUSTED_SERVER); + if (err == NIPC_NP_OK) + nipc_np_close_session(&session); + + /* The refused client left the only instance closing; start over. */ + nipc_np_close_listener(&listener); + unique_service(service, sizeof(service)); + err = nipc_np_listen(TEST_RUN_DIR, service, &scfg, &listener); + check("listen for trusted owner", err == NIPC_NP_OK); + if (err != NIPC_NP_OK) { + nipc_np_set_trusted_owner_for_tests(NULL); + return; + } + + /* Trusting the real owner lets the client through, and the server sees + * only an identification-level token for it. */ + char *owner = test_object_owner_sid(listener.pipe); + check("read pipe owner", owner != NULL); + nipc_np_set_trusted_owner_for_tests(owner); + + identify_ctx_t ictx = { .listener = &listener }; + HANDLE thread = test_create_thread(NULL, 0, identify_accept_thread, &ictx, 0, NULL); + err = nipc_np_connect(TEST_RUN_DIR, service, &ccfg, &session); + check("connect accepts the trusted owner", err == NIPC_NP_OK); + if (err != NIPC_NP_OK) + nipc_np_close_listener(&listener); + WaitForSingleObject(thread, TEST_WAIT_LIMIT_MS); + CloseHandle(thread); + if (err == NIPC_NP_OK) { + check("server accepted the trusted client", ictx.accept_ok); + check("server can only identify the client", + ictx.level == SecurityIdentification); + nipc_np_close_session(&session); + } + + nipc_np_set_trusted_owner_for_tests(NULL); + if (owner) + LocalFree(owner); + nipc_np_close_listener(&listener); +} + /* ------------------------------------------------------------------ */ /* Test: service name validation */ /* ------------------------------------------------------------------ */ @@ -2697,6 +3356,7 @@ int main(void) test_chunked_ping_pong(); test_chunked_receive_reuses_buffer(); test_chunked_pipeline(); + test_pipeline_beyond_pipe_buffers(); test_auth_failure(); test_duplicate_message_id(); test_unknown_response_message_id(); @@ -2714,6 +3374,12 @@ int main(void) test_addr_in_use(); test_bad_params_and_noop_close(); test_accept_on_closed_listener(); + test_connect_waits_for_busy_pipe(); + test_connect_retries_stolen_instance(); + test_accept_survives_failed_instance(); + test_pipe_dacl_owner_only(); + test_remote_client_rejected(); + test_untrusted_server_rejected(); printf("\n=== Results: %d passed, %d failed ===\n", g_pass, g_fail); return g_fail ? 1 : 0; diff --git a/tests/fixtures/c/test_ping_pong.c b/tests/fixtures/c/test_ping_pong.c index 0b3adc27..d8b9402f 100644 --- a/tests/fixtures/c/test_ping_pong.c +++ b/tests/fixtures/c/test_ping_pong.c @@ -8,6 +8,7 @@ #include "netipc/netipc_service.h" #include "netipc/netipc_protocol.h" +#include "test_sync.h" #include #include @@ -142,13 +143,14 @@ static bool start_server(server_ctx_t *sctx) &scfg, 4, &service_handler); if (err != NIPC_OK) return false; + test_server_use_test_timings(&sctx->server, NULL); pthread_t tid; if (pthread_create(&tid, NULL, server_thread, sctx) != 0) return false; sctx->thread = tid; - usleep(50000); /* 50ms for server to start */ + /* init bound the listener, so clients may connect right away. */ sctx->started = true; return true; } diff --git a/tests/fixtures/c/test_robustness.c b/tests/fixtures/c/test_robustness.c new file mode 100644 index 00000000..01e52780 --- /dev/null +++ b/tests/fixtures/c/test_robustness.c @@ -0,0 +1,1280 @@ +/* + * test_robustness.c - Robustness reproduction tests (SOW-0040), POSIX. + * + * Each test asserts the correct behavior. Every blocking step runs on a + * helper thread joined with a deadline, so a regression fails instead of + * hanging. Bounds are deliberately lenient so they do not dictate a fix. + * Tests wait on server events and peer signals, never on sleeps. + * + * Tests: + * K1 SHM sessions released after clean close (guard: C is correct) + * K1 SHM sessions released after client crash (guard: C is correct) + * K3 silent client does not block other clients + * K3 silent client does not block shutdown + * K3 silent client's worker is freed at the server handshake deadline + * K4 client bounded when server never accepts + * K4 client bounded when server never sends HELLO_ACK + * K4 a call's reconnect is bounded by the call deadline + * K4 abort ends a refresh waiting for HELLO_ACK + * N1 call honors a timeout longer than 100 ms (guard: C is correct) + * N2 shutdown not blocked by a client that stopped reading + * N3 stop before run is honored + * N5 SHM client notices a crashed server before its timeout + * N5 baseline client reports a crashed server as disconnected + * N5 SHM attach stops when the server closes the session + * CLOEXEC every NetIPC socket and pipe is close-on-exec + * N11 default packet size carries a message larger than SO_SNDBUF + * (plus an explicit-packet-size control) + * + * The binary re-executes itself with --helper-shm-client / --helper-shm-server / + * --helper-uds-server to simulate a peer process that is killed with SIGKILL mid-session. + */ + +#define _GNU_SOURCE +#include "netipc/netipc_service.h" +#include "netipc/netipc_protocol.h" +#include "netipc/netipc_uds.h" +#include "netipc/netipc_shm.h" +#include "test_sync.h" +#include "uds_test_socket.h" + +#include +#include +#include +#include +#include +#include +#if defined(__FreeBSD__) +#include +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define TEST_RUN_DIR "/tmp/nipc_robustness_test" +#define AUTH_TOKEN 0xDEADBEEFCAFEBABEull +#define RESPONSE_BUF_SIZE 65536 + +/* Absolute path of this binary, for re-executing it in helper mode. */ +static char g_self_path[PATH_MAX]; + +static int g_pass = 0; +static int g_fail = 0; + +static void check(const char *name, int cond) +{ + if (cond) { + printf(" PASS: %s\n", name); + g_pass++; + } else { + printf(" FAIL: %s\n", name); + g_fail++; + } + fflush(stdout); +} + +static uint64_t now_ms(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000u + (uint64_t)ts.tv_nsec / 1000000u; +} + +static void sleep_ms(unsigned ms) +{ + usleep(ms * 1000u); +} + +static void cleanup_all(const char *service) +{ + char path[256]; + snprintf(path, sizeof(path), "%s/%s.sock", TEST_RUN_DIR, service); + unlink(path); + nipc_shm_cleanup_stale(TEST_RUN_DIR, service); +} + +/* Join with a deadline, portably (macOS has no pthread_timedjoin_np): a + * helper thread joins the target and signals. Returns true if the thread + * finished in time; otherwise the join stays pending and join_rest() + * completes it. */ +typedef struct { + pthread_t target; + pthread_t helper; + pthread_mutex_t lock; + pthread_cond_t cond; + bool done; +} timed_join_t; + +#define MAX_PENDING_JOINS 8 +static timed_join_t *g_pending_joins[MAX_PENDING_JOINS]; + +static void *timed_join_helper(void *arg) +{ + timed_join_t *j = arg; + pthread_join(j->target, NULL); + pthread_mutex_lock(&j->lock); + j->done = true; + pthread_cond_signal(&j->cond); + pthread_mutex_unlock(&j->lock); + return NULL; +} + +static void timed_join_free(timed_join_t *j) +{ + pthread_join(j->helper, NULL); + pthread_cond_destroy(&j->cond); + pthread_mutex_destroy(&j->lock); + free(j); +} + +static bool join_within(pthread_t tid, unsigned limit_ms) +{ + timed_join_t *j = calloc(1, sizeof(*j)); + if (!j) + abort(); + j->target = tid; + pthread_mutex_init(&j->lock, NULL); + pthread_cond_init(&j->cond, NULL); + pthread_create(&j->helper, NULL, timed_join_helper, j); + + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + ts.tv_sec += limit_ms / 1000u; + ts.tv_nsec += (long)(limit_ms % 1000u) * 1000000L; + if (ts.tv_nsec >= 1000000000L) { + ts.tv_sec++; + ts.tv_nsec -= 1000000000L; + } + + pthread_mutex_lock(&j->lock); + while (!j->done) { + if (pthread_cond_timedwait(&j->cond, &j->lock, &ts) != 0) + break; + } + bool done = j->done; + pthread_mutex_unlock(&j->lock); + + if (done) { + timed_join_free(j); + return true; + } + for (int i = 0; i < MAX_PENDING_JOINS; i++) { + if (!g_pending_joins[i]) { + g_pending_joins[i] = j; + return false; + } + } + abort(); +} + +/* Finish the join of a thread whose join_within() timed out. */ +static void join_rest(pthread_t tid) +{ + for (int i = 0; i < MAX_PENDING_JOINS; i++) { + timed_join_t *j = g_pending_joins[i]; + if (j && pthread_equal(j->target, tid)) { + g_pending_joins[i] = NULL; + timed_join_free(j); + return; + } + } + abort(); +} + +#if defined(__linux__) || defined(__FreeBSD__) +static int count_shm_files(const char *service) +{ + DIR *d = opendir(TEST_RUN_DIR); + if (!d) + return 0; + size_t plen = strlen(service); + int n = 0; + struct dirent *e; + while ((e = readdir(d)) != NULL) { + size_t len = strlen(e->d_name); + if (len > plen + 7 && strncmp(e->d_name, service, plen) == 0 && + e->d_name[plen] == '-' && strcmp(e->d_name + len - 7, ".ipcshm") == 0) + n++; + } + closedir(d); + return n; +} +#endif + +/* ------------------------------------------------------------------ */ +/* Server */ +/* ------------------------------------------------------------------ */ + +typedef struct { + unsigned delay_ms; /* handler delay */ + size_t raw_len; /* if non-zero, reply with raw_len opaque bytes */ + bool block; /* report entry on stdout, then block until killed */ + int handled; /* __atomic: raised once a raw reply is built */ +} handler_opts_t; + +static nipc_error_t robust_handler(void *user, const nipc_header_t *request_hdr, + const uint8_t *request_payload, size_t request_len, + uint8_t *response_buf, size_t response_buf_size, + size_t *response_len_out) +{ + handler_opts_t *opts = (handler_opts_t *)user; + (void)request_hdr; + + if (opts && opts->block) { + printf("HELPER_HANDLER_ENTERED\n"); + fflush(stdout); + for (;;) + pause(); + } + + /* N1 stimulus: a handler slower than the 100 ms abort-poll slice. */ + if (opts && opts->delay_ms) + sleep_ms(opts->delay_ms); + + if (opts && opts->raw_len) { + if (opts->raw_len > response_buf_size) + return NIPC_ERR_OVERFLOW; + memset(response_buf, 0, opts->raw_len); + *response_len_out = opts->raw_len; + test_flag_set(&opts->handled); + return NIPC_OK; + } + + nipc_cgroups_req_t req; + nipc_error_t err = nipc_cgroups_req_decode(request_payload, request_len, &req); + if (err != NIPC_OK) + return err; + + nipc_cgroups_builder_t builder; + nipc_cgroups_builder_init(&builder, response_buf, response_buf_size, 1, 1, 42); + err = nipc_cgroups_builder_add(&builder, 1001, 0, 1, "rob-cg", 6, "/sys/fs/cgroup/rob", 18); + if (err != NIPC_OK) + return err; + *response_len_out = nipc_cgroups_builder_finish(&builder); + return (*response_len_out > 0) ? NIPC_OK : NIPC_ERR_OVERFLOW; +} + +typedef struct { + const char *service; + int workers; + bool shm; + uint32_t max_payload; + uint32_t packet_size; + handler_opts_t opts; + nipc_managed_server_t server; + test_server_events_t events; + pthread_t tid; +} robust_server_t; + +static nipc_uds_server_config_t robust_server_config(const robust_server_t *rs) +{ + nipc_uds_server_config_t scfg = { + .supported_profiles = NIPC_PROFILE_BASELINE | + (rs->shm ? NIPC_PROFILE_SHM_HYBRID : 0), + .preferred_profiles = rs->shm ? NIPC_PROFILE_SHM_HYBRID : 0, + .max_request_payload_bytes = rs->max_payload ? rs->max_payload : 4096, + .max_request_batch_items = 1, + .max_response_payload_bytes = rs->max_payload ? rs->max_payload : RESPONSE_BUF_SIZE, + .max_response_batch_items = 1, + .auth_token = AUTH_TOKEN, + .packet_size = rs->packet_size, + .backlog = 16, + }; + return scfg; +} + +static void *robust_server_fn(void *arg) +{ + robust_server_t *rs = (robust_server_t *)arg; + nipc_server_run(&rs->server); + nipc_server_destroy(&rs->server); + return NULL; +} + +static bool start_server(robust_server_t *rs) +{ + cleanup_all(rs->service); + nipc_uds_server_config_t scfg = robust_server_config(rs); + nipc_error_t err = nipc_server_init(&rs->server, TEST_RUN_DIR, rs->service, &scfg, + rs->workers, NIPC_METHOD_CGROUPS_SNAPSHOT, + robust_handler, &rs->opts); + if (err != NIPC_OK) { + fprintf(stderr, "server init failed: %d\n", err); + return false; + } + /* init binds the listener: clients may connect once it returns. */ + test_server_use_test_timings(&rs->server, &rs->events); + pthread_create(&rs->tid, NULL, robust_server_fn, rs); + return true; +} + +/* Stop and report whether run+destroy finished within limit_ms. */ +static bool stop_server_within(robust_server_t *rs, unsigned limit_ms) +{ + nipc_server_stop(&rs->server); + return join_within(rs->tid, limit_ms); +} + +/* ------------------------------------------------------------------ */ +/* Clients */ +/* ------------------------------------------------------------------ */ + +static nipc_client_config_t client_config(bool shm) +{ + nipc_client_config_t ccfg = { + .supported_profiles = NIPC_PROFILE_BASELINE | (shm ? NIPC_PROFILE_SHM_HYBRID : 0), + .preferred_profiles = shm ? NIPC_PROFILE_SHM_HYBRID : 0, + .max_request_batch_items = 1, + .max_response_payload_bytes = RESPONSE_BUF_SIZE, + .auth_token = AUTH_TOKEN, + .call_timeout_ms = 1000, + }; + return ccfg; +} + +/* Connect and complete one snapshot call, in one attempt. */ +static bool served_once(const char *service, bool shm) +{ + nipc_client_config_t ccfg = client_config(shm); + nipc_client_ctx_t client; + nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); + nipc_client_refresh(&client); + bool ok = false; + if (nipc_client_ready(&client)) { + nipc_cgroups_resp_view_t view; + nipc_error_t err = nipc_client_call_cgroups_snapshot_timeout(&client, &view, 1000); + ok = (err == NIPC_OK && view.item_count == 1); + } + nipc_client_close(&client); + return ok; +} + +typedef struct { + const char *service; + bool shm; + bool result; +} served_job_t; + +static void *served_job_fn(void *arg) +{ + served_job_t *job = (served_job_t *)arg; + job->result = served_once(job->service, job->shm); + return NULL; +} + +static int raw_socket_connect(const char *service) +{ + char path[256]; + snprintf(path, sizeof(path), "%s/%s.sock", TEST_RUN_DIR, service); + int fd = socket(AF_UNIX, TEST_UDS_SOCK_TYPE, 0); + if (fd < 0) + return -1; + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + close(fd); + return -1; + } + return fd; +} + +static int raw_listener(const char *service) +{ + char path[256]; + snprintf(path, sizeof(path), "%s/%s.sock", TEST_RUN_DIR, service); + unlink(path); + int fd = socket(AF_UNIX, TEST_UDS_SOCK_TYPE, 0); + if (fd < 0) + return -1; + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); + if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0 || listen(fd, 4) < 0) { + close(fd); + return -1; + } + return fd; +} + +/* ------------------------------------------------------------------ */ +/* Crash helper (child process) */ +/* ------------------------------------------------------------------ */ + +#if defined(__linux__) || defined(__FreeBSD__) +static int helper_shm_client(const char *service) +{ + nipc_client_config_t ccfg = client_config(true); + nipc_client_ctx_t client; + nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); + nipc_client_refresh(&client); + nipc_cgroups_resp_view_t view; + if (nipc_client_call_cgroups_snapshot_timeout(&client, &view, 2000) != NIPC_OK) { + printf("HELPER_FAILED\n"); + return 1; + } + printf("HELPER_READY\n"); + fflush(stdout); + for (;;) + pause(); +} +#endif + +/* Server whose handler stalls, so a client call is in flight when the + * parent kills this process. */ +static int helper_stalled_server(const char *service, bool shm) +{ + static robust_server_t rs; + rs = (robust_server_t){.service = service, .workers = 2, .shm = shm, + .opts = {.block = true}}; + if (!start_server(&rs)) { + printf("HELPER_FAILED\n"); + return 1; + } + printf("HELPER_READY\n"); + fflush(stdout); + for (;;) + pause(); +} + +/* Wait until the helper's stdout carries marker. Returns false on EOF or + * after TEST_WAIT_LIMIT_MS. */ +static bool helper_wait_line(int fd, const char *marker) +{ + char buf[256]; + size_t used = 0; + uint64_t deadline = now_ms() + TEST_WAIT_LIMIT_MS; + while (used < sizeof(buf) - 1) { + uint64_t now = now_ms(); + if (now >= deadline) + return false; + struct pollfd pfd = {.fd = fd, .events = POLLIN}; + if (poll(&pfd, 1, (int)(deadline - now)) <= 0) + continue; + /* One byte at a time, so a later marker stays in the pipe. */ + ssize_t n = read(fd, buf + used, 1); + if (n <= 0) + return false; + used += (size_t)n; + buf[used] = '\0'; + if (strstr(buf, marker) != NULL) + return true; + if (buf[used - 1] == '\n') + used = 0; + } + return false; +} + +/* Spawn this binary in helper mode and wait for HELPER_READY. When out_fd + * is given it receives the helper's stdout for later markers. */ +static pid_t spawn_helper(const char *mode, const char *service, int *out_fd) +{ + int pipefd[2]; + if (pipe(pipefd) < 0) + return -1; + pid_t pid = fork(); + if (pid == 0) { + dup2(pipefd[1], STDOUT_FILENO); + close(pipefd[0]); + close(pipefd[1]); + execl(g_self_path, "test_robustness", mode, service, (char *)NULL); + _exit(127); + } + close(pipefd[1]); + if (pid < 0) { + close(pipefd[0]); + return -1; + } + + if (!helper_wait_line(pipefd[0], "HELPER_READY")) { + close(pipefd[0]); + kill(pid, SIGKILL); + waitpid(pid, NULL, 0); + return -1; + } + if (out_fd) + *out_fd = pipefd[0]; + else + close(pipefd[0]); + return pid; +} + +/* ------------------------------------------------------------------ */ +/* K1: SHM sessions released after the client leaves */ +/* ------------------------------------------------------------------ */ + +#if defined(__linux__) || defined(__FreeBSD__) +static void test_shm_sessions_released_after_clean_close(void) +{ + printf("K1: SHM sessions released after clean close\n"); + robust_server_t rs = {.service = "rob_shm_clean", .workers = 2, .shm = true}; + if (!start_server(&rs)) { + check("server started", 0); + return; + } + + /* Each worker must exit and remove its region once its client closes; + * a leaked session fails the session-exit wait. */ + int served = 0, exited = 0; + for (int i = 0; i < 5; i++) { + served += served_once(rs.service, true) ? 1 : 0; + exited += test_server_wait_session_exits(&rs.events, 1) ? 1 : 0; + } + int leftover = count_shm_files(rs.service); + + check("5 sequential SHM clients served by 2 workers", served == 5); + check("every SHM session exited after its client closed", exited == 5); + check("no SHM regions left behind", leftover == 0); + stop_server_within(&rs, 5000); + cleanup_all(rs.service); +} +#endif + +#if defined(__linux__) || defined(__FreeBSD__) +static void test_shm_sessions_released_after_client_crash(void) +{ + printf("K1: SHM sessions released after client crash\n"); + robust_server_t rs = {.service = "rob_shm_crash", .workers = 2, .shm = true}; + if (!start_server(&rs)) { + check("server started", 0); + return; + } + + bool spawned = true; + for (int i = 0; i < 2; i++) { + pid_t pid = spawn_helper("--helper-shm-client", rs.service, NULL); + if (pid < 0) { + spawned = false; + break; + } + kill(pid, SIGKILL); + waitpid(pid, NULL, 0); + } + check("crash helpers held SHM sessions", spawned); + check("both workers freed their crashed clients' slots", + spawned && test_server_wait_session_exits(&rs.events, 2)); + check("new client served after 2 crashed SHM clients on 2 workers", + served_once(rs.service, true)); + check("new client's session exited", + test_server_wait_session_exits(&rs.events, 1)); + check("no SHM regions left behind", count_shm_files(rs.service) == 0); + stop_server_within(&rs, 5000); + cleanup_all(rs.service); +} +#endif + +/* ------------------------------------------------------------------ */ +/* K3: silent client */ +/* ------------------------------------------------------------------ */ + +static void test_silent_client_does_not_block_other_clients(void) +{ + printf("K3: silent client does not block other clients\n"); + robust_server_t rs = {.service = "rob_silent", .workers = 4}; + if (!start_server(&rs)) { + check("server started", 0); + return; + } + + int silent = raw_socket_connect(rs.service); + check("silent client connected", silent >= 0); + check("a worker waits for the silent client's HELLO", + test_server_wait_handshakes(&rs.events, 1)); + + served_job_t job = {.service = rs.service}; + pthread_t tid; + pthread_create(&tid, NULL, served_job_fn, &job); + bool finished = join_within(tid, 5000); + + if (silent >= 0) + close(silent); + if (!finished) + join_rest(tid); + check("legitimate client served within 5s while a silent client is connected", + finished && job.result); + stop_server_within(&rs, 5000); + cleanup_all(rs.service); +} + +static void test_silent_client_does_not_block_shutdown(void) +{ + printf("K3: silent client does not block shutdown\n"); + robust_server_t rs = {.service = "rob_silent_stop", .workers = 4}; + if (!start_server(&rs)) { + check("server started", 0); + return; + } + + int silent = raw_socket_connect(rs.service); + check("a worker waits for the silent client's HELLO", + test_server_wait_handshakes(&rs.events, 1)); + bool stopped = stop_server_within(&rs, 3000); + + if (silent >= 0) + close(silent); + if (!stopped) + join_rest(rs.tid); + check("server stop + destroy finish within 3s while a silent client is connected", + stopped); + cleanup_all(rs.service); +} + +static void test_silent_client_released_at_handshake_deadline(void) +{ + printf("K3: silent client released at the handshake deadline\n"); + robust_server_t rs = {.service = "rob_silent_deadline", .workers = 1}; + if (!start_server(&rs)) { + check("server started", 0); + return; + } + + int silent = raw_socket_connect(rs.service); + check("silent client connected", silent >= 0); + uint64_t start = now_ms(); + bool released = test_server_wait_handshakes(&rs.events, 1) && + test_server_wait_session_exits(&rs.events, 1); + uint64_t elapsed = now_ms() - start; + check("the silent client's worker exits", released); + check("the worker waited for the handshake deadline", + elapsed >= TEST_SERVER_HANDSHAKE_MS); + check("the only worker serves a new client once the silent one misses the deadline", + served_once(rs.service, false)); + + if (silent >= 0) { + char ch; + ssize_t n = recv(silent, &ch, 1, MSG_DONTWAIT); + check("server closed the silent connection at the deadline", n == 0); + close(silent); + } + stop_server_within(&rs, 5000); + cleanup_all(rs.service); +} + +/* ------------------------------------------------------------------ */ +/* K4: client bounded when the server never completes HELLO */ +/* ------------------------------------------------------------------ */ + +typedef struct { + const char *service; +} bounded_job_t; + +static void *bounded_client_fn(void *arg) +{ + bounded_job_t *job = (bounded_job_t *)arg; + nipc_client_config_t ccfg = client_config(false); + nipc_client_ctx_t client; + nipc_client_init(&client, TEST_RUN_DIR, job->service, &ccfg); + nipc_client_refresh(&client); + nipc_cgroups_resp_view_t view; + (void)nipc_client_call_cgroups_snapshot_timeout(&client, &view, 1000); + nipc_client_close(&client); + return NULL; +} + +static void test_client_bounded_when_server_never_accepts(void) +{ + printf("K4: client bounded when server never accepts\n"); + const char *svc = "rob_noaccept"; + int listener = raw_listener(svc); + check("raw listener bound", listener >= 0); + + bounded_job_t job = {.service = svc}; + pthread_t tid; + pthread_create(&tid, NULL, bounded_client_fn, &job); + bool finished = join_within(tid, 6000); + + if (listener >= 0) + close(listener); + if (!finished) + join_rest(tid); + check("refresh + call with 1s timeout return within 6s", finished); + cleanup_all(svc); +} + +typedef struct { + int listener; + int accepted; /* __atomic */ + int release; /* __atomic */ +} hold_job_t; + +static void *accept_and_hold_fn(void *arg) +{ + hold_job_t *job = (hold_job_t *)arg; + int fd = accept(job->listener, NULL, NULL); + test_flag_set(&job->accepted); + /* Hold the session open without replying until the test releases it. */ + (void)test_flag_wait(&job->release); + if (fd >= 0) + close(fd); + close(job->listener); + return NULL; +} + +static void test_client_bounded_when_server_never_sends_hello_ack(void) +{ + printf("K4: client bounded when server never sends HELLO_ACK\n"); + const char *svc = "rob_nohelloack"; + hold_job_t hold = {.listener = raw_listener(svc)}; + check("raw listener bound", hold.listener >= 0); + pthread_t holder; + pthread_create(&holder, NULL, accept_and_hold_fn, &hold); + + bounded_job_t job = {.service = svc}; + pthread_t tid; + pthread_create(&tid, NULL, bounded_client_fn, &job); + bool finished = join_within(tid, 6000); + + test_flag_set(&hold.release); + pthread_join(holder, NULL); + if (!finished) + join_rest(tid); + check("refresh + call with 1s timeout return within 6s", finished); + cleanup_all(svc); +} + +typedef struct { + nipc_client_ctx_t *client; + nipc_error_t err; + uint64_t elapsed_ms; +} call_job_t; + +static void *snapshot_call_fn(void *arg) +{ + call_job_t *job = (call_job_t *)arg; + nipc_cgroups_resp_view_t view; + uint64_t start = now_ms(); + job->err = nipc_client_call_cgroups_snapshot_timeout(job->client, &view, 1000); + job->elapsed_ms = now_ms() - start; + return NULL; +} + +static void test_call_reconnect_bounded_by_call_deadline(void) +{ + printf("K4: a call's reconnect is bounded by the call deadline\n"); + robust_server_t rs = {.service = "rob_reconnect_noack", .workers = 1}; + if (!start_server(&rs)) { + check("server started", 0); + return; + } + nipc_client_config_t ccfg = client_config(false); + nipc_client_ctx_t client; + nipc_client_init(&client, TEST_RUN_DIR, rs.service, &ccfg); + nipc_client_refresh(&client); + check("client ready", nipc_client_ready(&client)); + check("server stops", stop_server_within(&rs, 5000)); + + /* The call fails on the dead session and reconnects to a server that + * accepts but never sends HELLO_ACK. */ + hold_job_t hold = {.listener = raw_listener(rs.service)}; + check("raw listener bound", hold.listener >= 0); + pthread_t holder; + pthread_create(&holder, NULL, accept_and_hold_fn, &hold); + + call_job_t job = {.client = &client}; + pthread_t tid; + pthread_create(&tid, NULL, snapshot_call_fn, &job); + bool finished = join_within(tid, 6000); + + test_flag_set(&hold.release); + pthread_join(holder, NULL); + if (!finished) + join_rest(tid); + printf(" call took %llums\n", (unsigned long long)job.elapsed_ms); + check("call with 1s timeout returns within 6s", finished); + check("call reports the timeout", job.err == NIPC_ERR_TIMEOUT); + nipc_client_close(&client); + cleanup_all(rs.service); +} + +typedef struct { + nipc_client_ctx_t *client; +} refresh_job_t; + +static void *refresh_fn(void *arg) +{ + refresh_job_t *job = (refresh_job_t *)arg; + nipc_client_refresh(job->client); + return NULL; +} + +static void test_abort_ends_refresh_waiting_for_hello_ack(void) +{ + printf("K4: abort ends a refresh waiting for HELLO_ACK\n"); + const char *svc = "rob_abort_refresh"; + hold_job_t hold = {.listener = raw_listener(svc)}; + check("raw listener bound", hold.listener >= 0); + pthread_t holder; + pthread_create(&holder, NULL, accept_and_hold_fn, &hold); + + nipc_client_config_t ccfg = client_config(false); + ccfg.call_timeout_ms = 10000; /* only an abort can end the wait early */ + nipc_client_ctx_t client; + nipc_client_init(&client, TEST_RUN_DIR, svc, &ccfg); + + refresh_job_t job = {.client = &client}; + pthread_t tid; + pthread_create(&tid, NULL, refresh_fn, &job); + check("server accepted the connection", test_flag_wait(&hold.accepted)); + nipc_client_abort(&client); + bool finished = join_within(tid, 2000); + + test_flag_set(&hold.release); + pthread_join(holder, NULL); + if (!finished) + join_rest(tid); + check("refresh returns within 2s of the abort", finished); + check("client is not ready", !nipc_client_ready(&client)); + nipc_client_close(&client); + cleanup_all(svc); +} + +/* ------------------------------------------------------------------ */ +/* N1 guard: timeout longer than the 100 ms abort-poll slice */ +/* ------------------------------------------------------------------ */ + +static void test_call_honors_timeout_longer_than_100ms(void) +{ + printf("N1: call honors a timeout longer than 100 ms\n"); + robust_server_t rs = {.service = "rob_slow_handler", .workers = 2, + .opts = {.delay_ms = 300}}; + if (!start_server(&rs)) { + check("server started", 0); + return; + } + + nipc_client_config_t ccfg = client_config(false); + nipc_client_ctx_t client; + nipc_client_init(&client, TEST_RUN_DIR, rs.service, &ccfg); + nipc_client_refresh(&client); + nipc_cgroups_resp_view_t view; + nipc_error_t err = nipc_client_call_cgroups_snapshot_timeout(&client, &view, 2000); + nipc_client_close(&client); + + check("300ms handler succeeds with a 2000ms timeout", err == NIPC_OK); + stop_server_within(&rs, 5000); + cleanup_all(rs.service); +} + +/* ------------------------------------------------------------------ */ +/* N2: shutdown with a worker blocked sending to a non-reading client */ +/* ------------------------------------------------------------------ */ + +static void test_shutdown_not_blocked_by_non_reading_client(void) +{ + printf("N2: shutdown not blocked by a client that stopped reading\n"); + const uint32_t big = 8u * 1024u * 1024u; + robust_server_t rs = {.service = "rob_noread", .workers = 2, + .max_payload = big, .packet_size = 65536, + .opts = {.raw_len = 4u * 1024u * 1024u}}; + if (!start_server(&rs)) { + check("server started", 0); + return; + } + + nipc_uds_client_config_t ccfg = { + .supported_profiles = NIPC_PROFILE_BASELINE, + .max_request_payload_bytes = 4096, + .max_request_batch_items = 1, + .max_response_payload_bytes = big, + .max_response_batch_items = 1, + .auth_token = AUTH_TOKEN, + .packet_size = 65536, + }; + nipc_uds_session_t session; + bool connected = nipc_uds_connect(TEST_RUN_DIR, rs.service, &ccfg, &session) == NIPC_UDS_OK; + check("raw client connected", connected); + if (connected) { + uint8_t req[64]; + size_t req_len = nipc_cgroups_req_encode(&(nipc_cgroups_req_t){.layout_version = 1}, + req, sizeof(req)); + nipc_header_t hdr = {.kind = NIPC_KIND_REQUEST, .code = NIPC_METHOD_CGROUPS_SNAPSHOT, + .item_count = 1, .message_id = 1}; + check("request sent", nipc_uds_send(&session, &hdr, req, req_len) == NIPC_UDS_OK); + } + /* Never read the response. Once the handler built it, the worker can + * only be sending, which blocks. */ + check("handler built the 4 MiB response", test_flag_wait(&rs.opts.handled)); + + bool stopped = stop_server_within(&rs, 3000); + if (connected) + nipc_uds_close_session(&session); + if (!stopped) + join_rest(rs.tid); + check("server stop + destroy finish within 3s while a worker is blocked sending", stopped); + cleanup_all(rs.service); +} + +/* ------------------------------------------------------------------ */ +/* N3: stop before run */ +/* ------------------------------------------------------------------ */ + +static void *run_only_fn(void *arg) +{ + nipc_server_run((nipc_managed_server_t *)arg); + return NULL; +} + +static void test_stop_before_run_is_honored(void) +{ + printf("N3: stop before run is honored\n"); + robust_server_t rs = {.service = "rob_stop_first", .workers = 2}; + cleanup_all(rs.service); + nipc_uds_server_config_t scfg = robust_server_config(&rs); + if (nipc_server_init(&rs.server, TEST_RUN_DIR, rs.service, &scfg, rs.workers, + NIPC_METHOD_CGROUPS_SNAPSHOT, robust_handler, &rs.opts) != NIPC_OK) { + check("server init", 0); + return; + } + + nipc_server_stop(&rs.server); + pthread_t tid; + pthread_create(&tid, NULL, run_only_fn, &rs.server); + bool returned = join_within(tid, 2000); + if (!returned) { + nipc_server_stop(&rs.server); + join_rest(tid); + } + nipc_server_destroy(&rs.server); + check("run after stop returns promptly", returned); + cleanup_all(rs.service); +} + +/* ------------------------------------------------------------------ */ +/* N5: SHM client notices a crashed server */ +/* ------------------------------------------------------------------ */ + +typedef struct { + const char *service; + bool shm; + nipc_error_t err; + uint64_t elapsed_ms; + int calling; /* __atomic */ +} dead_server_job_t; + +static void *dead_server_client_fn(void *arg) +{ + dead_server_job_t *job = (dead_server_job_t *)arg; + nipc_client_config_t ccfg = client_config(job->shm); + ccfg.call_timeout_ms = 10000; + nipc_client_ctx_t client; + nipc_client_init(&client, TEST_RUN_DIR, job->service, &ccfg); + nipc_client_refresh(&client); + nipc_cgroups_resp_view_t view; + test_flag_set(&job->calling); + uint64_t start = now_ms(); + job->err = nipc_client_call_cgroups_snapshot_timeout(&client, &view, 10000); + job->elapsed_ms = now_ms() - start; + nipc_client_close(&client); + return NULL; +} + +static void client_notices_crashed_server(bool shm) +{ + const char *svc = shm ? "rob_dead_server" : "rob_dead_uds_server"; + cleanup_all(svc); + int helper_out = -1; + pid_t server = spawn_helper(shm ? "--helper-shm-server" : "--helper-uds-server", svc, + &helper_out); + check("server helper started", server > 0); + if (server <= 0) + return; + + dead_server_job_t job = {.service = svc, .shm = shm}; + pthread_t tid; + pthread_create(&tid, NULL, dead_server_client_fn, &job); + check("client connected", test_flag_wait(&job.calling)); + check("request parked in the stalled handler", + helper_wait_line(helper_out, "HELPER_HANDLER_ENTERED")); + close(helper_out); + kill(server, SIGKILL); + waitpid(server, NULL, 0); + + bool finished = join_within(tid, 15000); + if (!finished) + join_rest(tid); + printf(" call returned err=%d after %llums\n", job.err, + (unsigned long long)job.elapsed_ms); + check("call fails within 3s of the server crash (timeout is 10s)", + finished && job.err != NIPC_OK && job.elapsed_ms < 3500); + check("call reports the server as disconnected", + finished && job.err == NIPC_ERR_DISCONNECTED); + cleanup_all(svc); +} + +#if defined(__linux__) || defined(__FreeBSD__) +static void test_shm_client_notices_crashed_server(void) +{ + printf("N5: SHM client notices a crashed server before its timeout\n"); + client_notices_crashed_server(true); +} +#endif + +static void test_uds_client_reports_crashed_server_as_disconnected(void) +{ + printf("N5: baseline client reports a crashed server as disconnected\n"); + client_notices_crashed_server(false); +} + +#if defined(__linux__) || defined(__FreeBSD__) +/* A server at worker capacity completes the handshake, then closes the + * session and removes the prepared SHM region. The client must not spend + * its attach retry budget (5 s) on a region that will never appear, and + * must keep SHM enabled for its next connect. */ +static void test_shm_attach_stops_when_server_closes_session(void) +{ + printf("N5: SHM attach stops when the server closes the session\n"); + robust_server_t rs = {.service = "rob_attach_refused", .workers = 1, .shm = true}; + if (!start_server(&rs)) { + check("server started", 0); + return; + } + + nipc_client_config_t ccfg = client_config(true); + nipc_client_ctx_t holder; + nipc_client_init(&holder, TEST_RUN_DIR, rs.service, &ccfg); + nipc_client_refresh(&holder); + check("first client holds the only worker", nipc_client_ready(&holder)); + + nipc_client_ctx_t refused; + nipc_client_init(&refused, TEST_RUN_DIR, rs.service, &ccfg); + uint64_t start = now_ms(); + nipc_client_refresh(&refused); + uint64_t elapsed = now_ms() - start; + printf(" refused refresh took %llums\n", (unsigned long long)elapsed); + check("refused connect returns within 1s (attach retry budget is 5s)", elapsed < 1000); + + nipc_client_close(&holder); + check("holder's session exited", test_server_wait_session_exits(&rs.events, 1)); + nipc_client_refresh(&refused); + bool ready = nipc_client_ready(&refused); + check("refused client connects once the worker is free", ready); + check("refused client still uses SHM", ready && refused.shm != NULL); + nipc_client_close(&refused); + check("server stops", stop_server_within(&rs, 5000)); +} +#endif + +/* Sockets and pipes must not leak into children a consumer forks and + * execs: an inherited session socket keeps the peer from seeing EOF. */ +#define CLOEXEC_MAX_FD 4096 + +/* Descriptors open when main() starts: the process inherited them (CI + * runners pass pipes), so they are not NetIPC's. Marked before any test runs, + * so a NetIPC descriptor an earlier test leaked is still checked. */ +static bool g_inherited_fds[CLOEXEC_MAX_FD]; + +static void mark_inherited_fds(void) +{ + for (int fd = 0; fd < CLOEXEC_MAX_FD; fd++) + g_inherited_fds[fd] = fcntl(fd, F_GETFD) >= 0; +} + +static bool all_ipc_fds_cloexec(const bool *inherited, int *checked) +{ + long max_fd = sysconf(_SC_OPEN_MAX); + if (max_fd < 0 || max_fd > CLOEXEC_MAX_FD) + max_fd = CLOEXEC_MAX_FD; + bool ok = true; + for (int fd = 3; fd < (int)max_fd; fd++) { + struct stat st; + if (inherited[fd] || fstat(fd, &st) < 0) + continue; + if (!S_ISSOCK(st.st_mode) && !S_ISFIFO(st.st_mode)) + continue; + (*checked)++; + int flags = fcntl(fd, F_GETFD); + if (flags < 0 || !(flags & FD_CLOEXEC)) { + printf(" fd %d (%s) is not close-on-exec\n", fd, + S_ISSOCK(st.st_mode) ? "socket" : "pipe"); + ok = false; + } + } + return ok; +} + +static void test_ipc_fds_are_cloexec(void) +{ + printf("CLOEXEC: every NetIPC socket and pipe is close-on-exec\n"); + robust_server_t rs = {.service = "rob_cloexec", .workers = 2, .shm = true}; + if (!start_server(&rs)) { + check("server started", 0); + return; + } + nipc_client_config_t ccfg = client_config(true); + nipc_client_ctx_t client; + nipc_client_init(&client, TEST_RUN_DIR, rs.service, &ccfg); + nipc_client_refresh(&client); + check("client connected", nipc_client_ready(&client)); + /* A completed call proves the server holds its session socket. */ + nipc_cgroups_resp_view_t view; + check("client call served", + nipc_client_call_cgroups_snapshot_timeout(&client, &view, 1000) == NIPC_OK); + + /* Listener, accepted session, client session and client abort pipe. */ + int checked = 0; + bool ok = all_ipc_fds_cloexec(g_inherited_fds, &checked); + printf(" checked %d socket/pipe fds\n", checked); + check("listener, both session ends and the abort pipe are checked", checked >= 5); + check("all of them are close-on-exec", ok); + + nipc_client_close(&client); + check("server stops", stop_server_within(&rs, 5000)); +} + +/* ------------------------------------------------------------------ */ +/* N11: message larger than SO_SNDBUF with the default packet size */ +/* ------------------------------------------------------------------ */ + +typedef struct { + const char *service; + uint32_t packet_size; + size_t expected_len; + bool received_ok; + int ready; /* __atomic */ + int failed; /* __atomic */ +} big_listener_job_t; + +static void *big_listener_fn(void *arg) +{ + big_listener_job_t *job = (big_listener_job_t *)arg; + nipc_uds_server_config_t scfg = { + .supported_profiles = NIPC_PROFILE_BASELINE, + .max_request_payload_bytes = 1024u * 1024u, + .max_request_batch_items = 1, + .max_response_payload_bytes = 4096, + .max_response_batch_items = 1, + .auth_token = AUTH_TOKEN, + .packet_size = job->packet_size, + .backlog = 4, + }; + nipc_uds_listener_t listener; + if (nipc_uds_listen(TEST_RUN_DIR, job->service, &scfg, &listener) != NIPC_UDS_OK) { + test_flag_set(&job->failed); + return NULL; + } + test_flag_set(&job->ready); + + struct pollfd pfd = {.fd = listener.fd, .events = POLLIN}; + if (poll(&pfd, 1, 5000) == 1) { + nipc_uds_session_t session; + if (nipc_uds_accept(&listener, 1, &session) == NIPC_UDS_OK) { + size_t buf_size = job->expected_len + 4096; + uint8_t *buf = malloc(buf_size); + nipc_header_t hdr; + const void *payload = NULL; + size_t payload_len = 0; + job->received_ok = + buf && nipc_uds_receive_timeout(&session, buf, buf_size, &hdr, &payload, + &payload_len, 5000, -1) == NIPC_UDS_OK && + payload_len == job->expected_len; + free(buf); + nipc_uds_close_session(&session); + } + } + nipc_uds_close_listener(&listener); + return NULL; +} + +static bool large_message_round_trip(const char *service, uint32_t packet_size) +{ + const size_t len = 512u * 1024u; + cleanup_all(service); + big_listener_job_t job = {.service = service, .packet_size = packet_size, + .expected_len = len}; + pthread_t tid; + pthread_create(&tid, NULL, big_listener_fn, &job); + (void)test_flag_wait_any(&job.ready, &job.failed); + + nipc_uds_client_config_t ccfg = { + .supported_profiles = NIPC_PROFILE_BASELINE, + .max_request_payload_bytes = 1024u * 1024u, + .max_request_batch_items = 1, + .max_response_payload_bytes = 4096, + .max_response_batch_items = 1, + .auth_token = AUTH_TOKEN, + .packet_size = packet_size, + }; + nipc_uds_session_t session; + bool sent = false; + if (nipc_uds_connect(TEST_RUN_DIR, service, &ccfg, &session) == NIPC_UDS_OK) { + uint8_t *payload = calloc(1, len); + nipc_header_t hdr = {.kind = NIPC_KIND_REQUEST, .code = NIPC_METHOD_CGROUPS_SNAPSHOT, + .item_count = 1, .message_id = 1}; + sent = payload && nipc_uds_send(&session, &hdr, payload, len) == NIPC_UDS_OK; + free(payload); + nipc_uds_close_session(&session); + } + pthread_join(tid, NULL); + cleanup_all(service); + return sent && job.received_ok; +} + +static void test_default_packet_size_carries_large_message(void) +{ + printf("N11: default packet size carries a 512 KiB message\n"); + check("512 KiB message delivered with auto-detected packet size", + large_message_round_trip("rob_bigmsg_default", 0)); + check("control: 512 KiB message delivered with 64 KiB packet size", + large_message_round_trip("rob_bigmsg_explicit", 65536)); + check("512 KiB message delivered with an oversized 4 MiB packet size (clamped)", + large_message_round_trip("rob_bigmsg_oversized", 4u * 1024u * 1024u)); +} + +/* ------------------------------------------------------------------ */ + +int main(int argc, char **argv) +{ + mark_inherited_fds(); +#if defined(__linux__) || defined(__FreeBSD__) + if (argc == 3 && strcmp(argv[1], "--helper-shm-client") == 0) + return helper_shm_client(argv[2]); +#endif + if (argc == 3 && strcmp(argv[1], "--helper-shm-server") == 0) + return helper_stalled_server(argv[2], true); + if (argc == 3 && strcmp(argv[1], "--helper-uds-server") == 0) + return helper_stalled_server(argv[2], false); + + if (!realpath(argv[0], g_self_path)) { + perror("realpath"); + return 1; + } + signal(SIGPIPE, SIG_IGN); + mkdir(TEST_RUN_DIR, 0700); + setvbuf(stdout, NULL, _IOLBF, 0); + +#if defined(__linux__) || defined(__FreeBSD__) + test_shm_sessions_released_after_clean_close(); + test_shm_sessions_released_after_client_crash(); +#endif + test_silent_client_does_not_block_other_clients(); + test_silent_client_does_not_block_shutdown(); + test_silent_client_released_at_handshake_deadline(); + test_client_bounded_when_server_never_accepts(); + test_client_bounded_when_server_never_sends_hello_ack(); + test_call_reconnect_bounded_by_call_deadline(); + test_abort_ends_refresh_waiting_for_hello_ack(); + test_call_honors_timeout_longer_than_100ms(); + test_shutdown_not_blocked_by_non_reading_client(); + test_stop_before_run_is_honored(); +#if defined(__linux__) || defined(__FreeBSD__) + test_shm_client_notices_crashed_server(); +#endif + test_uds_client_reports_crashed_server_as_disconnected(); +#if defined(__linux__) || defined(__FreeBSD__) + test_shm_attach_stops_when_server_closes_session(); +#endif + test_ipc_fds_are_cloexec(); + test_default_packet_size_carries_large_message(); + + printf("\n%d passed, %d failed\n", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tests/fixtures/c/test_service.c b/tests/fixtures/c/test_service.c index 7a35684e..a804f2d3 100644 --- a/tests/fixtures/c/test_service.c +++ b/tests/fixtures/c/test_service.c @@ -8,6 +8,8 @@ */ #include "netipc/netipc_service.h" +#include "test_sync.h" +#include "uds_test_socket.h" #include "netipc/netipc_protocol.h" #include "netipc/netipc_uds.h" @@ -25,7 +27,11 @@ #include #include #include +#if defined(__linux__) #include +#elif defined(__FreeBSD__) +#include +#endif /* ------------------------------------------------------------------ */ /* Test infrastructure */ @@ -181,6 +187,7 @@ static void cleanup_all(const char *service) cleanup_shm(service); } +#if defined(__linux__) || defined(__FreeBSD__) static void publish_raw_shm_message(nipc_shm_ctx_t *ctx, const void *msg, size_t copy_len, @@ -210,13 +217,18 @@ static void publish_raw_shm_message(nipc_shm_ctx_t *ctx, __atomic_store_n(len_ptr, published_len, __ATOMIC_RELEASE); __atomic_add_fetch(seq_ptr, 1, __ATOMIC_RELEASE); __atomic_add_fetch(signal_ptr, 1, __ATOMIC_RELEASE); +#if defined(__linux__) syscall(SYS_futex, signal_ptr, FUTEX_WAKE, 1, NULL, NULL, 0); +#else + _umtx_op(signal_ptr, UMTX_OP_WAKE, 1, NULL, NULL); +#endif if (ctx->role == NIPC_SHM_ROLE_CLIENT) ctx->local_req_seq++; else ctx->local_resp_seq++; } +#endif static nipc_uds_server_config_t default_server_config(void) { @@ -330,6 +342,15 @@ static nipc_error_t failing_handler(void *user, return NIPC_ERR_HANDLER_FAILED; } +/* Slow handlers hold the request until the test, having seen the client + * give up, releases them. */ +static int g_slow_handler_release; + +static void slow_handler_arm(void) +{ + __atomic_store_n(&g_slow_handler_release, 0, __ATOMIC_RELEASE); +} + static nipc_error_t slow_cgroups_handler(void *user, const nipc_header_t *request_hdr, const uint8_t *request_payload, @@ -338,7 +359,7 @@ static nipc_error_t slow_cgroups_handler(void *user, size_t response_buf_size, size_t *response_len_out) { - usleep(150000); + (void)test_flag_wait(&g_slow_handler_release); return test_cgroups_handler(user, request_hdr, request_payload, request_len, response_buf, response_buf_size, response_len_out); @@ -355,9 +376,8 @@ static nipc_error_t blocking_cgroups_handler(void *user, size_t response_buf_size, size_t *response_len_out) { - __atomic_store_n(&g_blocking_handler_entered, 1, __ATOMIC_RELEASE); - while (!__atomic_load_n(&g_blocking_handler_release, __ATOMIC_ACQUIRE)) - usleep(1000); + test_flag_set(&g_blocking_handler_entered); + (void)test_flag_wait(&g_blocking_handler_release); return test_cgroups_handler(user, request_hdr, request_payload, request_len, response_buf, response_buf_size, @@ -386,7 +406,9 @@ static void *snapshot_call_thread_fn(void *arg) typedef struct { const char *service; nipc_managed_server_t server; + test_server_events_t events; nipc_server_handler_fn handler; + bool zero_profiles; /* configure supported_profiles = 0 (the default) */ int ready; /* use __atomic builtins for cross-thread access */ int done; /* use __atomic builtins for cross-thread access */ } server_thread_ctx_t; @@ -396,6 +418,8 @@ static void *server_thread_fn(void *arg) server_thread_ctx_t *ctx = (server_thread_ctx_t *)arg; nipc_uds_server_config_t scfg = default_server_config(); + if (ctx->zero_profiles) + scfg.supported_profiles = 0; nipc_error_t err = nipc_server_init(&ctx->server, TEST_RUN_DIR, ctx->service, &scfg, @@ -403,17 +427,18 @@ static void *server_thread_fn(void *arg) if (err != NIPC_OK) { fprintf(stderr, "server init failed: %d\n", err); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_server_use_test_timings(&ctx->server, &ctx->events); + test_flag_set(&ctx->ready); /* Blocking acceptor loop */ nipc_server_run(&ctx->server); nipc_server_destroy(&ctx->server); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -430,10 +455,7 @@ static void start_server(server_thread_ctx_t *sctx, const char *service, pthread_create(tid, NULL, server_thread_fn, sctx); /* Wait for server to be ready */ - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx->ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx->done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx->ready, &sctx->done); } /* Stop a managed server and join its thread. */ @@ -447,6 +469,7 @@ typedef struct { const char *service; uint16_t method_code; nipc_managed_server_t server; + test_server_events_t events; nipc_server_handler_fn handler; int ready; int done; @@ -463,14 +486,15 @@ static void *raw_method_server_thread_fn(void *arg) ctx->method_code, ctx->handler, NULL); if (err != NIPC_OK) { fprintf(stderr, "raw method server init failed: %d\n", err); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_server_use_test_timings(&ctx->server, &ctx->events); + test_flag_set(&ctx->ready); nipc_server_run(&ctx->server); nipc_server_destroy(&ctx->server); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -486,10 +510,7 @@ static void start_raw_method_server(raw_method_server_thread_ctx_t *sctx, sctx->handler = handler; pthread_create(tid, NULL, raw_method_server_thread_fn, sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx->ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx->done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx->ready, &sctx->done); } static void stop_raw_method_server(raw_method_server_thread_ctx_t *sctx, @@ -526,6 +547,7 @@ typedef struct { const char *service; lookup_server_kind_t kind; nipc_managed_server_t server; + test_server_events_t events; nipc_server_config_t config; int has_config; int worker_count; @@ -554,14 +576,15 @@ static void *lookup_server_thread_fn(void *arg) if (err != NIPC_OK) { fprintf(stderr, "lookup server init failed: %d\n", err); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_server_use_test_timings(&ctx->server, &ctx->events); + test_flag_set(&ctx->ready); nipc_server_run(&ctx->server); nipc_server_destroy(&ctx->server); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -586,10 +609,7 @@ static void start_lookup_server(lookup_server_thread_ctx_t *sctx, __atomic_store_n(&sctx->ready, 0, __ATOMIC_RELAXED); __atomic_store_n(&sctx->done, 0, __ATOMIC_RELAXED); pthread_create(tid, NULL, lookup_server_thread_fn, sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx->ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx->done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx->ready, &sctx->done); } static void stop_lookup_server(lookup_server_thread_ctx_t *sctx, pthread_t tid) @@ -698,7 +718,7 @@ static void *raw_lookup_partial_disconnect_thread(void *arg) NIPC_UDS_OK) goto out; - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); if (accept_with_timeout(&listener, 1, 30000, &session) != NIPC_UDS_OK) goto out; @@ -742,7 +762,7 @@ static void *raw_lookup_partial_disconnect_thread(void *arg) nipc_uds_close_session(&session); if (listener.fd != -1) nipc_uds_close_listener(&listener); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -756,12 +776,10 @@ static void start_raw_lookup_partial_disconnect( ctx->service = service; ctx->kind = kind; pthread_create(tid, NULL, raw_lookup_partial_disconnect_thread, ctx); - for (int i = 0; i < 2000 && - !__atomic_load_n(&ctx->ready, __ATOMIC_ACQUIRE) && - !__atomic_load_n(&ctx->done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&ctx->ready, &ctx->done); } +#if defined(__linux__) || defined(__FreeBSD__) static nipc_server_config_t default_shm_service_server_config(void) { return (nipc_server_config_t){ @@ -783,6 +801,7 @@ static nipc_client_config_t default_shm_service_client_config(void) .auth_token = AUTH_TOKEN, }; } +#endif typedef struct { const char *service; @@ -807,7 +826,7 @@ static void *raw_hello_ack_version_server_thread(void *arg) build_socket_path(path, sizeof(path), ctx->service); unlink(path); - fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); + fd = socket(AF_UNIX, TEST_UDS_SOCK_TYPE, 0); if (fd < 0) goto out; @@ -820,13 +839,13 @@ static void *raw_hello_ack_version_server_thread(void *arg) if (listen(fd, 4) != 0) goto out; - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); cfd = accept(fd, NULL, NULL); if (cfd < 0) goto out; - __atomic_store_n(&ctx->accepted, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->accepted); { uint8_t hello_buf[128]; @@ -879,7 +898,7 @@ static void *raw_hello_ack_version_server_thread(void *arg) close(cfd); if (fd >= 0) close(fd); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -891,10 +910,7 @@ static void start_raw_hello_ack_version_server(raw_hello_ack_server_ctx_t *ctx, ctx->service = service; pthread_create(tid, NULL, raw_hello_ack_version_server_thread, ctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&ctx->ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&ctx->done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&ctx->ready, &ctx->done); } /* ------------------------------------------------------------------ */ @@ -954,6 +970,40 @@ static void test_client_lifecycle(void) cleanup_all(svc); } +/* supported_profiles = 0 is the documented default: baseline. */ +static void test_server_zero_profiles_selects_baseline(void) +{ + printf("Test: server with supported_profiles = 0 serves baseline\n"); + const char *svc = "svc_zero_profiles"; + cleanup_all(svc); + + server_thread_ctx_t sctx; + memset(&sctx, 0, sizeof(sctx)); + sctx.service = svc; + sctx.handler = test_cgroups_handler; + sctx.zero_profiles = true; + pthread_t tid; + pthread_create(&tid, NULL, server_thread_fn, &sctx); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); + check("zero-profile server started", + __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); + + nipc_client_ctx_t client; + nipc_client_config_t ccfg = default_client_config(); + nipc_client_init(&client, TEST_RUN_DIR, svc, &ccfg); + nipc_client_refresh(&client); + check("client is READY against a zero-profile server", + nipc_client_ready(&client)); + + nipc_cgroups_resp_view_t view; + check("call against a zero-profile server succeeds", + nipc_client_call_cgroups_snapshot(&client, &view) == NIPC_OK); + + nipc_client_close(&client); + stop_server(&sctx, tid); + cleanup_all(svc); +} + /* ------------------------------------------------------------------ */ /* Test 2: Typed cgroups snapshot call */ /* ------------------------------------------------------------------ */ @@ -1035,6 +1085,7 @@ static void test_client_call_timeout_on_wedged_peer(void) server_thread_ctx_t sctx; pthread_t tid; + slow_handler_arm(); start_server(&sctx, svc, slow_cgroups_handler, &tid); check("timeout test server started", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); @@ -1042,10 +1093,7 @@ static void test_client_call_timeout_on_wedged_peer(void) nipc_client_ctx_t client; nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, svc, &ccfg); - for (int i = 0; i < 2000 && !nipc_client_ready(&client); i++) { - nipc_client_refresh(&client); - usleep(500); - } + nipc_client_refresh(&client); check("timeout test client is READY", nipc_client_ready(&client)); nipc_cgroups_resp_view_t view; @@ -1056,12 +1104,146 @@ static void test_client_call_timeout_on_wedged_peer(void) uint64_t elapsed = monotonic_msec() - start; check("wedged peer returns timeout", err == NIPC_ERR_TIMEOUT); check("timeout returns promptly", elapsed < 1000u); + test_flag_set(&g_slow_handler_release); nipc_client_close(&client); stop_server(&sctx, tid); cleanup_all(svc); } +/* A server that completes the handshake and never reads, so a request + * larger than the socket buffers blocks the client's send. */ +#define STALLED_READER_REQUEST_BYTES (8u * 1024u * 1024u) +#define STALLED_READER_PIDS (1024u * 1024u) + +typedef struct { + const char *service; + int ready; + int reading_started; + int request_seen; + int release; +} stalled_reader_ctx_t; + +static void *stalled_reader_server_fn(void *arg) +{ + stalled_reader_ctx_t *ctx = (stalled_reader_ctx_t *)arg; + nipc_uds_listener_t listener; + nipc_uds_session_t session; + nipc_uds_server_config_t scfg = { + .supported_profiles = NIPC_PROFILE_BASELINE, + .max_request_payload_bytes = STALLED_READER_REQUEST_BYTES, + .max_request_batch_items = 1, + .max_response_payload_bytes = RESPONSE_BUF_SIZE, + .max_response_batch_items = 1, + .auth_token = AUTH_TOKEN, + .backlog = 4, + }; + + memset(&listener, 0, sizeof(listener)); + memset(&session, 0, sizeof(session)); + listener.fd = -1; + session.fd = -1; + if (nipc_uds_listen(TEST_RUN_DIR, ctx->service, &scfg, &listener) != + NIPC_UDS_OK) { + test_flag_set(&ctx->ready); + test_flag_set(&ctx->reading_started); + return NULL; + } + test_flag_set(&ctx->ready); + + if (nipc_uds_accept(&listener, 1, &session) == NIPC_UDS_OK) { + /* The request's first bytes arrived: the client is in its send. */ + struct pollfd pfd = {.fd = session.fd, .events = POLLIN}; + if (poll(&pfd, 1, 10000) > 0 && (pfd.revents & POLLIN)) + __atomic_store_n(&ctx->request_seen, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->reading_started); + (void)test_flag_wait(&ctx->release); + nipc_uds_close_session(&session); + } else { + test_flag_set(&ctx->reading_started); + } + nipc_uds_close_listener(&listener); + return NULL; +} + +typedef struct { + nipc_client_ctx_t *client; + const uint32_t *pids; + uint32_t timeout_ms; + nipc_error_t err; +} stalled_send_call_ctx_t; + +static void *stalled_send_call_fn(void *arg) +{ + stalled_send_call_ctx_t *ctx = (stalled_send_call_ctx_t *)arg; + nipc_apps_lookup_resp_view_t view; + ctx->err = nipc_client_call_apps_lookup_timeout( + ctx->client, ctx->pids, STALLED_READER_PIDS, &view, ctx->timeout_ms); + return NULL; +} + +/* A request the server never reads ends at the call deadline, or on abort, + * instead of blocking in the send. */ +static void test_client_send_bounded_by_deadline_and_abort(void) +{ + printf("Test: Client send bounded by deadline and abort\n"); + + uint32_t *pids = calloc(STALLED_READER_PIDS, sizeof(*pids)); + check("stalled send pids allocated", pids != NULL); + if (!pids) + return; + for (uint32_t i = 0; i < STALLED_READER_PIDS; i++) + pids[i] = i + 1; + + for (int use_abort = 0; use_abort <= 1; use_abort++) { + const char *svc = use_abort ? "svc_send_abort" : "svc_send_deadline"; + cleanup_all(svc); + + stalled_reader_ctx_t sctx = {.service = svc}; + pthread_t server_tid; + pthread_create(&server_tid, NULL, stalled_reader_server_fn, &sctx); + check("stalled reader listening", test_flag_wait(&sctx.ready)); + + nipc_client_ctx_t client; + nipc_client_config_t ccfg = default_client_config(); + ccfg.max_request_payload_bytes = STALLED_READER_REQUEST_BYTES; + ccfg.max_logical_lookup_items = STALLED_READER_PIDS; + nipc_client_init(&client, TEST_RUN_DIR, svc, &ccfg); + nipc_client_refresh(&client); + check("stalled send client is READY", nipc_client_ready(&client)); + + stalled_send_call_ctx_t cctx = { + .client = &client, + .pids = pids, + .timeout_ms = use_abort ? 30000u : 200u, + }; + uint64_t start = monotonic_msec(); + pthread_t call_tid; + pthread_create(&call_tid, NULL, stalled_send_call_fn, &cctx); + if (use_abort) { + check("server saw the request start", + test_flag_wait(&sctx.reading_started) && + __atomic_load_n(&sctx.request_seen, __ATOMIC_ACQUIRE)); + nipc_client_abort(&client); + } + pthread_join(call_tid, NULL); + uint64_t elapsed = monotonic_msec() - start; + test_flag_set(&sctx.release); + pthread_join(server_tid, NULL); + + if (use_abort) { + check("blocked send returns aborted", cctx.err == NIPC_ERR_ABORTED); + } else { + check("blocked send returns timeout", cctx.err == NIPC_ERR_TIMEOUT); + } + check("blocked send ends promptly", elapsed < 5000u); + + nipc_client_close(&client); + cleanup_all(svc); + } + free(pids); +} + static void test_client_abort_unblocks_call(void) { printf("Test: Client abort unblocks in-flight call\n"); @@ -1080,10 +1262,7 @@ static void test_client_abort_unblocks_call(void) nipc_client_ctx_t client; nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, svc, &ccfg); - for (int i = 0; i < 2000 && !nipc_client_ready(&client); i++) { - nipc_client_refresh(&client); - usleep(500); - } + nipc_client_refresh(&client); check("abort test client is READY", nipc_client_ready(&client)); snapshot_call_thread_ctx_t call_ctx; @@ -1095,10 +1274,7 @@ static void test_client_abort_unblocks_call(void) pthread_create(&call_tid, NULL, snapshot_call_thread_fn, &call_ctx) == 0); - for (int i = 0; i < 2000 - && !__atomic_load_n(&g_blocking_handler_entered, __ATOMIC_ACQUIRE); - i++) - usleep(500); + (void)test_flag_wait_any(&g_blocking_handler_entered, NULL); check("blocking handler received request", __atomic_load_n(&g_blocking_handler_entered, __ATOMIC_ACQUIRE) == 1); @@ -1110,12 +1286,112 @@ static void test_client_abort_unblocks_call(void) check("aborted call returns aborted", call_ctx.err == NIPC_ERR_ABORTED); check("abort returns promptly", elapsed < 1000u); - __atomic_store_n(&g_blocking_handler_release, 1, __ATOMIC_RELEASE); + test_flag_set(&g_blocking_handler_release); nipc_client_close(&client); stop_server(&sctx, tid); cleanup_all(svc); } +typedef struct { + const char *service; + nipc_managed_server_t server; + int ready; + int run_returned; + int destroy; + int destroyed; +} shutdown_order_ctx_t; + +static void *shutdown_order_server_fn(void *arg) +{ + shutdown_order_ctx_t *ctx = (shutdown_order_ctx_t *)arg; + nipc_uds_server_config_t scfg = default_server_config(); + if (nipc_server_init(&ctx->server, TEST_RUN_DIR, ctx->service, &scfg, 1, + NIPC_METHOD_CGROUPS_SNAPSHOT, + blocking_cgroups_handler, NULL) != NIPC_OK) { + test_flag_set(&ctx->run_returned); + test_flag_set(&ctx->destroyed); + return NULL; + } + test_server_use_test_timings(&ctx->server, NULL); + test_flag_set(&ctx->ready); + nipc_server_run(&ctx->server); + test_flag_set(&ctx->run_returned); + (void)test_flag_wait(&ctx->destroy); + nipc_server_destroy(&ctx->server); + test_flag_set(&ctx->destroyed); + return NULL; +} + +/* A second server on the same endpoint starts only while none holds it. */ +static bool endpoint_free(const char *service) +{ + nipc_managed_server_t other; + nipc_uds_server_config_t scfg = default_server_config(); + if (nipc_server_init(&other, TEST_RUN_DIR, service, &scfg, 1, + NIPC_METHOD_CGROUPS_SNAPSHOT, + test_cgroups_handler, NULL) != NIPC_OK) + return false; + nipc_server_destroy(&other); + return true; +} + +/* Run closes the listening socket when its loop exits; the endpoint lock + * stays held until every worker of the server has exited. */ +static void test_server_shutdown_order(void) +{ + printf("Test: server shutdown closes the socket first, the endpoint last\n"); + const char *svc = "svc_shutdown_order"; + cleanup_all(svc); + __atomic_store_n(&g_blocking_handler_entered, 0, __ATOMIC_RELEASE); + __atomic_store_n(&g_blocking_handler_release, 0, __ATOMIC_RELEASE); + + shutdown_order_ctx_t sctx; + memset(&sctx, 0, sizeof(sctx)); + sctx.service = svc; + pthread_t tid; + pthread_create(&tid, NULL, shutdown_order_server_fn, &sctx); + (void)test_flag_wait_any(&sctx.ready, &sctx.run_returned); + check("shutdown-order server started", + __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); + + nipc_client_ctx_t client; + nipc_client_config_t ccfg = default_client_config(); + nipc_client_init(&client, TEST_RUN_DIR, svc, &ccfg); + nipc_client_refresh(&client); + check("shutdown-order client is READY", nipc_client_ready(&client)); + + snapshot_call_thread_ctx_t call_ctx; + memset(&call_ctx, 0, sizeof(call_ctx)); + call_ctx.client = &client; + pthread_t call_tid; + pthread_create(&call_tid, NULL, snapshot_call_thread_fn, &call_ctx); + (void)test_flag_wait_any(&g_blocking_handler_entered, NULL); + + nipc_server_stop(&sctx.server); + (void)test_flag_wait(&sctx.run_returned); + char path[256]; + snprintf(path, sizeof(path), "%s/%s.sock", TEST_RUN_DIR, svc); + check("socket file removed when run returns", + access(path, F_OK) != 0 && errno == ENOENT); + check("endpoint held while a worker runs", !endpoint_free(svc)); + + /* Destroy shuts the blocked worker's socket after its grace, which + * fails the client's call; the worker itself stays in the handler. */ + test_flag_set(&sctx.destroy); + pthread_join(call_tid, NULL); + check("blocked call fails once destroy shuts its socket", + call_ctx.err != NIPC_OK); + check("endpoint held until the worker exits", !endpoint_free(svc)); + + test_flag_set(&g_blocking_handler_release); + (void)test_flag_wait(&sctx.destroyed); + pthread_join(tid, NULL); + check("endpoint free after destroy", endpoint_free(svc)); + + nipc_client_close(&client); + cleanup_all(svc); +} + /* ------------------------------------------------------------------ */ /* Test 3: Typed lookup calls */ /* ------------------------------------------------------------------ */ @@ -1200,6 +1476,7 @@ typedef struct { int slow_second_call; int signal_second_call; int second_call_entered; + int release_second_call; } lookup_scale_state_t; static void lookup_scale_note_items(lookup_scale_state_t *state, @@ -1219,9 +1496,9 @@ static void lookup_scale_after_call(lookup_scale_state_t *state, uint32_t call) return; if (state->signal_second_call) - __atomic_store_n(&state->second_call_entered, 1, __ATOMIC_RELEASE); + test_flag_set(&state->second_call_entered); if (state->slow_second_call) - usleep(250000); + (void)test_flag_wait(&state->release_second_call); } typedef struct { @@ -1256,14 +1533,7 @@ static void *lookup_call_thread_fn(void *arg) static int lookup_scale_wait_for_second_call(lookup_scale_state_t *state) { - for (int i = 0; i < 5000; i++) { - if (__atomic_load_n(&state->second_call_entered, - __ATOMIC_ACQUIRE) != 0) - return 1; - usleep(500); - } - - return 0; + return test_flag_wait(&state->second_call_entered) ? 1 : 0; } static bool cgroups_lookup_scale_handler(void *user, @@ -1852,7 +2122,7 @@ static bool cgroups_lookup_slow_handler( const nipc_cgroups_lookup_req_view_t *request, nipc_cgroups_lookup_builder_t *builder) { - usleep(150000); + (void)test_flag_wait(&g_slow_handler_release); return cgroups_lookup_test_handler(user, request, builder); } @@ -1861,7 +2131,7 @@ static bool apps_lookup_slow_handler( const nipc_apps_lookup_req_view_t *request, nipc_apps_lookup_builder_t *builder) { - usleep(150000); + (void)test_flag_wait(&g_slow_handler_release); return apps_lookup_test_handler(user, request, builder); } @@ -3214,6 +3484,7 @@ static void test_apps_lookup_rejects_mixed_generation_retry(void) cleanup_all(svc); } +#if defined(__linux__) || defined(__FreeBSD__) static void test_cgroups_lookup_call_shm(void) { printf("Test 3g: Typed cgroups lookup call over SHM/futex\n"); @@ -3362,18 +3633,20 @@ static void test_shm_lookup_session_closes_after_client_disconnect(void) check("first SHM lookup client ready", nipc_client_ready(&first) && first.shm != NULL); - usleep(150000); if (nipc_client_ready(&first) && first.session.fd >= 0) { char control_byte = 0; check("first SHM lookup control fd accepts readable byte", send(first.session.fd, &control_byte, sizeof(control_byte), 0) == (ssize_t)sizeof(control_byte)); - usleep(150000); + nipc_str_view_t path = { .ptr = "/known", .len = 6 }; + nipc_cgroups_lookup_resp_view_t view; + check("SHM lookup session survives a stray control byte", + nipc_client_call_cgroups_lookup(&first, &path, 1, &view) == NIPC_OK); } nipc_client_close(&first); - - usleep(300000); + check("first SHM lookup session exits after disconnect", + test_server_wait_session_exits(&sctx.events, 1)); nipc_client_ctx_t second; nipc_client_init(&second, TEST_RUN_DIR, svc, &ccfg); @@ -3397,6 +3670,33 @@ static void test_shm_lookup_session_closes_after_client_disconnect(void) cleanup_all(svc); } +#endif +/* Internal liveness probe; exercised directly so each outcome is covered + * without racing the session's poll cadence. */ +bool nipc_service_posix_peer_disconnected(int fd); + +static void test_peer_disconnected_probe(void) +{ + printf("Test 3i2: Peer liveness probe distinguishes data from hangup\n"); + + int sv[2]; + check("probe socketpair created", + socketpair(AF_UNIX, TEST_UDS_SOCK_TYPE, 0, sv) == 0); + check("invalid fd counts as disconnected", + nipc_service_posix_peer_disconnected(-1)); + check("idle peer is alive", !nipc_service_posix_peer_disconnected(sv[0])); + + char byte = 0; + check("probe byte sent", send(sv[1], &byte, 1, 0) == 1); + check("pending data keeps peer alive", + !nipc_service_posix_peer_disconnected(sv[0])); + + close(sv[1]); + check("closed peer is disconnected", + nipc_service_posix_peer_disconnected(sv[0])); + close(sv[0]); +} + static void test_lookup_init_guards(void) { printf("Test 3j: Typed lookup server init guards\n"); @@ -4418,6 +4718,7 @@ static void test_lookup_timeout_cap_and_raw_guards(void) lookup_server_thread_ctx_t sctx; memset(&sctx, 0, sizeof(sctx)); + slow_handler_arm(); sctx.cgroups_handler.handle = cgroups_lookup_slow_handler; pthread_t tid; start_lookup_server(&sctx, svc, LOOKUP_SERVER_CGROUPS, &tid); @@ -4435,6 +4736,7 @@ static void test_lookup_timeout_cap_and_raw_guards(void) nipc_error_t err = nipc_client_call_cgroups_lookup_timeout(&client, &path, 1, &view, 20); check("cgroups lookup timeout rejected", err == NIPC_ERR_TIMEOUT); + test_flag_set(&g_slow_handler_release); nipc_client_close(&client); stop_lookup_server(&sctx, tid); @@ -4447,6 +4749,7 @@ static void test_lookup_timeout_cap_and_raw_guards(void) lookup_server_thread_ctx_t sctx; memset(&sctx, 0, sizeof(sctx)); + slow_handler_arm(); sctx.apps_handler.handle = apps_lookup_slow_handler; pthread_t tid; start_lookup_server(&sctx, svc, LOOKUP_SERVER_APPS, &tid); @@ -4464,6 +4767,7 @@ static void test_lookup_timeout_cap_and_raw_guards(void) nipc_error_t err = nipc_client_call_apps_lookup_timeout(&client, &pid, 1, &view, 20); check("apps lookup timeout rejected", err == NIPC_ERR_TIMEOUT); + test_flag_set(&g_slow_handler_release); nipc_client_close(&client); stop_lookup_server(&sctx, tid); @@ -4952,8 +5256,9 @@ static void test_lookup_timeout_during_followup_subcall(void) nipc_cgroups_lookup_resp_view_t view; nipc_error_t err = nipc_client_call_cgroups_lookup_timeout(&client, paths, 3, &view, - 75); + 200); check("cgroups follow-up timeout rejected", err == NIPC_ERR_TIMEOUT); + test_flag_set(&state.release_second_call); check("cgroups follow-up timeout reached second subcall", __atomic_load_n(&state.calls, __ATOMIC_ACQUIRE) >= 2); @@ -4991,8 +5296,9 @@ static void test_lookup_timeout_during_followup_subcall(void) uint32_t pids[] = { 11, 22, 33 }; nipc_apps_lookup_resp_view_t view; nipc_error_t err = - nipc_client_call_apps_lookup_timeout(&client, pids, 3, &view, 75); + nipc_client_call_apps_lookup_timeout(&client, pids, 3, &view, 200); check("apps follow-up timeout rejected", err == NIPC_ERR_TIMEOUT); + test_flag_set(&state.release_second_call); check("apps follow-up timeout reached second subcall", __atomic_load_n(&state.calls, __ATOMIC_ACQUIRE) >= 2); @@ -5056,6 +5362,7 @@ static void test_lookup_abort_during_followup_subcall(void) lookup_scale_wait_for_second_call(&state)); nipc_client_abort(&client); pthread_join(call_tid, NULL); + test_flag_set(&state.release_second_call); check("cgroups follow-up abort rejected", call_ctx.err == NIPC_ERR_ABORTED); check("cgroups follow-up abort used follow-up call", @@ -5113,6 +5420,7 @@ static void test_lookup_abort_during_followup_subcall(void) lookup_scale_wait_for_second_call(&state)); nipc_client_abort(&client); pthread_join(call_tid, NULL); + test_flag_set(&state.release_second_call); check("apps follow-up abort rejected", call_ctx.err == NIPC_ERR_ABORTED); check("apps follow-up abort used follow-up call", @@ -5298,7 +5606,6 @@ static void test_retry_on_failure(void) /* Kill server */ stop_server(&sctx, tid); cleanup_all(svc); - usleep(50000); /* let cleanup settle */ /* Restart server */ server_thread_ctx_t sctx2; @@ -5358,11 +5665,11 @@ static void test_multiple_clients(void) if (err1 == NIPC_OK) check("client 1 got 3 items", view1.item_count == 3); - /* Close client 1 so the server can accept client 2 - * (single-threaded acceptor). Give the server a short window to - * observe the disconnect before opening the next session. */ + /* Close client 1 and wait for its worker to exit before opening the + * next session. */ nipc_client_close(&client1); - usleep(50000); + check("client 1 session exited", + test_server_wait_session_exits(&sctx.events, 1)); nipc_client_init(&client2, TEST_RUN_DIR, svc, &ccfg); nipc_client_refresh(&client2); @@ -5485,6 +5792,8 @@ static void test_status_reporting(void) typedef struct { const char *service; int done; /* __atomic */ + int connected; /* __atomic: calls finished, session still open */ + int release; /* __atomic: drain finished, close the session */ int call_ok; /* __atomic */ } drain_client_ctx_t; @@ -5496,27 +5805,23 @@ static void *drain_client_fn(void *arg) nipc_client_ctx_t client; nipc_client_init(&client, TEST_RUN_DIR, ctx->service, &ccfg); - /* Connect with retry */ - for (int r = 0; r < 200; r++) { - nipc_client_refresh(&client); - if (nipc_client_ready(&client)) - break; - usleep(5000); - } - + nipc_client_refresh(&client); if (nipc_client_ready(&client)) { - /* Make a slow series of calls to be "in-flight" during drain */ for (int i = 0; i < 5; i++) { nipc_cgroups_resp_view_t view; nipc_error_t err = nipc_client_call_cgroups_snapshot(&client, &view); if (err == NIPC_OK) __atomic_fetch_add(&ctx->call_ok, 1, __ATOMIC_RELAXED); - usleep(10000); /* 10ms between calls */ } } + /* Keep the session open across the drain: calling during it would + * race the session shutdown. */ + test_flag_set(&ctx->connected); + (void)test_flag_wait(&ctx->release); + nipc_client_close(&client); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -5537,8 +5842,8 @@ static void test_graceful_drain(void) /* Start server in background thread */ pthread_t server_tid; + test_server_use_test_timings(&server, NULL); pthread_create(&server_tid, NULL, (void *(*)(void *))nipc_server_run, &server); - usleep(50000); /* let it start */ /* Start 3 client threads that will be making calls */ drain_client_ctx_t cctxs[3]; @@ -5546,20 +5851,23 @@ static void test_graceful_drain(void) for (int i = 0; i < 3; i++) { cctxs[i].service = svc; __atomic_store_n(&cctxs[i].done, 0, __ATOMIC_RELAXED); + __atomic_store_n(&cctxs[i].connected, 0, __ATOMIC_RELAXED); + __atomic_store_n(&cctxs[i].release, 0, __ATOMIC_RELAXED); __atomic_store_n(&cctxs[i].call_ok, 0, __ATOMIC_RELAXED); pthread_create(&ctids[i], NULL, drain_client_fn, &cctxs[i]); } - /* Let clients establish connections and start making calls */ - usleep(50000); + for (int i = 0; i < 3; i++) + check("drain client connected", test_flag_wait(&cctxs[i].connected)); /* Drain with 5-second timeout */ bool drained = nipc_server_drain(&server, 5000); check("drain completed", drained); - /* Wait for client threads to finish */ - for (int i = 0; i < 3; i++) + for (int i = 0; i < 3; i++) { + test_flag_set(&cctxs[i].release); pthread_join(ctids[i], NULL); + } /* Join server thread */ pthread_join(server_tid, NULL); @@ -5569,7 +5877,7 @@ static void test_graceful_drain(void) for (int i = 0; i < 3; i++) total_ok += __atomic_load_n(&cctxs[i].call_ok, __ATOMIC_RELAXED); printf(" total successful calls during drain: %d\n", total_ok); - check("clients got successful calls before drain", total_ok > 0); + check("clients got successful calls before drain", total_ok == 15); cleanup_all(svc); } @@ -5594,9 +5902,9 @@ static void test_non_request_terminates_session(void) check("server init", err == NIPC_OK); pthread_t server_tid; + test_server_use_test_timings(&server, NULL); pthread_create(&server_tid, NULL, (void *(*)(void *))nipc_server_run, &server); - usleep(50000); /* Connect via raw UDS session */ nipc_uds_client_config_t ccfg = default_transport_client_config(); @@ -5619,9 +5927,6 @@ static void test_non_request_terminates_session(void) uerr = nipc_uds_send(&session, &hdr, NULL, 0); check("send non-request", uerr == NIPC_UDS_OK); - /* Wait for server to process and terminate the session */ - usleep(200000); - /* Try to send a valid request - should fail because server closed */ nipc_header_t hdr2 = {0}; hdr2.kind = NIPC_KIND_REQUEST; @@ -5672,6 +5977,7 @@ static void test_non_request_terminates_session(void) cleanup_all(svc); } +#if defined(__linux__) || defined(__FreeBSD__) /* ------------------------------------------------------------------ */ /* SHM-mode snapshot service coverage */ /* ------------------------------------------------------------------ */ @@ -5679,6 +5985,7 @@ static void test_non_request_terminates_session(void) typedef struct { const char *service; nipc_managed_server_t server; + test_server_events_t events; int ready; int done; } shm_server_ctx_t; @@ -5688,6 +5995,7 @@ typedef struct { uint32_t max_request_payload_bytes; uint32_t max_response_payload_bytes; nipc_managed_server_t server; + test_server_events_t events; int ready; int done; } shm_limit_server_ctx_t; @@ -5713,14 +6021,15 @@ static void *shm_server_thread_fn(void *arg) if (err != NIPC_OK) { fprintf(stderr, "SHM server init failed: %d\n", err); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_server_use_test_timings(&ctx->server, &ctx->events); + test_flag_set(&ctx->ready); nipc_server_run(&ctx->server); nipc_server_destroy(&ctx->server); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -5744,14 +6053,15 @@ static void *shm_limit_server_thread_fn(void *arg) if (err != NIPC_OK) { fprintf(stderr, "limited SHM server init failed: %d\n", err); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_server_use_test_timings(&ctx->server, &ctx->events); + test_flag_set(&ctx->ready); nipc_server_run(&ctx->server); nipc_server_destroy(&ctx->server); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -5808,11 +6118,11 @@ static void *fake_shm_response_server_thread_fn(void *arg) shm.fd = -1; if (nipc_uds_listen(TEST_RUN_DIR, ctx->service, &scfg, &listener) != NIPC_UDS_OK) { - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); if (nipc_uds_accept(&listener, 1, &session) != NIPC_UDS_OK) goto out; @@ -5945,7 +6255,7 @@ static void *fake_shm_response_server_thread_fn(void *arg) nipc_shm_destroy(&shm); nipc_uds_close_session(&session); nipc_uds_close_listener(&listener); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -5976,11 +6286,11 @@ static void *fake_shm_attach_fallback_server_thread_fn(void *arg) ctx->second_receive_err = NIPC_UDS_ERR_BAD_PARAM; if (nipc_uds_listen(TEST_RUN_DIR, ctx->service, &scfg, &listener) != NIPC_UDS_OK) { - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); if (accept_with_timeout(&listener, 1, 30000, &first) != NIPC_UDS_OK) goto out; @@ -6017,7 +6327,7 @@ static void *fake_shm_attach_fallback_server_thread_fn(void *arg) nipc_uds_close_session(&second); if (listener.fd != -1) nipc_uds_close_listener(&listener); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -6035,10 +6345,7 @@ static void test_shm_l2_service(void) pthread_t tid; pthread_create(&tid, NULL, shm_server_thread_fn, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("SHM server started", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); /* Connect client with SHM profile */ @@ -6083,7 +6390,7 @@ static void test_shm_client_rejects_malformed_responses(void) const char *label; nipc_error_t expected; } cases[] = { - { FAKE_SHM_RESP_TOO_LARGE, "svc_shm_resp_big", "oversize SHM response", NIPC_ERR_TRUNCATED }, + { FAKE_SHM_RESP_TOO_LARGE, "svc_shm_resp_big", "oversize SHM response", NIPC_ERR_OVERFLOW }, { FAKE_SHM_RESP_SHORT, "svc_shm_resp_short", "short SHM response", NIPC_ERR_TRUNCATED }, { FAKE_SHM_RESP_BAD_MAGIC, "svc_shm_resp_magic", "bad SHM response header", NIPC_ERR_BAD_MAGIC }, { FAKE_SHM_RESP_BAD_KIND, "svc_shm_resp_kind", "wrong SHM response kind", NIPC_ERR_BAD_KIND }, @@ -6114,13 +6421,7 @@ static void test_shm_client_rejects_malformed_responses(void) sctx.mode = cases[i].mode; pthread_create(&tid, NULL, fake_shm_response_server_thread_fn, &sctx); - for (int spin = 0; - spin < 2000 && - !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) && - !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); - spin++) { - usleep(500); - } + (void)test_flag_wait_any(&sctx.ready, &sctx.done); snprintf(msg, sizeof(msg), "%s: fake SHM server started", cases[i].label); check(msg, __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); @@ -6146,6 +6447,98 @@ static void test_shm_client_rejects_malformed_responses(void) } } +#endif +/* ------------------------------------------------------------------ */ +/* Classification: a send failure with a live peer is a transport error */ +/* ------------------------------------------------------------------ */ + +typedef struct { + const char *service; + int ready; + int accepted; + int release; + int done; +} live_peer_server_ctx_t; + +/* Accepts one session, then removes the endpoint so the client cannot + * reconnect, and keeps the session open until released. */ +static void *live_peer_server_thread_fn(void *arg) +{ + live_peer_server_ctx_t *ctx = (live_peer_server_ctx_t *)arg; + nipc_uds_listener_t listener; + nipc_uds_session_t session; + nipc_uds_server_config_t scfg = default_server_config(); + + memset(&listener, 0, sizeof(listener)); + memset(&session, 0, sizeof(session)); + listener.fd = -1; + session.fd = -1; + + if (nipc_uds_listen(TEST_RUN_DIR, ctx->service, &scfg, &listener) != + NIPC_UDS_OK) + goto out; + + test_flag_set(&ctx->ready); + + if (accept_with_timeout(&listener, 1, 30000, &session) != NIPC_UDS_OK) + goto out; + + nipc_uds_close_listener(&listener); + listener.fd = -1; + test_flag_set(&ctx->accepted); + (void)test_flag_wait(&ctx->release); + +out: + if (session.fd != -1) + nipc_uds_close_session(&session); + if (listener.fd != -1) + nipc_uds_close_listener(&listener); + test_flag_set(&ctx->done); + return NULL; +} + +static void test_send_failure_with_live_peer_is_transport(void) +{ + const char *service = "svc_send_live_peer"; + live_peer_server_ctx_t sctx; + pthread_t tid; + nipc_client_config_t ccfg = default_client_config(); + nipc_client_ctx_t client; + + printf("Test: send failure with a live peer is a transport error\n"); + + cleanup_all(service); + memset(&sctx, 0, sizeof(sctx)); + sctx.service = service; + pthread_create(&tid, NULL, live_peer_server_thread_fn, &sctx); + + (void)test_flag_wait_any(&sctx.ready, &sctx.done); + check("live peer: server started", + __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); + + nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); + nipc_client_refresh(&client); + check("live peer: client ready", nipc_client_ready(&client)); + check("live peer: endpoint removed", test_flag_wait(&sctx.accepted)); + + if (nipc_client_ready(&client)) { + /* Our own write shutdown makes send fail with EPIPE while the + * server still holds the session open. */ + shutdown(client.session.fd, SHUT_WR); + + nipc_cgroups_resp_view_t view; + nipc_error_t err = nipc_client_call_cgroups_snapshot(&client, &view); + check("live peer: call returns NIPC_ERR_TRANSPORT", + err == NIPC_ERR_TRANSPORT); + } + + test_flag_set(&sctx.release); + nipc_client_close(&client); + pthread_join(tid, NULL); + cleanup_all(service); +} + +#if defined(__linux__) || defined(__FreeBSD__) /* ------------------------------------------------------------------ */ /* Coverage: SHM server rejects malformed raw requests */ /* ------------------------------------------------------------------ */ @@ -6190,13 +6583,7 @@ static void test_shm_server_rejects_malformed_requests(void) sctx.service = cases[i].service; pthread_create(&tid, NULL, shm_server_thread_fn, &sctx); - for (int spin = 0; - spin < 2000 && - !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) && - !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); - spin++) { - usleep(500); - } + (void)test_flag_wait_any(&sctx.ready, &sctx.done); snprintf(msg, sizeof(msg), "%s: SHM server started", cases[i].label); check(msg, __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); @@ -6214,10 +6601,11 @@ static void test_shm_server_rejects_malformed_requests(void) cases[i].copy_len > 0 ? cases[i].msg : NULL, cases[i].copy_len, cases[i].published_len); - usleep(100000); } nipc_client_close(&bad_client); + snprintf(msg, sizeof(msg), "%s: attacker session exited", cases[i].label); + check(msg, test_server_wait_session_exits(&sctx.events, 1)); nipc_client_init(&good_client, TEST_RUN_DIR, cases[i].service, &ccfg); nipc_client_refresh(&good_client); @@ -6268,13 +6656,7 @@ static void test_shm_request_resize_retry(void) sctx.max_response_payload_bytes = RESPONSE_BUF_SIZE; pthread_create(&tid, NULL, shm_limit_server_thread_fn, &sctx); - for (int spin = 0; - spin < 2000 && - !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) && - !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); - spin++) { - usleep(500); - } + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("request resize: server started", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); @@ -6331,13 +6713,7 @@ static void test_shm_response_resize_retry(void) sctx.max_response_payload_bytes = 32; pthread_create(&tid, NULL, shm_limit_server_thread_fn, &sctx); - for (int spin = 0; - spin < 2000 && - !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) && - !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); - spin++) { - usleep(500); - } + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("response resize: server started", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); @@ -6368,6 +6744,7 @@ static void test_shm_response_resize_retry(void) cleanup_session_shm(svc, 1); } +#endif /* ------------------------------------------------------------------ */ /* Test: Refresh failure preserves cache */ /* ------------------------------------------------------------------ */ @@ -6404,7 +6781,6 @@ static void test_cache_refresh_failure_preserves(void) /* Stop server */ stop_server(&sctx, tid); cleanup_all(svc); - usleep(50000); /* Refresh fails, but old cache must be preserved */ updated = nipc_cgroups_cache_refresh(&cache); @@ -6477,7 +6853,6 @@ static void test_malformed_response_handling(void) /* Stop good server, start garbage server */ stop_server(&sctx_good, tid_good); cleanup_all(svc); - usleep(50000); server_thread_ctx_t sctx_bad; pthread_t tid_bad; @@ -6625,7 +7000,6 @@ static void test_client_broken_refresh(void) /* Kill server */ stop_server(&sctx, tid); cleanup_all(svc); - usleep(50000); /* Make a call - should fail and put client in BROKEN state */ nipc_cgroups_resp_view_t view; @@ -6755,10 +7129,10 @@ static void test_server_closes_after_terminal_error_response(void) check("terminal close status bad-envelope", resp_hdr.transport_status == NIPC_STATUS_BAD_ENVELOPE); check("terminal close response empty", payload_len == 0); + check("terminal close session exited", + test_server_wait_session_exits(&sctx.events, 1)); } - usleep(300000); - nipc_client_ctx_t good_client; nipc_client_config_t good_cfg = default_client_config(); nipc_client_init(&good_client, TEST_RUN_DIR, svc, &good_cfg); @@ -6853,8 +7227,8 @@ static void test_drain_timeout(void) check("server init ok", err == NIPC_OK); pthread_t server_tid; + test_server_use_test_timings(&server, NULL); pthread_create(&server_tid, NULL, (void *(*)(void *))nipc_server_run, &server); - usleep(50000); /* Start clients that make calls */ drain_client_ctx_t cctxs[2]; @@ -6862,18 +7236,23 @@ static void test_drain_timeout(void) for (int i = 0; i < 2; i++) { cctxs[i].service = svc; __atomic_store_n(&cctxs[i].done, 0, __ATOMIC_RELAXED); + __atomic_store_n(&cctxs[i].connected, 0, __ATOMIC_RELAXED); + __atomic_store_n(&cctxs[i].release, 0, __ATOMIC_RELAXED); __atomic_store_n(&cctxs[i].call_ok, 0, __ATOMIC_RELAXED); pthread_create(&ctids[i], NULL, drain_client_fn, &cctxs[i]); } - usleep(100000); + for (int i = 0; i < 2; i++) + check("drain client connected", test_flag_wait(&cctxs[i].connected)); /* Drain with short timeout */ bool drained = nipc_server_drain(&server, 3000); /* With fast service callbacks and 3s timeout, drain should succeed */ check("drain completed", drained); - for (int i = 0; i < 2; i++) + for (int i = 0; i < 2; i++) { + test_flag_set(&cctxs[i].release); pthread_join(ctids[i], NULL); + } pthread_join(server_tid, NULL); cleanup_all(svc); } @@ -6928,8 +7307,9 @@ static void test_cache_empty_snapshot(void) 1, NIPC_METHOD_CGROUPS_SNAPSHOT, empty_cgroups_handler, NULL); check("server init", serr == NIPC_OK); + test_server_use_test_timings(&sctx.server, NULL); + pthread_create(&tid, NULL, (void *(*)(void *))nipc_server_run, &sctx.server); - usleep(50000); /* Create cache and refresh */ nipc_cgroups_cache_t cache; @@ -7136,6 +7516,7 @@ static void test_client_init_defaults_and_truncation(void) nipc_client_close(&client); } +#if defined(__linux__) || defined(__FreeBSD__) /* ------------------------------------------------------------------ */ /* Coverage: negotiated SHM obstruction rejects the session */ /* ------------------------------------------------------------------ */ @@ -7154,10 +7535,7 @@ static void test_shm_negotiation_falls_back_to_baseline_on_obstructed_region(voi pthread_t tid; pthread_create(&tid, NULL, shm_server_thread_fn, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("obstructed SHM server started", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); @@ -7201,10 +7579,7 @@ static void test_shm_allocation_failure_falls_back_to_working_baseline(void) pthread_t tid; pthread_create(&tid, NULL, shm_server_thread_fn, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("allocation-fallback SHM server started", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); @@ -7254,10 +7629,7 @@ static void test_client_shm_attach_failure_falls_back_to_baseline(void) pthread_t tid; pthread_create(&tid, NULL, fake_shm_attach_fallback_server_thread_fn, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("attach-fallback server started", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); @@ -7271,6 +7643,8 @@ static void test_client_shm_attach_failure_falls_back_to_baseline(void) nipc_client_ctx_t client; nipc_client_init(&client, TEST_RUN_DIR, svc, &ccfg); + nipc_client_set_shm_attach_timeout_for_tests(&client, + TEST_CLIENT_SHM_ATTACH_TIMEOUT_MS); bool changed = nipc_client_refresh(&client); check("refresh reports READY transition after attach failure fallback", changed); check("client is ready after attach failure fallback", nipc_client_ready(&client)); @@ -7302,6 +7676,7 @@ static void test_client_shm_attach_failure_falls_back_to_baseline(void) cleanup_all(svc); } +#endif /* ------------------------------------------------------------------ */ /* Main */ /* ------------------------------------------------------------------ */ @@ -7316,7 +7691,10 @@ int main(void) test_client_lifecycle(); printf("\n"); test_cgroups_call(); printf("\n"); + test_server_zero_profiles_selects_baseline(); printf("\n"); + test_server_shutdown_order(); printf("\n"); test_client_call_timeout_on_wedged_peer(); printf("\n"); + test_client_send_bounded_by_deadline_and_abort(); printf("\n"); test_client_abort_unblocks_call(); printf("\n"); test_cgroups_lookup_call(); printf("\n"); test_apps_lookup_call(); printf("\n"); @@ -7330,9 +7708,12 @@ int main(void) test_lookup_logical_limits(); printf("\n"); test_cgroups_lookup_rejects_mixed_generation_retry(); printf("\n"); test_apps_lookup_rejects_mixed_generation_retry(); printf("\n"); +#if defined(__linux__) || defined(__FreeBSD__) test_cgroups_lookup_call_shm(); printf("\n"); test_apps_lookup_call_shm(); printf("\n"); test_shm_lookup_session_closes_after_client_disconnect(); printf("\n"); +#endif + test_peer_disconnected_probe(); printf("\n"); test_lookup_init_guards(); printf("\n"); test_lookup_client_invalid_requests(); printf("\n"); test_lookup_rejects_bad_server_responses(); printf("\n"); @@ -7351,11 +7732,16 @@ int main(void) test_status_reporting(); printf("\n"); test_graceful_drain(); printf("\n"); test_non_request_terminates_session(); printf("\n"); +#if defined(__linux__) || defined(__FreeBSD__) test_shm_l2_service(); printf("\n"); test_shm_client_rejects_malformed_responses(); printf("\n"); +#endif + test_send_failure_with_live_peer_is_transport(); printf("\n"); +#if defined(__linux__) || defined(__FreeBSD__) test_shm_server_rejects_malformed_requests(); printf("\n"); test_shm_request_resize_retry(); printf("\n"); test_shm_response_resize_retry(); printf("\n"); +#endif test_cache_refresh_failure_preserves(); printf("\n"); test_malformed_response_handling(); printf("\n"); @@ -7375,9 +7761,11 @@ int main(void) test_server_init_long_strings(); printf("\n"); test_server_init_worker_floor_and_long_run_dir(); printf("\n"); test_client_init_defaults_and_truncation(); printf("\n"); +#if defined(__linux__) || defined(__FreeBSD__) test_shm_negotiation_falls_back_to_baseline_on_obstructed_region(); printf("\n"); test_shm_allocation_failure_falls_back_to_working_baseline(); printf("\n"); test_client_shm_attach_failure_falls_back_to_baseline(); printf("\n"); +#endif printf("=== Results: %d passed, %d failed ===\n", g_pass, g_fail); return g_fail == 0 ? 0 : 1; diff --git a/tests/fixtures/c/test_service_extra.c b/tests/fixtures/c/test_service_extra.c index a3a1990f..8c7c3aef 100644 --- a/tests/fixtures/c/test_service_extra.c +++ b/tests/fixtures/c/test_service_extra.c @@ -2,6 +2,8 @@ #include "netipc/netipc_service.h" #include "netipc/netipc_protocol.h" +#include "test_sync.h" +#include "test_lock_cleanup.h" #include "netipc_service_common.h" #include @@ -34,6 +36,7 @@ static void unique_service(char *buf, size_t len, const char *prefix) { int n = __sync_add_and_fetch(&g_service_counter, 1); snprintf(buf, len, "%s_%d_%ld", prefix, n, (long)getpid()); + test_locks_track(TEST_RUN_DIR, buf); } static int cache_has(nipc_cgroups_cache_t *cache, uint32_t hash, const char *name) @@ -136,6 +139,7 @@ typedef struct { nipc_server_config_t config; nipc_cgroups_service_handler_t service_handler; nipc_managed_server_t server; + test_server_events_t events; int ready; int done; int init_ok; @@ -152,14 +156,15 @@ static void *managed_server_thread(void *arg) if (err != NIPC_OK) { fprintf(stderr, "server init failed for %s: %d\n", ctx->service, err); __atomic_store_n(&ctx->init_ok, 0, __ATOMIC_RELEASE); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } __atomic_store_n(&ctx->init_ok, 1, __ATOMIC_RELEASE); - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_server_use_test_timings(&ctx->server, &ctx->events); + test_flag_set(&ctx->ready); nipc_server_run(&ctx->server); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -182,10 +187,7 @@ static int start_server_named(server_thread_ctx_t *ctx, } check("server thread created", 1); - for (int i = 0; i < 2000 - && !__atomic_load_n(&ctx->ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&ctx->done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&ctx->ready, &ctx->done); check("server ready", __atomic_load_n(&ctx->ready, __ATOMIC_ACQUIRE) == 1); check("server init ok", __atomic_load_n(&ctx->init_ok, __ATOMIC_ACQUIRE) == 1); @@ -211,30 +213,18 @@ static void stop_server_drain(server_thread_ctx_t *ctx, pthread_t tid) nipc_server_destroy(&ctx->server); } -static int refresh_until_ready(nipc_client_ctx_t *client, int max_tries, useconds_t sleep_us) +/* One refresh, then report whether the client is ready. */ +static int refresh_ready(nipc_client_ctx_t *client) { - for (int i = 0; i < max_tries; i++) { - nipc_client_refresh(client); - if (nipc_client_ready(client)) - return 1; - usleep(sleep_us); - } - - return 0; + nipc_client_refresh(client); + return nipc_client_ready(client); } -static int refresh_until_state(nipc_client_ctx_t *client, - nipc_client_state_t target_state, - int max_tries, - useconds_t sleep_us) +/* One refresh, then report whether the client reached target_state. */ +static int refresh_to_state(nipc_client_ctx_t *client, + nipc_client_state_t target_state) { - for (int i = 0; i < max_tries; i++) { - nipc_client_refresh(client); - if (client->state == target_state) - return 1; - usleep(sleep_us); - } - + nipc_client_refresh(client); return client->state == target_state; } @@ -243,6 +233,77 @@ static void clear_test_faults(void) nipc_posix_service_test_fault_clear(); } +#if defined(__linux__) || defined(__FreeBSD__) +static int shm_regions_created(server_thread_ctx_t *ctx) +{ + test_sync_lock(); + int n = ctx->events.shm_created; + test_sync_unlock(); + return n; +} +#endif + +/* The server creates a session's SHM region only when the handshake + * selects SHM, and falls back to baseline in the same handshake when the + * region cannot be created. The region exists before HELLO_ACK, so a + * client that is READY has already been counted. */ +#if defined(__linux__) || defined(__FreeBSD__) +static void test_shm_region_only_for_shm_sessions(void) +{ + printf("--- SHM region created only for SHM sessions ---\n"); + + char service[64]; + unique_service(service, sizeof(service), "svc_shm_after_negotiation"); + + server_thread_ctx_t sctx; + pthread_t tid; + nipc_server_config_t scfg = default_typed_hybrid_server_config(); + if (!start_server_named(&sctx, service, 4, &scfg, &full_service_handler, &tid)) + return; + + nipc_client_ctx_t client; + nipc_cgroups_resp_view_t view; + + nipc_client_config_t baseline_cfg = default_client_config(); + nipc_client_init(&client, TEST_RUN_DIR, service, &baseline_cfg); + check("baseline client ready", refresh_ready(&client) && client.shm == NULL); + check("baseline client call works", + nipc_client_call_cgroups_snapshot(&client, &view) == NIPC_OK); + check("baseline session created no SHM region", shm_regions_created(&sctx) == 0); + nipc_client_close(&client); + + nipc_client_config_t bad_auth_cfg = default_typed_hybrid_client_config(); + bad_auth_cfg.auth_token = AUTH_TOKEN + 1; + nipc_client_init(&client, TEST_RUN_DIR, service, &bad_auth_cfg); + check("rejected client reports auth failure", + refresh_to_state(&client, NIPC_CLIENT_AUTH_FAILED)); + check("rejected client created no SHM region", shm_regions_created(&sctx) == 0); + nipc_client_close(&client); + + nipc_client_config_t shm_cfg = default_typed_hybrid_client_config(); + nipc_client_init(&client, TEST_RUN_DIR, service, &shm_cfg); + check("SHM client ready on SHM", refresh_ready(&client) && client.shm != NULL); + check("SHM client call works", + nipc_client_call_cgroups_snapshot(&client, &view) == NIPC_OK); + check("SHM session created one region", shm_regions_created(&sctx) == 1); + nipc_client_close(&client); + + nipc_posix_service_test_fault_set( + NIPC_POSIX_SERVICE_TEST_FAULT_SERVER_SHM_CTX_CALLOC, 0); + nipc_client_init(&client, TEST_RUN_DIR, service, &shm_cfg); + check("failed region falls back to baseline in one handshake", + refresh_ready(&client) && client.shm == NULL && + client.session.selected_profile == NIPC_PROFILE_BASELINE); + clear_test_faults(); + check("fallback session call works", + nipc_client_call_cgroups_snapshot(&client, &view) == NIPC_OK); + check("failed region was not counted", shm_regions_created(&sctx) == 1); + nipc_client_close(&client); + + stop_server_drain(&sctx, tid); +} +#endif + static void ensure_run_dir(void) { mkdir(TEST_RUN_DIR, 0700); @@ -414,17 +475,18 @@ static void test_client_fault_injection_disconnects_and_recovers(void) nipc_posix_service_test_fault_set( NIPC_POSIX_SERVICE_TEST_FAULT_CLIENT_RESPONSE_BUF_REALLOC, 0); check("response buffer alloc fault disconnects client", - refresh_until_state(&client, NIPC_CLIENT_DISCONNECTED, 20, 10000)); + refresh_to_state(&client, NIPC_CLIENT_DISCONNECTED)); check("response buffer alloc fault leaves client not ready", !nipc_client_ready(&client)); clear_test_faults(); check("response buffer alloc fault recovers", - refresh_until_ready(&client, 100, 10000)); + refresh_ready(&client)); nipc_client_close(&client); stop_server_drain(&sctx, tid); } +#if defined(__linux__) || defined(__FreeBSD__) { char service[64]; unique_service(service, sizeof(service), "svc_client_send_fault"); @@ -442,15 +504,17 @@ static void test_client_fault_injection_disconnects_and_recovers(void) nipc_posix_service_test_fault_set( NIPC_POSIX_SERVICE_TEST_FAULT_CLIENT_SEND_BUF_REALLOC, 0); check("send buffer alloc fault disconnects hybrid client", - refresh_until_state(&client, NIPC_CLIENT_DISCONNECTED, 20, 10000)); + refresh_to_state(&client, NIPC_CLIENT_DISCONNECTED)); clear_test_faults(); check("send buffer alloc fault recovers", - refresh_until_ready(&client, 200, 10000) && client.shm != NULL); + refresh_ready(&client) && client.shm != NULL); nipc_client_close(&client); stop_server_drain(&sctx, tid); } +#endif +#if defined(__linux__) || defined(__FreeBSD__) { char service[64]; unique_service(service, sizeof(service), "svc_client_shm_ctx_fault"); @@ -468,16 +532,17 @@ static void test_client_fault_injection_disconnects_and_recovers(void) nipc_posix_service_test_fault_set( NIPC_POSIX_SERVICE_TEST_FAULT_CLIENT_SHM_CTX_CALLOC, 0); check("client SHM ctx alloc fault disconnects hybrid client", - refresh_until_state(&client, NIPC_CLIENT_DISCONNECTED, 20, 10000)); + refresh_to_state(&client, NIPC_CLIENT_DISCONNECTED)); check("client SHM ctx alloc fault leaves session invalid", !client.session_valid && client.shm == NULL); clear_test_faults(); check("client SHM ctx alloc fault recovers", - refresh_until_ready(&client, 200, 10000) && client.shm != NULL); + refresh_ready(&client) && client.shm != NULL); nipc_client_close(&client); stop_server_drain(&sctx, tid); } +#endif } static void test_server_init_fault_injection(void) @@ -575,6 +640,9 @@ int main(void) test_common_service_helpers(); test_lookup_remaining_timeout_helpers(); test_client_fault_injection_disconnects_and_recovers(); +#if defined(__linux__) || defined(__FreeBSD__) + test_shm_region_only_for_shm_sessions(); +#endif test_server_init_fault_injection(); test_cache_fault_injection(); diff --git a/tests/fixtures/c/test_service_limit_helpers.h b/tests/fixtures/c/test_service_limit_helpers.h index d951f1a4..0f6e86d1 100644 --- a/tests/fixtures/c/test_service_limit_helpers.h +++ b/tests/fixtures/c/test_service_limit_helpers.h @@ -10,6 +10,9 @@ #include #include +#include "test_lock_cleanup.h" +#include "test_sync.h" + #define AUTH_TOKEN 0xDEADBEEFCAFEBABEull #define TEST_RUN_DIR "/tmp/nipc_service_limits" #define RESPONSE_BUF_SIZE 65536 @@ -33,6 +36,7 @@ static void unique_service(char *buf, size_t len, const char *prefix) { int n = __sync_add_and_fetch(&g_service_counter, 1); snprintf(buf, len, "%s_%d_%ld", prefix, n, (long)getpid()); + test_locks_track(TEST_RUN_DIR, buf); } static nipc_server_config_t default_typed_server_config(void) @@ -162,15 +166,25 @@ static void *managed_server_thread(void *arg) &ctx->service_handler); if (err != NIPC_OK) { fprintf(stderr, "server init failed for %s: %d\n", ctx->service, err); - __atomic_store_n(&ctx->init_ok, 0, __ATOMIC_RELEASE); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_sync_lock(); + ctx->init_ok = 0; + ctx->done = 1; + test_sync_broadcast(); + test_sync_unlock(); return NULL; } - __atomic_store_n(&ctx->init_ok, 1, __ATOMIC_RELEASE); - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + /* The listener accepts connections from here on; run takes them over. */ + test_sync_lock(); + ctx->init_ok = 1; + ctx->ready = 1; + test_sync_broadcast(); + test_sync_unlock(); nipc_server_run(&ctx->server); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_sync_lock(); + ctx->done = 1; + test_sync_broadcast(); + test_sync_unlock(); return NULL; } @@ -193,15 +207,17 @@ static int start_server_named(server_thread_ctx_t *ctx, } check("server thread created", 1); - for (int i = 0; i < 2000 - && !__atomic_load_n(&ctx->ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&ctx->done, __ATOMIC_ACQUIRE); i++) - usleep(500); - - check("server ready", __atomic_load_n(&ctx->ready, __ATOMIC_ACQUIRE) == 1); - check("server init ok", __atomic_load_n(&ctx->init_ok, __ATOMIC_ACQUIRE) == 1); - return __atomic_load_n(&ctx->ready, __ATOMIC_ACQUIRE) == 1 && - __atomic_load_n(&ctx->init_ok, __ATOMIC_ACQUIRE) == 1; + uint64_t deadline = test_sync_deadline(); + test_sync_lock(); + while (!ctx->ready && !ctx->done && test_sync_wait_until(deadline)) + ; + int ready = ctx->ready; + int init_ok = ctx->init_ok; + test_sync_unlock(); + + check("server ready", ready == 1); + check("server init ok", init_ok == 1); + return ready == 1 && init_ok == 1; } static int start_default_server_named(server_thread_ctx_t *ctx, @@ -222,16 +238,12 @@ static void stop_server_drain(server_thread_ctx_t *ctx, pthread_t tid) nipc_server_destroy(&ctx->server); } -static int refresh_until_ready(nipc_client_ctx_t *client, int max_tries, useconds_t sleep_us) +/* The server listens before start_server_named() returns, so one refresh + * connects: the handshake waits until the run loop accepts. */ +static int refresh_ready(nipc_client_ctx_t *client) { - for (int i = 0; i < max_tries; i++) { - nipc_client_refresh(client); - if (nipc_client_ready(client)) - return 1; - usleep(sleep_us); - } - - return 0; + nipc_client_refresh(client); + return nipc_client_ready(client); } static void ensure_run_dir(void) diff --git a/tests/fixtures/c/test_service_method_limits.c b/tests/fixtures/c/test_service_method_limits.c index eec9f876..4f7871fb 100644 --- a/tests/fixtures/c/test_service_method_limits.c +++ b/tests/fixtures/c/test_service_method_limits.c @@ -19,7 +19,7 @@ static void test_typed_unsupported_method_response_guard(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("typed unsupported-method client ready", - refresh_until_ready(&client, 200, 10000) && client.shm == NULL); + refresh_ready(&client) && client.shm == NULL); if (nipc_client_ready(&client)) { nipc_cgroups_resp_view_t view = {0}; @@ -40,6 +40,7 @@ static void test_typed_unsupported_method_response_guard(void) stop_server_drain(&sctx, tid); } +#if defined(__linux__) || defined(__FreeBSD__) { char service[64]; unique_service(service, sizeof(service), "svc_typed_hybrid_unsupported"); @@ -54,7 +55,7 @@ static void test_typed_unsupported_method_response_guard(void) nipc_client_config_t ccfg = default_typed_hybrid_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("typed hybrid unsupported-method client ready", - refresh_until_ready(&client, 200, 10000) && client.shm != NULL); + refresh_ready(&client) && client.shm != NULL); if (nipc_client_ready(&client) && client.shm != NULL) { nipc_cgroups_resp_view_t view = {0}; @@ -74,6 +75,7 @@ static void test_typed_unsupported_method_response_guard(void) nipc_client_close(&client); stop_server_drain(&sctx, tid); } +#endif } int main(void) diff --git a/tests/fixtures/c/test_service_payload_limits.c b/tests/fixtures/c/test_service_payload_limits.c index 3926d1ae..9789b6eb 100644 --- a/tests/fixtures/c/test_service_payload_limits.c +++ b/tests/fixtures/c/test_service_payload_limits.c @@ -19,7 +19,7 @@ static void test_typed_request_overflow_reconnect_guards(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("typed baseline request-overflow client ready", - refresh_until_ready(&client, 200, 10000) && client.shm == NULL); + refresh_ready(&client) && client.shm == NULL); if (nipc_client_ready(&client)) { nipc_cgroups_resp_view_t view = {0}; @@ -41,6 +41,7 @@ static void test_typed_request_overflow_reconnect_guards(void) stop_server_drain(&sctx, tid); } +#if defined(__linux__) || defined(__FreeBSD__) { char service[64]; unique_service(service, sizeof(service), "svc_typed_hybrid_req_overflow"); @@ -55,7 +56,7 @@ static void test_typed_request_overflow_reconnect_guards(void) nipc_client_config_t ccfg = default_typed_hybrid_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("typed hybrid request-overflow client ready", - refresh_until_ready(&client, 200, 10000) && client.shm != NULL); + refresh_ready(&client) && client.shm != NULL); if (nipc_client_ready(&client) && client.shm != NULL) { nipc_cgroups_resp_view_t view = {0}; @@ -78,7 +79,9 @@ static void test_typed_request_overflow_reconnect_guards(void) nipc_client_close(&client); stop_server_drain(&sctx, tid); } +#endif +#if defined(__linux__) || defined(__FreeBSD__) { char service[64]; unique_service(service, sizeof(service), "svc_typed_hybrid_no_growth"); @@ -93,7 +96,7 @@ static void test_typed_request_overflow_reconnect_guards(void) nipc_client_config_t ccfg = default_typed_hybrid_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("typed hybrid no-growth overflow client ready", - refresh_until_ready(&client, 200, 10000) && client.shm != NULL); + refresh_ready(&client) && client.shm != NULL); if (nipc_client_ready(&client) && client.shm != NULL) { nipc_cgroups_resp_view_t view = {0}; @@ -113,6 +116,7 @@ static void test_typed_request_overflow_reconnect_guards(void) nipc_client_close(&client); stop_server_drain(&sctx, tid); } +#endif } static void test_typed_response_overflow_reconnect_guards(void) @@ -134,7 +138,7 @@ static void test_typed_response_overflow_reconnect_guards(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("typed baseline response-overflow client ready", - refresh_until_ready(&client, 200, 10000) && client.shm == NULL); + refresh_ready(&client) && client.shm == NULL); if (nipc_client_ready(&client)) { nipc_cgroups_resp_view_t view = {0}; @@ -156,6 +160,7 @@ static void test_typed_response_overflow_reconnect_guards(void) stop_server_drain(&sctx, tid); } +#if defined(__linux__) || defined(__FreeBSD__) { char service[64]; unique_service(service, sizeof(service), "svc_typed_hybrid_resp_overflow"); @@ -171,7 +176,7 @@ static void test_typed_response_overflow_reconnect_guards(void) nipc_client_config_t ccfg = default_typed_hybrid_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("typed hybrid response-overflow client ready", - refresh_until_ready(&client, 200, 10000) && client.shm != NULL); + refresh_ready(&client) && client.shm != NULL); if (nipc_client_ready(&client) && client.shm != NULL) { nipc_cgroups_resp_view_t view = {0}; @@ -192,6 +197,7 @@ static void test_typed_response_overflow_reconnect_guards(void) nipc_client_close(&client); stop_server_drain(&sctx, tid); } +#endif } static void test_typed_dispatch_overflow_reconnect_guard(void) @@ -217,7 +223,7 @@ static void test_typed_dispatch_overflow_reconnect_guard(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("typed dispatch-overflow client ready", - refresh_until_ready(&client, 200, 10000) && client.shm == NULL); + refresh_ready(&client) && client.shm == NULL); if (nipc_client_ready(&client)) { nipc_cgroups_resp_view_t view = {0}; diff --git a/tests/fixtures/c/test_shm.c b/tests/fixtures/c/test_shm.c index ec90953b..c54f3c12 100644 --- a/tests/fixtures/c/test_shm.c +++ b/tests/fixtures/c/test_shm.c @@ -10,6 +10,7 @@ #include "netipc/netipc_shm.h" #include "netipc/netipc_uds.h" #include "netipc/netipc_protocol.h" +#include "test_sync.h" #include #include @@ -32,6 +33,9 @@ static int g_fail = 0; #define TEST_RUN_DIR "/tmp/nipc_shm_test" #define AUTH_TOKEN 0xDEADBEEFCAFEBABEull +/* Above every OS PID limit, so never a live process; a small fixed PID such + * as 99999 can be in use on hosts with a large pid_max. */ +#define DEAD_PID INT32_MAX static void check(const char *name, int cond) { @@ -94,11 +98,11 @@ static void *shm_echo_server_thread(void *arg) TEST_RUN_DIR, ctx->service, 1, 4096, 4096, &shm); if (err != NIPC_SHM_OK) { fprintf(stderr, "shm server create failed: %d\n", err); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); /* Receive a request. */ uint8_t msg[65536]; @@ -107,7 +111,7 @@ static void *shm_echo_server_thread(void *arg) if (err != NIPC_SHM_OK) { fprintf(stderr, "shm server receive failed: %d\n", err); nipc_shm_destroy(&shm); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -139,7 +143,7 @@ static void *shm_echo_server_thread(void *arg) } nipc_shm_destroy(&shm); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -157,11 +161,7 @@ static void test_direct_roundtrip(void) pthread_create(&tid, NULL, shm_echo_server_thread, &sctx); /* Wait for server to be ready. */ - int retries = 0; - while (!__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE) && retries < 2000) { - usleep(500); - retries++; - } + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("server created SHM region", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); /* Client attaches. */ @@ -229,6 +229,7 @@ typedef struct { int ready; int done; int shm_created; + int region_attempted; /* __atomic */ int echo_ok; } hybrid_server_ctx_t; @@ -256,17 +257,17 @@ static void *hybrid_server_thread(void *arg) &scfg, &listener); if (uerr != NIPC_UDS_OK) { fprintf(stderr, "hybrid server listen failed: %d\n", uerr); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); nipc_uds_session_t session; uerr = nipc_uds_accept(&listener, 1, &session); if (uerr != NIPC_UDS_OK) { nipc_uds_close_listener(&listener); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -279,10 +280,12 @@ static void *hybrid_server_thread(void *arg) TEST_RUN_DIR, ctx->service, session.session_id, 4096, 4096, &shm); if (serr == NIPC_SHM_OK) { - ctx->shm_created = 1; + test_flag_set(&ctx->shm_created); use_shm = 1; } } + /* Released either way, so the client never waits for a missing region. */ + test_flag_set(&ctx->region_attempted); if (use_shm) { /* Echo via SHM. */ @@ -314,7 +317,7 @@ static void *hybrid_server_thread(void *arg) nipc_uds_close_session(&session); nipc_uds_close_listener(&listener); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -331,11 +334,7 @@ static void test_negotiated_shm(void) pthread_t tid; pthread_create(&tid, NULL, hybrid_server_thread, &sctx); - int retries = 0; - while (!__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE) && retries < 2000) { - usleep(500); - retries++; - } + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); /* Client connects via UDS with SHM_HYBRID support. */ @@ -358,25 +357,12 @@ static void test_negotiated_shm(void) check("selected profile is SHM_HYBRID", session.selected_profile == NIPC_PROFILE_SHM_HYBRID); - /* Attach to SHM (with retry -- server creates region after - * the UDS handshake, so the file may not exist yet or the - * header may not be written yet). */ + /* The server creates the region after the UDS handshake. */ + check("server tried to create the SHM region", + test_flag_wait(&sctx.region_attempted)); nipc_shm_ctx_t client_shm; - nipc_shm_error_t serr = NIPC_SHM_ERR_NOT_READY; - for (int i = 0; i < 500; i++) { - serr = nipc_shm_client_attach(TEST_RUN_DIR, svc, - session.session_id, &client_shm); - if (serr == NIPC_SHM_OK) - break; - /* Retryable: file doesn't exist, header not ready, or - * header not yet published (magic is still zero). */ - if (serr == NIPC_SHM_ERR_NOT_READY || - serr == NIPC_SHM_ERR_OPEN || - serr == NIPC_SHM_ERR_BAD_MAGIC) - usleep(10000); - else - break; /* unexpected error */ - } + nipc_shm_error_t serr = nipc_shm_client_attach(TEST_RUN_DIR, svc, + session.session_id, &client_shm); check("client SHM attach", serr == NIPC_SHM_OK); if (serr == NIPC_SHM_OK) { @@ -448,10 +434,10 @@ static void *baseline_only_server_thread(void *arg) nipc_uds_error_t uerr = nipc_uds_listen(TEST_RUN_DIR, ctx->service, &scfg, &listener); if (uerr != NIPC_UDS_OK) { - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); nipc_uds_session_t session; uerr = nipc_uds_accept(&listener, 1, &session); @@ -462,7 +448,7 @@ static void *baseline_only_server_thread(void *arg) } nipc_uds_close_listener(&listener); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -479,11 +465,7 @@ static void test_profile_fallback(void) pthread_t tid; pthread_create(&tid, NULL, baseline_only_server_thread, &sctx); - int retries = 0; - while (!__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE) && retries < 2000) { - usleep(500); - retries++; - } + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); /* Client supports both, but server only supports baseline. */ @@ -534,11 +516,8 @@ static void test_disconnect_detection(void) err = nipc_shm_client_attach(TEST_RUN_DIR, svc, 1, &client); check("client attach", err == NIPC_SHM_OK); - if (err == NIPC_SHM_OK) { - /* Owner (this process) is alive. */ - check("owner alive from client", nipc_shm_owner_alive(&client)); + if (err == NIPC_SHM_OK) nipc_shm_close(&client); - } nipc_shm_destroy(&server); } @@ -567,7 +546,7 @@ static void test_stale_shm_recovery(void) /* Set owner_pid to a definitely-dead PID. */ nipc_shm_region_header_t *hdr = (nipc_shm_region_header_t *)first.base; - hdr->owner_pid = 99999; /* very unlikely to be alive */ + hdr->owner_pid = DEAD_PID; /* Don't destroy (unlink) -- just unmap and close. */ nipc_shm_close(&first); @@ -602,10 +581,10 @@ static void *large_msg_server_thread(void *arg) nipc_shm_error_t err = nipc_shm_server_create( TEST_RUN_DIR, ctx->service, 1, 65536, 65536, &shm); if (err != NIPC_SHM_OK) { - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); uint8_t *msg = malloc(65536); size_t msg_len; @@ -632,7 +611,7 @@ static void *large_msg_server_thread(void *arg) free(msg); nipc_shm_destroy(&shm); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -649,11 +628,7 @@ static void test_large_message(void) pthread_t tid; pthread_create(&tid, NULL, large_msg_server_thread, &sctx); - int retries = 0; - while (!__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE) && retries < 2000) { - usleep(500); - retries++; - } + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); nipc_shm_ctx_t client; @@ -804,10 +779,10 @@ static void *multi_rt_server_thread(void *arg) nipc_shm_error_t err = nipc_shm_server_create( TEST_RUN_DIR, ctx->service, 1, 4096, 4096, &shm); if (err != NIPC_SHM_OK) { - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); int all_ok = 1; uint8_t recv_msg[65536]; @@ -841,7 +816,7 @@ static void *multi_rt_server_thread(void *arg) ctx->echo_ok = all_ok; nipc_shm_destroy(&shm); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -858,11 +833,7 @@ static void test_multiple_roundtrips(void) pthread_t tid; pthread_create(&tid, NULL, multi_rt_server_thread, &sctx); - int retries = 0; - while (!__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE) && retries < 2000) { - usleep(500); - retries++; - } + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); nipc_shm_ctx_t client; @@ -930,6 +901,11 @@ static void test_server_create_validation(void) nipc_shm_server_create(NULL, "svc", 1, 4096, 4096, &ctx) == NIPC_SHM_ERR_BAD_PARAM); + /* Empty run_dir */ + check("empty run_dir", + nipc_shm_server_create("", "svc", 1, 4096, 4096, &ctx) + == NIPC_SHM_ERR_BAD_PARAM); + /* NULL service_name */ check("null service_name", nipc_shm_server_create(TEST_RUN_DIR, NULL, 1, 4096, 4096, &ctx) @@ -1022,6 +998,19 @@ static void test_server_create_allocation_failures(void) check("unsupported allocation removes incomplete region", access(path, F_OK) != 0); + /* FreeBSD ZFS rejects posix_fallocate with EINVAL. */ + nipc_shm_test_fault_set(NIPC_SHM_TEST_FAULT_ALLOCATE, EINVAL); + err = nipc_shm_server_create( + TEST_RUN_DIR, svc, 1, 4096, 4096, &ctx); + allocation_errno = errno; + nipc_shm_test_fault_clear(); + + check("EINVAL allocation has no sparse fallback", + err == NIPC_SHM_ERR_ALLOCATE); + check("EINVAL allocation preserves errno", allocation_errno == EINVAL); + check("EINVAL allocation removes incomplete region", + access(path, F_OK) != 0); + nipc_shm_test_fault_set(NIPC_SHM_TEST_FAULT_ALLOCATE, EINTR); err = nipc_shm_server_create(TEST_RUN_DIR, svc, 1, 4096, 4096, &ctx); nipc_shm_test_fault_clear(); @@ -1116,6 +1105,36 @@ static void test_shm_send_bad_param(void) err != NIPC_SHM_OK); } +/* A server under a umask without owner read or write must still create a + * region its client can attach to read-write. */ +static void test_shm_attach_under_restrictive_umask(void) +{ + printf("Test: Client attaches to a region created under umask 0477\n"); + const char *svc = "shm_umask"; + cleanup_shm(svc); + + nipc_shm_ctx_t server; + mode_t old_mask = umask(0477); + nipc_shm_error_t err = nipc_shm_server_create(TEST_RUN_DIR, svc, 1, 4096, 4096, &server); + umask(old_mask); + check("server create under umask 0477", err == NIPC_SHM_OK); + if (err != NIPC_SHM_OK) + return; + + char path[256]; + snprintf(path, sizeof(path), "%s/%s-%016" PRIx64 ".ipcshm", TEST_RUN_DIR, svc, + (uint64_t)1); + struct stat st; + check("region file is 0600", stat(path, &st) == 0 && (st.st_mode & 0777) == 0600); + + nipc_shm_ctx_t client; + err = nipc_shm_client_attach(TEST_RUN_DIR, svc, 1, &client); + check("client attaches", err == NIPC_SHM_OK); + if (err == NIPC_SHM_OK) + nipc_shm_close(&client); + nipc_shm_destroy(&server); +} + static void test_shm_bad_magic_file(void) { printf("Test: Client attach to file with bad magic\n"); @@ -1266,129 +1285,6 @@ static void test_null_params(void) check("receive(NULL) returns error", err == NIPC_SHM_ERR_BAD_PARAM); } -/* ------------------------------------------------------------------ */ -/* Coverage: pid_alive with pid=0 */ -/* ------------------------------------------------------------------ */ - -static void test_pid_zero(void) -{ - printf("Test: owner_alive with dead PID via pid=0 in header\n"); - const char *svc = "shm_pid0"; - cleanup_shm(svc); - - nipc_shm_ctx_t server; - nipc_shm_error_t err = nipc_shm_server_create( - TEST_RUN_DIR, svc, 1, 1024, 1024, &server); - check("server create", err == NIPC_SHM_OK); - - if (err == NIPC_SHM_OK) { - nipc_shm_ctx_t client; - err = nipc_shm_client_attach(TEST_RUN_DIR, svc, 1, &client); - check("client attach", err == NIPC_SHM_OK); - - if (err == NIPC_SHM_OK) { - /* Corrupt owner_pid to 0 */ - nipc_shm_region_header_t *hdr = - (nipc_shm_region_header_t *)client.base; - int32_t saved_pid = hdr->owner_pid; - hdr->owner_pid = 0; - - check("owner_alive with pid=0 returns false", - !nipc_shm_owner_alive(&client)); - - /* Restore */ - hdr->owner_pid = saved_pid; - nipc_shm_close(&client); - } - nipc_shm_destroy(&server); - } - cleanup_shm(svc); -} - -/* ------------------------------------------------------------------ */ -/* Coverage: owner_alive with dead PID, generation mismatch */ -/* ------------------------------------------------------------------ */ - -static void test_owner_dead_pid(void) -{ - printf("Test: owner_alive with dead PID\n"); - const char *svc = "shm_deadpid"; - cleanup_shm(svc); - - nipc_shm_ctx_t server; - nipc_shm_error_t err = nipc_shm_server_create( - TEST_RUN_DIR, svc, 1, 1024, 1024, &server); - check("server create", err == NIPC_SHM_OK); - - if (err == NIPC_SHM_OK) { - nipc_shm_ctx_t client; - err = nipc_shm_client_attach(TEST_RUN_DIR, svc, 1, &client); - check("client attach", err == NIPC_SHM_OK); - - if (err == NIPC_SHM_OK) { - /* Set owner_pid to a definitely-dead PID */ - nipc_shm_region_header_t *hdr = - (nipc_shm_region_header_t *)client.base; - hdr->owner_pid = 99999; - - check("owner_alive with dead pid returns false", - !nipc_shm_owner_alive(&client)); - - /* Restore for cleanup */ - hdr->owner_pid = (int32_t)getpid(); - nipc_shm_close(&client); - } - nipc_shm_destroy(&server); - } - cleanup_shm(svc); -} - -static void test_owner_generation_mismatch(void) -{ - printf("Test: owner_alive with generation mismatch\n"); - const char *svc = "shm_gen_mm"; - cleanup_shm(svc); - - nipc_shm_ctx_t server; - nipc_shm_error_t err = nipc_shm_server_create( - TEST_RUN_DIR, svc, 1, 1024, 1024, &server); - check("server create", err == NIPC_SHM_OK); - - if (err == NIPC_SHM_OK) { - nipc_shm_ctx_t client; - err = nipc_shm_client_attach(TEST_RUN_DIR, svc, 1, &client); - check("client attach", err == NIPC_SHM_OK); - - if (err == NIPC_SHM_OK) { - /* Corrupt generation in header to mismatch client's cached value */ - nipc_shm_region_header_t *hdr = - (nipc_shm_region_header_t *)client.base; - uint32_t saved_gen = hdr->owner_generation; - hdr->owner_generation = saved_gen + 1; /* mismatch */ - - check("owner_alive with gen mismatch returns false", - !nipc_shm_owner_alive(&client)); - - /* Restore */ - hdr->owner_generation = saved_gen; - nipc_shm_close(&client); - } - nipc_shm_destroy(&server); - } - cleanup_shm(svc); -} - -static void test_owner_alive_null(void) -{ - printf("Test: owner_alive with NULL and null base\n"); - - check("owner_alive(NULL) returns false", !nipc_shm_owner_alive(NULL)); - - nipc_shm_ctx_t ctx; - memset(&ctx, 0, sizeof(ctx)); - check("owner_alive with null base returns false", !nipc_shm_owner_alive(&ctx)); -} - /* ------------------------------------------------------------------ */ /* Coverage: cleanup_stale with NULL params, invalid service, no dir */ /* ------------------------------------------------------------------ */ @@ -1438,7 +1334,7 @@ static void test_cleanup_stale_with_files(void) /* Set owner to dead PID */ nipc_shm_region_header_t *hdr = (nipc_shm_region_header_t *)server.base; - hdr->owner_pid = 99999; + hdr->owner_pid = DEAD_PID; /* Close without unlink (just close fd + munmap) */ nipc_shm_close(&server); @@ -1467,10 +1363,10 @@ static void *small_recv_server_thread(void *arg) nipc_shm_error_t err = nipc_shm_server_create( TEST_RUN_DIR, ctx->service, 1, 4096, 4096, &shm); if (err != NIPC_SHM_OK) { - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); /* Receive into a small buffer to trigger MSG_TOO_LARGE */ uint8_t msg[32]; /* very small */ @@ -1480,7 +1376,7 @@ static void *small_recv_server_thread(void *arg) ctx->echo_ok = (err == NIPC_SHM_ERR_MSG_TOO_LARGE) ? 1 : 0; nipc_shm_destroy(&shm); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -1497,12 +1393,7 @@ static void test_shm_receive_msg_too_large(void) pthread_t tid; pthread_create(&tid, NULL, small_recv_server_thread, &sctx); - int retries = 0; - while (!__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) && - !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE) && retries < 2000) { - usleep(500); - retries++; - } + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); nipc_shm_ctx_t client; @@ -1721,6 +1612,38 @@ static void test_shm_bad_size_alignment_file(void) cleanup_shm(svc); } +static void test_shm_response_end_wraps_uint32(void) +{ + printf("Test: Client attach rejects response area whose end wraps uint32\n"); + const char *svc = "shm_bad_size_wrap"; + cleanup_shm(svc); + + nipc_shm_ctx_t server; + nipc_shm_error_t err = nipc_shm_server_create( + TEST_RUN_DIR, svc, 1, 4096, 4096, &server); + check("server create", err == NIPC_SHM_OK); + + if (err == NIPC_SHM_OK) { + nipc_shm_region_header_t *hdr = + (nipc_shm_region_header_t *)server.base; + uint32_t saved_resp_off = hdr->response_offset; + uint32_t saved_resp_cap = hdr->response_capacity; + /* The end wraps to 0; on 32-bit size_t the old check accepted it. */ + hdr->response_offset = 0xFFFFFFC0u; + hdr->response_capacity = 0x40u; + + nipc_shm_ctx_t client; + err = nipc_shm_client_attach(TEST_RUN_DIR, svc, 1, &client); + check("wrapping response end rejected", err == NIPC_SHM_ERR_BAD_SIZE); + + hdr->response_offset = saved_resp_off; + hdr->response_capacity = saved_resp_cap; + nipc_shm_destroy(&server); + } + + cleanup_shm(svc); +} + static void test_shm_declared_area_exceeds_file(void) { printf("Test: Client attach to file with declared area beyond file size\n"); @@ -1854,6 +1777,7 @@ int main(void) test_server_create_validation(); printf("\n"); test_server_create_allocation_failures(); printf("\n"); test_client_attach_validation(); printf("\n"); + test_shm_attach_under_restrictive_umask(); printf("\n"); test_shm_close_null(); printf("\n"); test_shm_send_bad_param(); printf("\n"); test_shm_bad_magic_file(); printf("\n"); @@ -1863,10 +1787,6 @@ int main(void) /* Coverage gap tests */ test_null_params(); printf("\n"); - test_pid_zero(); printf("\n"); - test_owner_dead_pid(); printf("\n"); - test_owner_generation_mismatch(); printf("\n"); - test_owner_alive_null(); printf("\n"); test_cleanup_stale_params(); printf("\n"); test_cleanup_stale_with_files(); printf("\n"); test_cleanup_stale_ignores_nonmatching_files(); printf("\n"); @@ -1878,6 +1798,7 @@ int main(void) test_shm_bad_header_len_file(); printf("\n"); test_shm_bad_size_alignment_file(); printf("\n"); test_shm_declared_area_exceeds_file(); printf("\n"); + test_shm_response_end_wraps_uint32(); printf("\n"); test_shm_receive_zero_length_corruption(); printf("\n"); printf("=== Results: %d passed, %d failed ===\n", g_pass, g_fail); diff --git a/tests/fixtures/c/test_shm_timeout.c b/tests/fixtures/c/test_shm_timeout.c index dc0063bb..88a49a04 100644 --- a/tests/fixtures/c/test_shm_timeout.c +++ b/tests/fixtures/c/test_shm_timeout.c @@ -1,6 +1,7 @@ /* Exercise the public SHM wait path on native and cross-compiled Linux ABIs. */ #define _GNU_SOURCE #include "netipc/netipc_shm.h" +#include "test_sync.h" #include #include #include @@ -50,6 +51,9 @@ static int wake_wait(nipc_shm_ctx_t *receiver, nipc_shm_ctx_t *sender, if (pid < 0) return 0; if (pid == 0) { + /* alarm() is not inherited; bound the child too so a hang fails + * the test instead of holding the output pipe open. */ + alarm(10); usleep(50000); _exit(nipc_shm_send(sender, message, sizeof(message)) == NIPC_SHM_OK ? 0 : 1); } @@ -58,7 +62,8 @@ static int wake_wait(nipc_shm_ctx_t *receiver, nipc_shm_ctx_t *sender, nipc_shm_error_t err = nipc_shm_receive(receiver, buf, sizeof(buf), &len, timeout_ms); int status = 0; int waited = waitpid(pid, &status, 0) == pid; - printf("wake %u ms: result %d, length %zu\n", timeout_ms, err, len); + printf("wake %u ms: result %d, length %zu, child status 0x%x\n", + timeout_ms, err, len, (unsigned)status); return waited && WIFEXITED(status) && WEXITSTATUS(status) == 0 && err == NIPC_SHM_OK && len == sizeof(message) && memcmp(buf, message, sizeof(message)) == 0; @@ -66,6 +71,8 @@ static int wake_wait(nipc_shm_ctx_t *receiver, nipc_shm_ctx_t *sender, int main(void) { + /* Unbuffered so progress survives a kill when stdout is a pipe. */ + setvbuf(stdout, NULL, _IONBF, 0); /* Also bounds an accidentally infinite wait when run directly. */ alarm(20); printf("ABI: pointer=%zu time_t=%zu timespec=%zu nsec_offset=%zu\n", diff --git a/tests/fixtures/c/test_stress.c b/tests/fixtures/c/test_stress.c index ce000618..4ba1357d 100644 --- a/tests/fixtures/c/test_stress.c +++ b/tests/fixtures/c/test_stress.c @@ -768,7 +768,24 @@ static void test_long_running_stability(void) 8, 65536, 65536, 0, &stid); check("server started", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); - /* Read initial VmRSS */ + const int num_clients = 5; + pthread_t ctids[5]; + stability_client_ctx_t cctxs[5]; + + for (int i = 0; i < num_clients; i++) { + cctxs[i].service = svc; + cctxs[i].total_refreshes = 0; + cctxs[i].total_errors = 0; + __atomic_store_n(&cctxs[i].running, 1, __ATOMIC_RELAXED); + pthread_create(&ctids[i], NULL, stability_client_fn, &cctxs[i]); + } + + /* Baseline after a warm-up: thread stacks, allocator arenas and, under + * QEMU, the emulator's per-thread state and translation cache otherwise + * count as growth (QEMU's RSS also varies with the emulated CPU model). */ + sleep(10); + + /* Read baseline VmRSS */ long vmrss_start = 0; { FILE *f = fopen("/proc/self/status", "r"); @@ -784,20 +801,8 @@ static void test_long_running_stability(void) } } - const int num_clients = 5; - pthread_t ctids[5]; - stability_client_ctx_t cctxs[5]; - - for (int i = 0; i < num_clients; i++) { - cctxs[i].service = svc; - cctxs[i].total_refreshes = 0; - cctxs[i].total_errors = 0; - __atomic_store_n(&cctxs[i].running, 1, __ATOMIC_RELAXED); - pthread_create(&ctids[i], NULL, stability_client_fn, &cctxs[i]); - } - - /* Run for 60 seconds */ - sleep(60); + /* Run for the remaining 50 seconds */ + sleep(50); /* Signal all clients to stop */ for (int i = 0; i < num_clients; i++) diff --git a/tests/fixtures/c/test_sync.h b/tests/fixtures/c/test_sync.h new file mode 100644 index 00000000..64872092 --- /dev/null +++ b/tests/fixtures/c/test_sync.h @@ -0,0 +1,281 @@ +/* + * test_sync.h - deterministic synchronization for the C test fixtures. + * + * Tests wait on events, never on sleeps: a thread raises a flag or a server + * reports an event, and the waiter blocks on a condition variable until it + * happens. TEST_WAIT_LIMIT_MS only bounds waits that expire when the code + * under test is broken; reaching it fails the test. + */ + +#ifndef NETIPC_TEST_SYNC_H +#define NETIPC_TEST_SYNC_H + +#include "netipc/netipc_service.h" + +#include +#include + +#if defined(_WIN32) || defined(__MSYS__) +#include +#ifdef __MSYS__ +#include +#include +#endif +#else +#include +#include +#endif + +#define TEST_WAIT_LIMIT_MS 10000u + +/* Server waits for tests: timeout paths run in milliseconds while staying + * well above scheduling noise. */ +#define TEST_SERVER_POLL_MS 10u +#define TEST_SERVER_STOP_GRACE_MS 200u +#define TEST_SERVER_HANDSHAKE_MS 500u +#define TEST_CLIENT_SHM_ATTACH_TIMEOUT_MS 300u + +/* One process-wide lock and condition variable back every flag and event + * counter: tests are small, and a single broadcast keeps them simple. */ +#if defined(_WIN32) || defined(__MSYS__) +static SRWLOCK g_test_sync_lock = SRWLOCK_INIT; +static CONDITION_VARIABLE g_test_sync_cond = CONDITION_VARIABLE_INIT; + +static inline void test_sync_lock(void) { AcquireSRWLockExclusive(&g_test_sync_lock); } +static inline void test_sync_unlock(void) { ReleaseSRWLockExclusive(&g_test_sync_lock); } +static inline void test_sync_broadcast(void) { WakeAllConditionVariable(&g_test_sync_cond); } + +/* Wait for a broadcast until deadline_ms (GetTickCount64 clock). Returns + * false once the deadline passed. */ +static inline bool test_sync_wait_until(uint64_t deadline_ms) +{ + uint64_t now = GetTickCount64(); + if (now >= deadline_ms) + return false; + SleepConditionVariableSRW(&g_test_sync_cond, &g_test_sync_lock, + (DWORD)(deadline_ms - now), 0); + return true; +} + +static inline uint64_t test_sync_deadline(void) +{ + return GetTickCount64() + TEST_WAIT_LIMIT_MS; +} +#else +static pthread_mutex_t g_test_sync_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t g_test_sync_cond = PTHREAD_COND_INITIALIZER; + +static inline void test_sync_lock(void) { pthread_mutex_lock(&g_test_sync_lock); } +static inline void test_sync_unlock(void) { pthread_mutex_unlock(&g_test_sync_lock); } +static inline void test_sync_broadcast(void) { pthread_cond_broadcast(&g_test_sync_cond); } + +static inline uint64_t test_sync_realtime_ms(void) +{ + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + return (uint64_t)ts.tv_sec * 1000u + (uint64_t)ts.tv_nsec / 1000000u; +} + +static inline bool test_sync_wait_until(uint64_t deadline_ms) +{ + if (test_sync_realtime_ms() >= deadline_ms) + return false; + struct timespec ts = { + .tv_sec = (time_t)(deadline_ms / 1000u), + .tv_nsec = (long)(deadline_ms % 1000u) * 1000000L, + }; + pthread_cond_timedwait(&g_test_sync_cond, &g_test_sync_lock, &ts); + return true; +} + +static inline uint64_t test_sync_deadline(void) +{ + return test_sync_realtime_ms() + TEST_WAIT_LIMIT_MS; +} +#endif + +#if defined(_WIN32) || defined(__MSYS__) +#ifdef __MSYS__ +/* The MSYS runtime does not support threads started with CreateThread: under + * load such a thread may never run and the process dies silently. Start test + * threads with pthread_create and hand back an event that is signaled when + * the thread function returns, so callers keep using WaitForSingleObject and + * CloseHandle. */ +typedef struct { + LPTHREAD_START_ROUTINE fn; + void *arg; + HANDLE done; +} test_thread_start_t; + +static void *test_thread_trampoline(void *p) +{ + test_thread_start_t start = *(test_thread_start_t *)p; + free(p); + start.fn(start.arg); + SetEvent(start.done); + CloseHandle(start.done); + return NULL; +} + +static inline HANDLE test_create_thread(LPSECURITY_ATTRIBUTES attrs, SIZE_T stack, + LPTHREAD_START_ROUTINE fn, void *arg, + DWORD flags, LPDWORD id) +{ + (void)attrs; + (void)stack; + (void)flags; + if (id) + *id = 0; + HANDLE done = CreateEventW(NULL, TRUE, FALSE, NULL); + if (!done) + return NULL; + test_thread_start_t *start = malloc(sizeof(*start)); + /* The thread signals and closes its own copy, so the caller may close + * the returned handle at any time. */ + if (!start || !DuplicateHandle(GetCurrentProcess(), done, GetCurrentProcess(), + &start->done, 0, FALSE, DUPLICATE_SAME_ACCESS)) { + free(start); + CloseHandle(done); + return NULL; + } + start->fn = fn; + start->arg = arg; + pthread_t thread; + if (pthread_create(&thread, NULL, test_thread_trampoline, start) != 0) { + CloseHandle(start->done); + free(start); + CloseHandle(done); + return NULL; + } + pthread_detach(thread); + return done; +} +#else +#define test_create_thread CreateThread +#endif +#endif + +/* Raise a flag that another thread waits for. */ +static inline void test_flag_set(int *flag) +{ + test_sync_lock(); + __atomic_store_n(flag, 1, __ATOMIC_RELEASE); + test_sync_broadcast(); + test_sync_unlock(); +} + +/* Wait until *a or (when given) *b is raised. Returns false on the limit. */ +static inline bool test_flag_wait_any(int *a, int *b) +{ + uint64_t deadline = test_sync_deadline(); + bool ok = true; + test_sync_lock(); + while (!__atomic_load_n(a, __ATOMIC_ACQUIRE) && + !(b && __atomic_load_n(b, __ATOMIC_ACQUIRE))) { + if (!test_sync_wait_until(deadline)) { + ok = false; + break; + } + } + test_sync_unlock(); + return ok; +} + +static inline bool test_flag_wait(int *flag) +{ + return test_flag_wait_any(flag, NULL); +} + +/* Count an occurrence another thread waits for. */ +static inline void test_counter_add(int *counter) +{ + test_sync_lock(); + __atomic_add_fetch(counter, 1, __ATOMIC_ACQ_REL); + test_sync_broadcast(); + test_sync_unlock(); +} + +/* Wait until *counter reaches n. Returns false on the limit. */ +static inline bool test_counter_wait(int *counter, int n) +{ + uint64_t deadline = test_sync_deadline(); + bool ok = true; + test_sync_lock(); + while (__atomic_load_n(counter, __ATOMIC_ACQUIRE) < n) { + if (!test_sync_wait_until(deadline)) { + ok = false; + break; + } + } + test_sync_unlock(); + return ok; +} + +#ifdef NIPC_INTERNAL_TESTING +/* Counts managed-server events; waits consume them like a channel. */ +typedef struct { + int handshakes_started; + int handshakes_consumed; + int sessions_exited; + int exits_consumed; + int shm_created; +} test_server_events_t; + +static inline void test_server_event_hook(void *user, int event) +{ + test_server_events_t *ev = (test_server_events_t *)user; + test_sync_lock(); + if (event == NIPC_SERVER_TEST_EVENT_HANDSHAKE_STARTED) + ev->handshakes_started++; + else if (event == NIPC_SERVER_TEST_EVENT_SESSION_EXITED) + ev->sessions_exited++; + else if (event == NIPC_SERVER_TEST_EVENT_SHM_CREATED) + ev->shm_created++; + test_sync_broadcast(); + test_sync_unlock(); +} + +static inline bool test_server_events_wait(int *count, int *consumed, int n) +{ + uint64_t deadline = test_sync_deadline(); + bool ok = true; + test_sync_lock(); + while (*count - *consumed < n) { + if (!test_sync_wait_until(deadline)) { + ok = false; + break; + } + } + if (ok) + *consumed += n; + test_sync_unlock(); + return ok; +} + +/* Wait until n session workers exited and freed their slots. */ +static inline bool test_server_wait_session_exits(test_server_events_t *ev, int n) +{ + return test_server_events_wait(&ev->sessions_exited, &ev->exits_consumed, n); +} + +/* Wait until n workers are waiting for HELLO. */ +static inline bool test_server_wait_handshakes(test_server_events_t *ev, int n) +{ + return test_server_events_wait(&ev->handshakes_started, + &ev->handshakes_consumed, n); +} + +/* Switch an initialized, not yet running server to test timings; report its + * events to ev when given. */ +static inline void test_server_use_test_timings(nipc_managed_server_t *server, + test_server_events_t *ev) +{ + nipc_server_set_timings_for_tests(server, TEST_SERVER_POLL_MS, + TEST_SERVER_STOP_GRACE_MS, + TEST_SERVER_HANDSHAKE_MS); + if (ev) + nipc_server_set_event_hook_for_tests(server, test_server_event_hook, ev); +} +#endif /* NIPC_INTERNAL_TESTING */ + +#endif /* NETIPC_TEST_SYNC_H */ diff --git a/tests/fixtures/c/test_uds.c b/tests/fixtures/c/test_uds.c index 9c99fdfb..aa282b79 100644 --- a/tests/fixtures/c/test_uds.c +++ b/tests/fixtures/c/test_uds.c @@ -7,6 +7,9 @@ #include "netipc/netipc_uds.h" #include "netipc/netipc_protocol.h" +#include "test_sync.h" +#include "uds_test_socket.h" +#include "netipc_uds_internal.h" #include #include @@ -16,8 +19,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -114,6 +119,12 @@ typedef enum { MALFORMED_ACK_INCOMPATIBLE_STATUS, MALFORMED_ACK_BAD_LAYOUT, MALFORMED_ACK_BAD_PAYLOAD, + MALFORMED_ACK_PACKET_ABOVE_PROPOSAL, + MALFORMED_ACK_SELECTED_NOT_OFFERED, + MALFORMED_ACK_SELECTED_NONE, + MALFORMED_ACK_SELECTED_TWO, + MALFORMED_ACK_TRAILING_BYTES, + MALFORMED_ACK_LONG_PAYLOAD, } malformed_ack_mode_t; typedef struct { @@ -252,11 +263,11 @@ static void *echo_server_thread(void *arg) &ctx->config, &listener); if (err != NIPC_UDS_OK) { fprintf(stderr, "server listen failed: %d\n", err); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); for (int c = 0; c < ctx->accept_count; c++) { nipc_uds_session_t session; @@ -291,7 +302,7 @@ static void *echo_server_thread(void *arg) } nipc_uds_close_listener(&listener); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -305,9 +316,9 @@ static void *malformed_ack_server_thread(void *arg) build_socket_path(path, sizeof(path), ctx->service); unlink(path); - lfd = socket(AF_UNIX, SOCK_SEQPACKET, 0); + lfd = socket(AF_UNIX, TEST_UDS_SOCK_TYPE, 0); if (lfd < 0) { - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -319,11 +330,11 @@ static void *malformed_ack_server_thread(void *arg) listen(lfd, 4) != 0) { close(lfd); unlink(path); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); cfd = accept(lfd, NULL, NULL); if (cfd >= 0) { @@ -452,6 +463,65 @@ static void *malformed_ack_server_thread(void *arg) send(cfd, pkt, sizeof(pkt), MSG_NOSIGNAL); break; } + case MALFORMED_ACK_PACKET_ABOVE_PROPOSAL: + case MALFORMED_ACK_SELECTED_NOT_OFFERED: + case MALFORMED_ACK_SELECTED_NONE: + case MALFORMED_ACK_SELECTED_TWO: + case MALFORMED_ACK_TRAILING_BYTES: + case MALFORMED_ACK_LONG_PAYLOAD: { + nipc_hello_t hello; + if (n < NIPC_HEADER_LEN || + nipc_hello_decode(buf + NIPC_HEADER_LEN, (size_t)n - NIPC_HEADER_LEN, + &hello) != NIPC_OK) + break; + nipc_hello_ack_t ack = { + .layout_version = 1, + .server_supported_profiles = NIPC_PROFILE_BASELINE, + .intersection_profiles = NIPC_PROFILE_BASELINE, + .selected_profile = NIPC_PROFILE_BASELINE, + .agreed_max_request_payload_bytes = hello.max_request_payload_bytes, + .agreed_max_request_batch_items = hello.max_request_batch_items, + .agreed_max_response_payload_bytes = hello.max_response_payload_bytes, + .agreed_max_response_batch_items = hello.max_response_batch_items, + .agreed_packet_size = hello.packet_size + 1, + .session_id = 1, + }; + if (ctx->mode == MALFORMED_ACK_TRAILING_BYTES || + ctx->mode == MALFORMED_ACK_LONG_PAYLOAD) { + /* A valid ACK, then 16 more bytes in the same packet. */ + ack.agreed_packet_size = hello.packet_size; + } else if (ctx->mode != MALFORMED_ACK_PACKET_ABOVE_PROPOSAL) { + /* The client offers baseline only. */ + ack.agreed_packet_size = hello.packet_size; + ack.selected_profile = + ctx->mode == MALFORMED_ACK_SELECTED_NOT_OFFERED ? NIPC_PROFILE_SHM_HYBRID + : ctx->mode == MALFORMED_ACK_SELECTED_NONE + ? 0 + : (NIPC_PROFILE_BASELINE | NIPC_PROFILE_SHM_HYBRID); + } + uint8_t ack_buf[48]; + uint8_t pkt[NIPC_HEADER_LEN + sizeof(ack_buf) + 16]; + size_t extra = (ctx->mode == MALFORMED_ACK_TRAILING_BYTES || + ctx->mode == MALFORMED_ACK_LONG_PAYLOAD) ? 16 : 0; + nipc_header_t hdr = { + .magic = NIPC_MAGIC_MSG, + .version = NIPC_VERSION, + .header_len = NIPC_HEADER_LEN, + .kind = NIPC_KIND_CONTROL, + .code = NIPC_CODE_HELLO_ACK, + .transport_status = NIPC_STATUS_OK, + .payload_len = sizeof(ack_buf) + + (ctx->mode == MALFORMED_ACK_LONG_PAYLOAD ? 16 : 0), + .item_count = 1, + }; + memset(pkt, 0, sizeof(pkt)); + nipc_hello_ack_encode(&ack, ack_buf, sizeof(ack_buf)); + nipc_header_encode(&hdr, pkt, sizeof(pkt)); + memcpy(pkt + NIPC_HEADER_LEN, ack_buf, sizeof(ack_buf)); + send(cfd, pkt, NIPC_HEADER_LEN + sizeof(ack_buf) + extra, + MSG_NOSIGNAL); + break; + } } } close(cfd); @@ -459,7 +529,7 @@ static void *malformed_ack_server_thread(void *arg) close(lfd); unlink(path); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -473,10 +543,10 @@ static void *accept_result_server_thread(void *arg) &ctx->config, &listener); if (err != NIPC_UDS_OK) { ctx->accept_err = err; - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); nipc_uds_session_t session; ctx->accept_err = nipc_uds_accept(&listener, 1, &session); @@ -484,7 +554,7 @@ static void *accept_result_server_thread(void *arg) nipc_uds_close_session(&session); nipc_uds_close_listener(&listener); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -495,10 +565,10 @@ static void *raw_response_server_thread(void *arg) nipc_uds_error_t err = nipc_uds_listen(TEST_RUN_DIR, ctx->service, &ctx->config, &listener); if (err != NIPC_UDS_OK) { - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); nipc_uds_session_t session; err = nipc_uds_accept(&listener, 1, &session); @@ -606,7 +676,7 @@ static void *raw_response_server_thread(void *arg) } nipc_uds_close_listener(&listener); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -619,11 +689,7 @@ static pthread_t start_echo_server(server_ctx_t *ctx) pthread_create(&tid, NULL, echo_server_thread, ctx); /* Wait for the server to be ready */ - int retries = 0; - while (!__atomic_load_n(&ctx->ready, __ATOMIC_ACQUIRE) && !__atomic_load_n(&ctx->done, __ATOMIC_ACQUIRE) && retries < 1000) { - usleep(1000); - retries++; - } + (void)test_flag_wait_any(&ctx->ready, &ctx->done); return tid; } @@ -690,6 +756,113 @@ static void test_ping_pong(void) cleanup_socket(svc); } +#ifdef NIPC_TEST_EINTR_WRAP +#include "uds_eintr_wrap.h" + +/* A signal that interrupts a socket call before data moved must not fail + * the handshake or the session: every send, sendmsg and recv below fails + * once with EINTR first. */ +static void test_eintr_retried(void) +{ + printf("Test: EINTR on accept, send, sendmsg and recv is retried\n"); + const char *svc = "test_eintr"; + cleanup_socket(svc); + + /* The server's first accept is interrupted before a client arrives. */ + uds_eintr_wrap_set_accept(1); + + server_ctx_t sctx = { + .service = svc, + .config = default_server_config(), + .accept_count = 1, + .echo_count = 1, + }; + pthread_t tid = start_echo_server(&sctx); + check("server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); + + /* HELLO and HELLO_ACK go through send and recv, the request and the + * response through sendmsg and recv; on a stream every packet goes + * through sendmsg. */ +#if TEST_UDS_STREAM + uds_eintr_wrap_set(0, 4, 4); +#else + uds_eintr_wrap_set(2, 2, 4); +#endif + + nipc_uds_client_config_t ccfg = default_client_config(); + nipc_uds_session_t session; + nipc_uds_error_t err = nipc_uds_connect(TEST_RUN_DIR, svc, &ccfg, &session); + check("connect survives EINTR", err == NIPC_UDS_OK); + + if (err == NIPC_UDS_OK) { + uint8_t payload[] = {0x05, 0x06}; + nipc_header_t hdr = { + .kind = NIPC_KIND_REQUEST, + .code = NIPC_METHOD_INCREMENT, + .item_count = 1, + .message_id = 7, + }; + err = nipc_uds_send(&session, &hdr, payload, sizeof(payload)); + check("send survives EINTR", err == NIPC_UDS_OK); + + uint8_t rbuf[4096]; + nipc_header_t rhdr; + const void *rpayload; + size_t rpayload_len; + err = nipc_uds_receive(&session, rbuf, sizeof(rbuf), + &rhdr, &rpayload, &rpayload_len); + check("receive survives EINTR", err == NIPC_UDS_OK); + check("response matches", + err == NIPC_UDS_OK && rhdr.message_id == 7 && + rpayload_len == sizeof(payload) && + memcmp(rpayload, payload, sizeof(payload)) == 0); + nipc_uds_close_session(&session); + } + + pthread_join(tid, NULL); + check("every injected EINTR was hit", uds_eintr_wrap_pending() == 0); + check("the injected accept EINTR was hit", + uds_eintr_wrap_accept_pending() == 0); + uds_eintr_wrap_set(0, 0, 0); + uds_eintr_wrap_set_accept(0); + cleanup_socket(svc); +} + +/* The umask is process-global: a file another thread creates while the + * listener binds must keep the process umask, and the socket must still be + * owner-only. */ +static void test_listen_keeps_process_umask(void) +{ + printf("Test: Listen leaves the process umask alone\n"); + const char *svc = "test_listen_umask"; + cleanup_socket(svc); + + char probe[256]; + snprintf(probe, sizeof(probe), "%s/%s.probe", TEST_RUN_DIR, svc); + unlink(probe); + + mode_t old_mask = umask(022); + uds_bind_probe_arm(probe); + nipc_uds_server_config_t scfg = default_server_config(); + nipc_uds_listener_t listener; + nipc_uds_error_t err = nipc_uds_listen(TEST_RUN_DIR, svc, &scfg, &listener); + umask(old_mask); + check("listen succeeds", err == NIPC_UDS_OK); + check("file created during bind keeps the process umask (0644)", + uds_bind_probe_mode() == 0644); + + if (err == NIPC_UDS_OK) { + char path[256]; + snprintf(path, sizeof(path), "%s/%s.sock", TEST_RUN_DIR, svc); + struct stat st; + check("socket mode is owner-only", + stat(path, &st) == 0 && (st.st_mode & 0777) == 0600); + nipc_uds_close_listener(&listener); + } + cleanup_socket(svc); +} +#endif + /* ------------------------------------------------------------------ */ /* Test 2: Multi-client concurrent sessions */ /* ------------------------------------------------------------------ */ @@ -847,16 +1020,16 @@ static void *chunked_server_thread(void *arg) nipc_uds_error_t err = nipc_uds_listen(TEST_RUN_DIR, ctx->service, &ctx->config, &listener); if (err != NIPC_UDS_OK) { - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); nipc_uds_session_t session; err = nipc_uds_accept(&listener, 0, &session); if (err != NIPC_UDS_OK) { nipc_uds_close_listener(&listener); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -877,7 +1050,7 @@ static void *chunked_server_thread(void *arg) nipc_uds_close_session(&session); nipc_uds_close_listener(&listener); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -905,11 +1078,7 @@ static void test_chunking(void) __atomic_store_n(&sctx.done, 0, __ATOMIC_RELAXED); pthread_create(&tid, NULL, chunked_server_thread, &sctx); - int retries = 0; - while (!__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE) && retries < 1000) { - usleep(1000); - retries++; - } + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("chunked server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); nipc_uds_client_config_t ccfg = default_client_config(); @@ -1036,6 +1205,124 @@ static void test_profile_mismatch(void) cleanup_socket(svc); } +/* Both peers configured for SHM: only Linux may select it; elsewhere the + * platform mask leaves baseline on both sides. */ +static void test_platform_profile_mask(void) +{ + printf("Test 6b: Platform profile mask\n"); + const char *svc = "test_platprofile"; + cleanup_socket(svc); +#if defined(__linux__) || defined(__FreeBSD__) + const uint32_t want = NIPC_PROFILE_SHM_HYBRID; +#else + const uint32_t want = NIPC_PROFILE_BASELINE; +#endif + + nipc_uds_server_config_t scfg = default_server_config(); + scfg.supported_profiles = NIPC_PROFILE_BASELINE | NIPC_PROFILE_SHM_HYBRID; + scfg.preferred_profiles = NIPC_PROFILE_SHM_HYBRID; + + server_ctx_t sctx = { + .service = svc, + .config = scfg, + .accept_count = 1, + .echo_count = 0, + }; + + pthread_t tid = start_echo_server(&sctx); + check("server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); + + nipc_uds_client_config_t ccfg = default_client_config(); + ccfg.supported_profiles = NIPC_PROFILE_BASELINE | NIPC_PROFILE_SHM_HYBRID; + ccfg.preferred_profiles = NIPC_PROFILE_SHM_HYBRID; + + nipc_uds_session_t session; + nipc_uds_error_t err = nipc_uds_connect(TEST_RUN_DIR, svc, &ccfg, &session); + check("client connect", err == NIPC_UDS_OK); + if (err == NIPC_UDS_OK) { + check("selected profile matches the platform", session.selected_profile == want); + nipc_uds_close_session(&session); + } + + pthread_join(tid, NULL); + cleanup_socket(svc); +} + +/* An unassigned profile bit offered and preferred by both peers is never + * selected: they would otherwise run baseline under that label. */ +static void test_unassigned_profile_bit(void) +{ + printf("Test 6c: Unassigned profile bit is never selected\n"); + const char *svc = "test_unassigned_profile"; + cleanup_socket(svc); + const uint32_t unassigned = 0x10u; + + nipc_uds_server_config_t scfg = default_server_config(); + scfg.supported_profiles = NIPC_PROFILE_BASELINE | unassigned; + scfg.preferred_profiles = unassigned; + + server_ctx_t sctx = { + .service = svc, + .config = scfg, + .accept_count = 1, + .echo_count = 0, + }; + + pthread_t tid = start_echo_server(&sctx); + check("server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); + + nipc_uds_client_config_t ccfg = default_client_config(); + ccfg.supported_profiles = NIPC_PROFILE_BASELINE | unassigned; + ccfg.preferred_profiles = unassigned; + + nipc_uds_session_t session; + nipc_uds_error_t err = nipc_uds_connect(TEST_RUN_DIR, svc, &ccfg, &session); + check("client connect", err == NIPC_UDS_OK); + if (err == NIPC_UDS_OK) { + check("baseline selected instead of the unassigned bit", + session.selected_profile == NIPC_PROFILE_BASELINE); + nipc_uds_close_session(&session); + } + + pthread_join(tid, NULL); + cleanup_socket(svc); +} + +/* A deadline-bounded send of an untracked message with nothing in flight + * sizes its pending queue for one response instead of dividing by zero. */ +static void test_bounded_control_send_idle(void) +{ + printf("Test 6d: Bounded control send with nothing in flight\n"); + const char *svc = "test_bounded_control_idle"; + cleanup_socket(svc); + + server_ctx_t sctx = { + .service = svc, + .config = default_server_config(), + .accept_count = 1, + .echo_count = 1, + }; + pthread_t tid = start_echo_server(&sctx); + check("server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); + + nipc_uds_client_config_t ccfg = default_client_config(); + nipc_uds_session_t session; + nipc_uds_error_t err = nipc_uds_connect(TEST_RUN_DIR, svc, &ccfg, &session); + check("client connect", err == NIPC_UDS_OK); + if (err == NIPC_UDS_OK) { + nipc_header_t hdr = { + .kind = NIPC_KIND_CONTROL, + .message_id = 1, + }; + err = nipc_uds_send_timeout(&session, &hdr, NULL, 0, 5000, -1); + check("bounded control send succeeds", err == NIPC_UDS_OK); + nipc_uds_close_session(&session); + } + + pthread_join(tid, NULL); + cleanup_socket(svc); +} + /* ------------------------------------------------------------------ */ /* Test 7: Handshake failure - request payload proposal over cap */ /* ------------------------------------------------------------------ */ @@ -1084,7 +1371,7 @@ static void test_stale_recovery(void) char path[256]; snprintf(path, sizeof(path), "%s/%s.sock", TEST_RUN_DIR, svc); - int sock = socket(AF_UNIX, SOCK_SEQPACKET, 0); + int sock = socket(AF_UNIX, TEST_UDS_SOCK_TYPE, 0); struct sockaddr_un addr; memset(&addr, 0, sizeof(addr)); addr.sun_family = AF_UNIX; @@ -1143,6 +1430,35 @@ static void test_stale_recovery_reclaims_regular_file(void) cleanup_socket(svc); } +/* Unlinking a directory fails with EISDIR on Linux and EPERM on FreeBSD and + * macOS; every platform must reclaim an empty one. */ +static void test_stale_recovery_reclaims_empty_directory(void) +{ + printf("Test: Stale socket recovery reclaims an empty directory\n"); + const char *svc = "test_stale_dir"; + cleanup_socket(svc); + + char path[256]; + snprintf(path, sizeof(path), "%s/%s.sock", TEST_RUN_DIR, svc); + rmdir(path); + check("directory created at socket path", mkdir(path, 0700) == 0); + + nipc_uds_server_config_t scfg = default_server_config(); + nipc_uds_listener_t listener; + nipc_uds_error_t err = nipc_uds_listen(TEST_RUN_DIR, svc, &scfg, &listener); + check("listen reclaims a directory path", err == NIPC_UDS_OK); + + struct stat st; + check("socket replaces the directory", + stat(path, &st) == 0 && S_ISSOCK(st.st_mode)); + + if (err == NIPC_UDS_OK) + nipc_uds_close_listener(&listener); + + rmdir(path); + cleanup_socket(svc); +} + static void test_listen_socket_owner_only(void) { printf("Test: Listen creates owner-only socket\n"); @@ -1181,10 +1497,10 @@ static void *disconnect_server_thread(void *arg) nipc_uds_error_t err = nipc_uds_listen(TEST_RUN_DIR, ctx->service, &ctx->config, &listener); if (err != NIPC_UDS_OK) { - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); nipc_uds_session_t session; err = nipc_uds_accept(&listener, 0, &session); @@ -1199,7 +1515,7 @@ static void *disconnect_server_thread(void *arg) } nipc_uds_close_listener(&listener); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -1221,11 +1537,7 @@ static void test_disconnect_inflight(void) __atomic_store_n(&sctx.done, 0, __ATOMIC_RELAXED); pthread_create(&tid, NULL, disconnect_server_thread, &sctx); - int retries = 0; - while (!__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE) && retries < 1000) { - usleep(1000); - retries++; - } + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); nipc_uds_client_config_t ccfg = default_client_config(); @@ -1462,7 +1774,7 @@ static void test_send_recv_validation(void) == NIPC_UDS_ERR_BAD_PARAM); int sv[2] = {-1, -1}; - check("socketpair for zero chunk budget", socketpair(AF_UNIX, SOCK_SEQPACKET, 0, sv) == 0); + check("socketpair for zero chunk budget", socketpair(AF_UNIX, TEST_UDS_SOCK_TYPE, 0, sv) == 0); if (sv[0] >= 0 && sv[1] >= 0) { nipc_uds_session_t tiny_packet_session; memset(&tiny_packet_session, 0, sizeof(tiny_packet_session)); @@ -1496,7 +1808,7 @@ static void test_send_recv_validation(void) } int sv_big[2] = {-1, -1}; - check("socketpair for oversized framed length", socketpair(AF_UNIX, SOCK_SEQPACKET, 0, sv_big) == 0); + check("socketpair for oversized framed length", socketpair(AF_UNIX, TEST_UDS_SOCK_TYPE, 0, sv_big) == 0); if (sv_big[0] >= 0 && sv_big[1] >= 0) { nipc_uds_session_t large_session; memset(&large_session, 0, sizeof(large_session)); @@ -1851,16 +2163,16 @@ static void *chunked_echo_server_thread(void *arg) nipc_uds_error_t err = nipc_uds_listen(TEST_RUN_DIR, ctx->service, &ctx->config, &listener); if (err != NIPC_UDS_OK) { - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); nipc_uds_session_t session; err = nipc_uds_accept(&listener, 0, &session); if (err != NIPC_UDS_OK) { nipc_uds_close_listener(&listener); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -1886,7 +2198,7 @@ static void *chunked_echo_server_thread(void *arg) nipc_uds_close_session(&session); nipc_uds_close_listener(&listener); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -1917,12 +2229,7 @@ static void test_pipeline_chunked(void) __atomic_store_n(&sctx.done, 0, __ATOMIC_RELAXED); pthread_create(&tid, NULL, chunked_echo_server_thread, &sctx); - int retries = 0; - while (!__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) && - !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE) && retries < 1000) { - usleep(1000); - retries++; - } + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("chunked pipeline server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); @@ -1986,66 +2293,499 @@ static void test_pipeline_chunked(void) } /* ------------------------------------------------------------------ */ -/* Coverage: handshake with wrong message kind from mock client */ +/* Test: Pipelining beyond the socket buffers */ /* ------------------------------------------------------------------ */ -static void *wrong_kind_server(void *arg) +/* Small buffers make the deadlock reachable on every platform (macOS and + * FreeBSD 14 default to 8 KiB, FreeBSD 15 to 64 KiB, Linux to about + * 208 KiB). The client's send timeout + * only bounds a failing run: a deadlocked send fails instead of hanging. A + * passing run never waits on it, and the server has none, so a slow server + * cannot fail a passing run. */ +static void shrink_socket_buffers(int fd, bool bound_sends) { - server_ctx_t *ctx = (server_ctx_t *)arg; - nipc_uds_server_config_t scfg = ctx->config; + int size = 4096; + (void)setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)); + (void)setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size)); + if (bound_sends) { + struct timeval tv = {.tv_sec = 10, .tv_usec = 0}; + (void)setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + } +} + +#define PIPE_BUF_COUNT 16 +#define PIPE_BUF_SIZE 16000 +#define PIPE_BUF_PKT 1024 +/* Flood mode: small responses keep the client's queue bound small. */ +#define PIPE_BUF_FLOOD_RESP 4096 + +typedef enum { + SMALL_BUF_ECHO = 1, /* receive one request, send its response */ + SMALL_BUF_CLOSE_AFTER_ONE, /* receive one request, then close */ + SMALL_BUF_FLOOD, /* receive one request, then send responses + without reading until the send fails */ +} small_buf_mode_t; + +typedef struct { + server_ctx_t base; + small_buf_mode_t mode; +} small_buf_ctx_t; + +static void *small_buffer_server_thread(void *arg) +{ + small_buf_ctx_t *sb = (small_buf_ctx_t *)arg; + server_ctx_t *ctx = &sb->base; nipc_uds_listener_t listener; nipc_uds_error_t err = nipc_uds_listen(TEST_RUN_DIR, ctx->service, - &scfg, &listener); + &ctx->config, &listener); if (err != NIPC_UDS_OK) { - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); nipc_uds_session_t session; - err = nipc_uds_accept(&listener, 1, &session); - /* We expect this to fail because the client sends wrong kind */ - if (err == NIPC_UDS_OK) - nipc_uds_close_session(&session); + err = nipc_uds_accept(&listener, 0, &session); + if (err != NIPC_UDS_OK) { + nipc_uds_close_listener(&listener); + test_flag_set(&ctx->done); + return NULL; + } + shrink_socket_buffers(session.fd, false); + + int count = sb->mode == SMALL_BUF_ECHO ? ctx->echo_count : 1; + for (int m = 0; m < count; m++) { + uint8_t buf[PIPE_BUF_PKT]; + nipc_header_t hdr; + const void *payload; + size_t payload_len; + + err = nipc_uds_receive(&session, buf, sizeof(buf), + &hdr, &payload, &payload_len); + if (err != NIPC_UDS_OK) + break; + + if (sb->mode == SMALL_BUF_ECHO) { + nipc_header_t resp = hdr; + resp.kind = NIPC_KIND_RESPONSE; + resp.transport_status = NIPC_STATUS_OK; + err = nipc_uds_send(&session, &resp, payload, payload_len); + if (err != NIPC_UDS_OK) + break; + } + } + + if (sb->mode == SMALL_BUF_FLOOD) { + static uint8_t resp_payload[PIPE_BUF_FLOOD_RESP]; + for (;;) { + nipc_header_t resp = { + .kind = NIPC_KIND_RESPONSE, .code = 1, + .item_count = 1, .message_id = 1, + .transport_status = NIPC_STATUS_OK, + }; + if (nipc_uds_send(&session, &resp, resp_payload, + sizeof(resp_payload)) != NIPC_UDS_OK) + break; + } + } + nipc_uds_close_session(&session); nipc_uds_close_listener(&listener); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } -static void test_handshake_wrong_kind(void) +/* Starts the server in the given mode and connects a client; both ends get + * small socket buffers. Returns 0 on success. */ +static int small_buffer_start(const char *svc, small_buf_mode_t mode, + uint32_t max_response, small_buf_ctx_t *sb, + pthread_t *tid, nipc_uds_session_t *session) { - printf("Test: Handshake with wrong message kind\n"); - const char *svc = "test_wrong_kind"; cleanup_socket(svc); - server_ctx_t sctx = { - .service = svc, - .config = default_server_config(), - }; - __atomic_store_n(&sctx.ready, 0, __ATOMIC_RELAXED); - __atomic_store_n(&sctx.done, 0, __ATOMIC_RELAXED); + nipc_uds_server_config_t scfg = default_server_config(); + scfg.packet_size = PIPE_BUF_PKT; + scfg.max_request_payload_bytes = 65536; + scfg.max_response_payload_bytes = max_response; - pthread_t tid; - pthread_create(&tid, NULL, wrong_kind_server, &sctx); + memset(sb, 0, sizeof(*sb)); + sb->base.service = svc; + sb->base.config = scfg; + sb->base.accept_count = 1; + sb->base.echo_count = PIPE_BUF_COUNT; + sb->mode = mode; - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); - check("server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); + pthread_create(tid, NULL, small_buffer_server_thread, sb); + (void)test_flag_wait_any(&sb->base.ready, &sb->base.done); + check("small-buffer server ready", + __atomic_load_n(&sb->base.ready, __ATOMIC_ACQUIRE) == 1); - /* Connect raw socket and send a RESPONSE instead of HELLO */ - char path[256]; - snprintf(path, sizeof(path), "%s/%s.sock", TEST_RUN_DIR, svc); - int fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); - struct sockaddr_un addr; - memset(&addr, 0, sizeof(addr)); - addr.sun_family = AF_UNIX; - strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); + nipc_uds_client_config_t ccfg = default_client_config(); + ccfg.packet_size = PIPE_BUF_PKT; + ccfg.max_request_payload_bytes = 65536; + ccfg.max_response_payload_bytes = max_response; - int rc = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); + nipc_uds_error_t err = nipc_uds_connect(TEST_RUN_DIR, svc, &ccfg, session); + check("connect", err == NIPC_UDS_OK); + if (err != NIPC_UDS_OK) + return -1; + shrink_socket_buffers(session->fd, true); + return 0; +} + +static nipc_uds_error_t send_pipelined_request(nipc_uds_session_t *session, + uint8_t *payload, int i) +{ + for (size_t j = 0; j < PIPE_BUF_SIZE; j++) + payload[j] = (uint8_t)((i * 7 + j) & 0xFF); + nipc_header_t hdr = { + .kind = NIPC_KIND_REQUEST, .code = 1, + .item_count = 1, .message_id = (uint64_t)(i + 1), + }; + return nipc_uds_send(session, &hdr, payload, PIPE_BUF_SIZE); +} + +/* A client that sends every request before reading any response must not + * deadlock with the server when the bytes in flight exceed the buffers. */ +static void test_pipeline_exceeds_socket_buffers(void) +{ + printf("Test: Pipelining beyond the socket buffers completes\n"); + const char *svc = "test_pipebuf"; + small_buf_ctx_t sb; + pthread_t tid; + nipc_uds_session_t session; + + if (small_buffer_start(svc, SMALL_BUF_ECHO, 65536, &sb, &tid, + &session) == 0) { + check("no pending data before sending", + !nipc_uds_session_has_pending(&session)); + uint8_t *payload = malloc(PIPE_BUF_SIZE); + int sent_ok = 1; + for (int i = 0; i < PIPE_BUF_COUNT && sent_ok; i++) { + if (send_pipelined_request(&session, payload, i) != NIPC_UDS_OK) + sent_ok = 0; + } + free(payload); + check("all pipelined requests sent", sent_ok); + /* The buffers hold a fraction of the responses, so finishing the + * sends required reading responses into the queue. */ + check("responses queued during the sends", + nipc_uds_session_has_pending(&session)); + + int all_ok = sent_ok; + for (int i = 0; i < PIPE_BUF_COUNT && all_ok; i++) { + uint8_t rbuf[PIPE_BUF_PKT]; + nipc_header_t rhdr; + const void *rp; + size_t rlen; + if (nipc_uds_receive(&session, rbuf, sizeof(rbuf), + &rhdr, &rp, &rlen) != NIPC_UDS_OK) { + all_ok = 0; + break; + } + if (rhdr.message_id != (uint64_t)(i + 1) || rlen != PIPE_BUF_SIZE) { + all_ok = 0; + break; + } + const uint8_t *rpp = (const uint8_t *)rp; + for (size_t j = 0; j < PIPE_BUF_SIZE; j++) { + if (rpp[j] != (uint8_t)((i * 7 + j) & 0xFF)) { + all_ok = 0; + break; + } + } + } + check("all pipelined responses correct", all_ok); + check("pending queue drained", session.pending_tail == 0); + check("no pending data after receiving", + !nipc_uds_session_has_pending(&session)); + + nipc_uds_close_session(&session); + } + + pthread_join(tid, NULL); + cleanup_socket(svc); +} + +/* A receive into a buffer too small for a header fails without reading a + * byte, so the message stays queued and the session stays usable. */ +static void test_receive_undersized_buffer_keeps_session(void) +{ + printf("Test: Undersized receive buffer keeps the session\n"); + int sv[2] = {-1, -1}; + check("socketpair", + socketpair(AF_UNIX, TEST_UDS_SOCK_TYPE, 0, sv) == 0); + if (sv[0] < 0 || sv[1] < 0) + return; + + nipc_uds_session_t rx, tx; + memset(&rx, 0, sizeof(rx)); + rx.fd = sv[0]; + rx.role = NIPC_UDS_ROLE_SERVER; + rx.packet_size = 4096; + rx.max_request_payload_bytes = 1024; + rx.max_request_batch_items = 1; + memset(&tx, 0, sizeof(tx)); + tx.fd = sv[1]; + tx.role = NIPC_UDS_ROLE_SERVER; + tx.packet_size = 4096; + + nipc_header_t hdr = { + .kind = NIPC_KIND_REQUEST, .code = 1, .item_count = 1, + .message_id = 7, + }; + uint8_t payload[8] = {1, 2, 3, 4, 5, 6, 7, 8}; + check("request sent", + nipc_uds_send(&tx, &hdr, payload, sizeof(payload)) == NIPC_UDS_OK); + + uint8_t small[NIPC_HEADER_LEN - 1]; + uint8_t rbuf[256]; + nipc_header_t rhdr; + const void *rp = NULL; + size_t rlen = 0; + check("undersized buffer rejected", + nipc_uds_receive(&rx, small, sizeof(small), &rhdr, &rp, &rlen) == + NIPC_UDS_ERR_BAD_PARAM); + check("request still received", + nipc_uds_receive_timeout(&rx, rbuf, sizeof(rbuf), &rhdr, &rp, &rlen, + 1000, -1) == NIPC_UDS_OK && + rhdr.message_id == 7 && rlen == sizeof(payload) && + memcmp(rp, payload, sizeof(payload)) == 0); + + free(rx.recv_buf); + nipc_uds_pending_free(&rx); + close(sv[0]); + close(sv[1]); +} + +#if !TEST_UDS_STREAM +/* A datagram longer than its header declares is a protocol error. The + * pending queue, filled while a send waits for buffer space, must keep the + * whole datagram so its receive rejects it as the direct receive does, + * instead of storing it cut to packet_size and accepting it. */ +static void test_pending_oversized_datagram_rejected(void) +{ + printf("Test: Oversized datagram rejected from the pending queue\n"); + int sv[2] = {-1, -1}; + check("socketpair", socketpair(AF_UNIX, SOCK_SEQPACKET, 0, sv) == 0); + if (sv[0] < 0 || sv[1] < 0) + return; + + nipc_uds_session_t s; + memset(&s, 0, sizeof(s)); + s.fd = sv[0]; + s.role = NIPC_UDS_ROLE_SERVER; + s.packet_size = 2 * NIPC_HEADER_LEN; + s.max_request_payload_bytes = 1024; + s.max_request_batch_items = 1; + + /* The header declares exactly packet_size; 16 more bytes follow. */ + uint8_t dgram[2 * NIPC_HEADER_LEN + 16]; + memset(dgram, 0xAB, sizeof(dgram)); + nipc_header_t hdr = { + .magic = NIPC_MAGIC_MSG, .version = NIPC_VERSION, + .header_len = NIPC_HEADER_LEN, .kind = NIPC_KIND_REQUEST, + .code = 1, .item_count = 1, .message_id = 1, + .payload_len = NIPC_HEADER_LEN, + }; + nipc_header_encode(&hdr, dgram, NIPC_HEADER_LEN); + + uint8_t rbuf[256]; + nipc_header_t rhdr; + const void *rp; + size_t rlen; + + check("direct datagram sent", + send(sv[1], dgram, sizeof(dgram), 0) == (ssize_t)sizeof(dgram)); + check("direct receive rejects it", + nipc_uds_receive(&s, rbuf, sizeof(rbuf), &rhdr, &rp, &rlen) == + NIPC_UDS_ERR_PROTOCOL); + + check("queued datagram sent", + send(sv[1], dgram, sizeof(dgram), 0) == (ssize_t)sizeof(dgram)); + bool eof = false; + check("drain queues it", + nipc_uds_pending_drain(&s, 4096, &eof) == NIPC_UDS_OK && !eof && + nipc_uds_pending_queued(&s)); + check("pending receive rejects it", + nipc_uds_receive(&s, rbuf, sizeof(rbuf), &rhdr, &rp, &rlen) == + NIPC_UDS_ERR_PROTOCOL); + check("pending queue consumed", !nipc_uds_pending_queued(&s)); + + nipc_uds_pending_free(&s); + close(sv[0]); + close(sv[1]); +} +#endif + +#if TEST_UDS_STREAM +/* Stream baseline: a receive that fails mid-stream marks the receive side failed, and + * every later receive must fail. Bytes left in the pending queue are part + * of that abandoned stream: here a forged response for an in-flight id + * follows an over-limit header, and must not be delivered. */ +static void test_stream_failed_receive_drops_pending(void) +{ + printf("Test: Failed stream receive drops pending bytes\n"); + const char *svc = "test_pipestale"; + small_buf_ctx_t sb; + pthread_t tid; + nipc_uds_session_t session; + + if (small_buffer_start(svc, SMALL_BUF_CLOSE_AFTER_ONE, 65536, &sb, &tid, + &session) == 0) { + uint8_t *payload = malloc(PIPE_BUF_SIZE); + check("request 1 sent", + send_pipelined_request(&session, payload, 0) == NIPC_UDS_OK); + free(payload); + + nipc_header_t over = { + .magic = NIPC_MAGIC_MSG, .version = NIPC_VERSION, + .header_len = NIPC_HEADER_LEN, .kind = NIPC_KIND_RESPONSE, + .item_count = 1, .message_id = 1, .payload_len = 65537, + }; + nipc_header_t forged = over; + forged.payload_len = 0; + session.pending_buf = malloc(2 * NIPC_HEADER_LEN); + nipc_header_encode(&over, session.pending_buf, NIPC_HEADER_LEN); + nipc_header_encode(&forged, session.pending_buf + NIPC_HEADER_LEN, + NIPC_HEADER_LEN); + session.pending_cap = 2 * NIPC_HEADER_LEN; + session.pending_head = 0; + session.pending_tail = 2 * NIPC_HEADER_LEN; + + uint8_t rbuf[PIPE_BUF_PKT]; + nipc_header_t rhdr; + const void *rp; + size_t rlen; + check("over-limit response rejected", + nipc_uds_receive(&session, rbuf, sizeof(rbuf), &rhdr, &rp, + &rlen) == NIPC_UDS_ERR_LIMIT_EXCEEDED); + check("later receive fails", + nipc_uds_receive(&session, rbuf, sizeof(rbuf), &rhdr, &rp, + &rlen) != NIPC_UDS_OK); + + nipc_uds_close_session(&session); + } + + pthread_join(tid, NULL); + cleanup_socket(svc); +} +#endif + +/* The server closes while the pipelining client waits for buffer space: + * the send fails instead of waiting forever. */ +static void test_pipeline_peer_close_during_send(void) +{ + printf("Test: Peer close during a pipelined send fails the send\n"); + const char *svc = "test_pipeclose"; + small_buf_ctx_t sb; + pthread_t tid; + nipc_uds_session_t session; + + if (small_buffer_start(svc, SMALL_BUF_CLOSE_AFTER_ONE, 65536, &sb, &tid, + &session) == 0) { + uint8_t *payload = malloc(PIPE_BUF_SIZE); + nipc_uds_error_t err = NIPC_UDS_OK; + for (int i = 0; i < PIPE_BUF_COUNT && err == NIPC_UDS_OK; i++) + err = send_pipelined_request(&session, payload, i); + free(payload); + check("pipelined send fails with SEND", err == NIPC_UDS_ERR_SEND); + check("in-flight set cleared", session.inflight_count == 0); + + nipc_uds_close_session(&session); + } + + pthread_join(tid, NULL); + cleanup_socket(svc); +} + +/* A server that sends more than the in-flight responses can occupy fails + * the client session instead of growing its queue without limit. */ +static void test_pipeline_pending_bound(void) +{ + printf("Test: Inbound bytes beyond the in-flight responses fail the session\n"); + const char *svc = "test_pipebound"; + small_buf_ctx_t sb; + pthread_t tid; + nipc_uds_session_t session; + + if (small_buffer_start(svc, SMALL_BUF_FLOOD, PIPE_BUF_FLOOD_RESP, &sb, + &tid, &session) == 0) { + uint8_t *payload = malloc(PIPE_BUF_SIZE); + nipc_uds_error_t err = NIPC_UDS_OK; + for (int i = 0; i < PIPE_BUF_COUNT && err == NIPC_UDS_OK; i++) + err = send_pipelined_request(&session, payload, i); + free(payload); + check("pipelined send fails with PROTOCOL", + err == NIPC_UDS_ERR_PROTOCOL); + check("in-flight set cleared", session.inflight_count == 0); + + nipc_uds_close_session(&session); + } + + pthread_join(tid, NULL); + cleanup_socket(svc); +} + +/* ------------------------------------------------------------------ */ +/* Coverage: handshake with wrong message kind from mock client */ +/* ------------------------------------------------------------------ */ + +static void *wrong_kind_server(void *arg) +{ + server_ctx_t *ctx = (server_ctx_t *)arg; + nipc_uds_server_config_t scfg = ctx->config; + + nipc_uds_listener_t listener; + nipc_uds_error_t err = nipc_uds_listen(TEST_RUN_DIR, ctx->service, + &scfg, &listener); + if (err != NIPC_UDS_OK) { + test_flag_set(&ctx->done); + return NULL; + } + test_flag_set(&ctx->ready); + + nipc_uds_session_t session; + err = nipc_uds_accept(&listener, 1, &session); + /* We expect this to fail because the client sends wrong kind */ + if (err == NIPC_UDS_OK) + nipc_uds_close_session(&session); + + nipc_uds_close_listener(&listener); + test_flag_set(&ctx->done); + return NULL; +} + +static void test_handshake_wrong_kind(void) +{ + printf("Test: Handshake with wrong message kind\n"); + const char *svc = "test_wrong_kind"; + cleanup_socket(svc); + + server_ctx_t sctx = { + .service = svc, + .config = default_server_config(), + }; + __atomic_store_n(&sctx.ready, 0, __ATOMIC_RELAXED); + __atomic_store_n(&sctx.done, 0, __ATOMIC_RELAXED); + + pthread_t tid; + pthread_create(&tid, NULL, wrong_kind_server, &sctx); + + (void)test_flag_wait_any(&sctx.ready, &sctx.done); + check("server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); + + /* Connect raw socket and send a RESPONSE instead of HELLO */ + char path[256]; + snprintf(path, sizeof(path), "%s/%s.sock", TEST_RUN_DIR, svc); + int fd = socket(AF_UNIX, TEST_UDS_SOCK_TYPE, 0); + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); + + int rc = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); check("raw connect", rc == 0); if (rc == 0) { @@ -2091,10 +2831,7 @@ static void test_inflight_duplicate(void) pthread_t tid; pthread_create(&tid, NULL, echo_server_thread, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); /* Connect client */ @@ -2138,10 +2875,10 @@ static void *oversized_server(void *arg) nipc_uds_error_t err = nipc_uds_listen(TEST_RUN_DIR, ctx->service, &ctx->config, &listener); if (err != NIPC_UDS_OK) { - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); nipc_uds_session_t session; err = nipc_uds_accept(&listener, 1, &session); @@ -2168,7 +2905,7 @@ static void *oversized_server(void *arg) nipc_uds_close_session(&session); } nipc_uds_close_listener(&listener); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -2192,10 +2929,7 @@ static void test_directional_limit_negotiation(void) pthread_t tid; pthread_create(&tid, NULL, oversized_server, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); /* Client requests large request capacity but advertises small response capacity. */ @@ -2269,10 +3003,7 @@ static void test_receive_payload_exceeds_limit(void) pthread_t tid; pthread_create(&tid, NULL, raw_response_server_thread, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("raw-response server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); @@ -2322,10 +3053,10 @@ static void *wrong_msgid_server(void *arg) nipc_uds_error_t err = nipc_uds_listen(TEST_RUN_DIR, ctx->service, &scfg, &listener); if (err != NIPC_UDS_OK) { - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } - __atomic_store_n(&ctx->ready, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->ready); nipc_uds_session_t session; err = nipc_uds_accept(&listener, 1, &session); @@ -2350,7 +3081,7 @@ static void *wrong_msgid_server(void *arg) nipc_uds_close_session(&session); } nipc_uds_close_listener(&listener); - __atomic_store_n(&ctx->done, 1, __ATOMIC_RELEASE); + test_flag_set(&ctx->done); return NULL; } @@ -2370,10 +3101,7 @@ static void test_receive_unknown_message_id(void) pthread_t tid; pthread_create(&tid, NULL, wrong_msgid_server, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); nipc_uds_client_config_t ccfg = default_client_config(); @@ -2432,10 +3160,251 @@ static void test_stale_live_server(void) cleanup_socket(svc); } +/* A server between bind() and listen() refuses connects just like a dead + * server's leftover socket file. A second server starting in that window + * must not take the endpoint over: it fails with ADDR_IN_USE, and the + * first server stays reachable. */ +typedef struct { + const char *service; + int bound; + int release; + int listened; + nipc_uds_error_t err; + nipc_uds_listener_t listener; +} held_listen_t; + +static void hold_after_bind(void *user) +{ + held_listen_t *held = (held_listen_t *)user; + test_flag_set(&held->bound); + (void)test_flag_wait(&held->release); +} + +static void *held_listen_thread(void *arg) +{ + held_listen_t *held = (held_listen_t *)arg; + nipc_uds_server_config_t scfg = default_server_config(); + held->err = nipc_uds_listen(TEST_RUN_DIR, held->service, &scfg, + &held->listener); + test_flag_set(&held->listened); + return NULL; +} + +static void test_listen_excludes_starting_server(void) +{ + printf("Test: a server between bind and listen keeps its endpoint\n"); + const char *svc = "test_listen_window"; + cleanup_socket(svc); + + held_listen_t held = {.service = svc}; + nipc_uds_set_listen_hook_for_tests(hold_after_bind, &held); + pthread_t tid; + pthread_create(&tid, NULL, held_listen_thread, &held); + check("first server bound", test_flag_wait(&held.bound)); + nipc_uds_set_listen_hook_for_tests(NULL, NULL); + + nipc_uds_server_config_t scfg = default_server_config(); + nipc_uds_listener_t second; + nipc_uds_error_t err = nipc_uds_listen(TEST_RUN_DIR, svc, &scfg, &second); + check("second server gets ADDR_IN_USE", err == NIPC_UDS_ERR_ADDR_IN_USE); + if (err == NIPC_UDS_OK) + nipc_uds_close_listener(&second); + + test_flag_set(&held.release); + check("first server listens", test_flag_wait(&held.listened)); + pthread_join(tid, NULL); + check("first listen ok", held.err == NIPC_UDS_OK); + + if (held.err == NIPC_UDS_OK) { + /* A connect to the endpoint must reach the first server. */ + struct sockaddr_un addr = {.sun_family = AF_UNIX}; + snprintf(addr.sun_path, sizeof(addr.sun_path), "%s/%s.sock", + TEST_RUN_DIR, svc); + int cfd = socket(AF_UNIX, TEST_UDS_SOCK_TYPE, 0); + check("connect to endpoint", + cfd >= 0 && connect(cfd, (struct sockaddr *)&addr, sizeof(addr)) == 0); + struct pollfd pfd = {.fd = held.listener.fd, .events = POLLIN}; + check("first server receives the connection", + poll(&pfd, 1, (int)TEST_WAIT_LIMIT_MS) == 1); + if (cfd >= 0) + close(cfd); + nipc_uds_close_listener(&held.listener); + } + + /* After the owner closed, the endpoint can be taken again. */ + err = nipc_uds_listen(TEST_RUN_DIR, svc, &scfg, &second); + check("endpoint free after close", err == NIPC_UDS_OK); + if (err == NIPC_UDS_OK) + nipc_uds_close_listener(&second); + cleanup_socket(svc); +} + +static mode_t lock_file_mode(const char *svc) +{ + char path[256]; + snprintf(path, sizeof(path), "%s/%s.lock", TEST_RUN_DIR, svc); + struct stat st; + return stat(path, &st) == 0 ? (st.st_mode & 0777) : 0; +} + +typedef struct { + const char *lock_path; + char tmp_path[320]; + int calls; + mode_t tmp_mode; + int lock_visible; +} lock_publish_probe_t; + +static void probe_lock_publish(const char *tmp_path, void *user) +{ + lock_publish_probe_t *probe = user; + struct stat st; + probe->calls++; + snprintf(probe->tmp_path, sizeof(probe->tmp_path), "%s", tmp_path); + probe->tmp_mode = stat(tmp_path, &st) == 0 ? (st.st_mode & 0777) : 0; + probe->lock_visible = lstat(probe->lock_path, &st) == 0; +} + +/* Under umask, the endpoint lock is created, contended and reclaimed. */ +static void check_lock_under_umask(const char *svc, mode_t mask) +{ + char path[256]; + snprintf(path, sizeof(path), "%s/%s.lock", TEST_RUN_DIR, svc); + unlink(path); + nipc_uds_server_config_t scfg = default_server_config(); + nipc_uds_listener_t first, second; + lock_publish_probe_t probe = { .lock_path = path }; + nipc_uds_set_lock_publish_hook_for_tests(probe_lock_publish, &probe); + mode_t old_mask = umask(mask); + nipc_uds_error_t err = nipc_uds_listen(TEST_RUN_DIR, svc, &scfg, &first); + nipc_uds_set_lock_publish_hook_for_tests(NULL, NULL); + nipc_uds_error_t busy = NIPC_UDS_ERR_SOCKET, again = NIPC_UDS_ERR_SOCKET; + if (err == NIPC_UDS_OK) { + busy = nipc_uds_listen(TEST_RUN_DIR, svc, &scfg, &second); + if (busy == NIPC_UDS_OK) + nipc_uds_close_listener(&second); + nipc_uds_close_listener(&first); + again = nipc_uds_listen(TEST_RUN_DIR, svc, &scfg, &second); + if (again == NIPC_UDS_OK) + nipc_uds_close_listener(&second); + } + umask(old_mask); + printf(" umask %04o: listen=%d second=%d restart=%d lock mode=%04o\n", + (unsigned)mask, err, busy, again, (unsigned)lock_file_mode(svc)); + check("listen creates the lock", err == NIPC_UDS_OK); + check("a second server gets ADDR_IN_USE", busy == NIPC_UDS_ERR_ADDR_IN_USE); + check("the endpoint restarts", again == NIPC_UDS_OK); + check("the lock file is 0600", lock_file_mode(svc) == 0600); + struct stat st; + check("the lock is 0600 before it gets its name", + probe.calls == 1 && probe.tmp_mode == 0600 && !probe.lock_visible); + check("the temporary name is gone", lstat(probe.tmp_path, &st) < 0); + cleanup_socket(svc); +} + +/* A leftover lock file of the given mode must not block a start. */ +static void check_lock_leftover_mode(const char *svc, mode_t mode) +{ + char path[256]; + snprintf(path, sizeof(path), "%s/%s.lock", TEST_RUN_DIR, svc); + unlink(path); + int fd = open(path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600); + check("leftover lock created", fd >= 0 && fchmod(fd, mode) == 0); + if (fd >= 0) + close(fd); + nipc_uds_server_config_t scfg = default_server_config(); + nipc_uds_listener_t listener; + nipc_uds_error_t err = nipc_uds_listen(TEST_RUN_DIR, svc, &scfg, &listener); + printf(" leftover %04o: listen=%d lock mode=%04o\n", (unsigned)mode, err, + (unsigned)lock_file_mode(svc)); + check("listen over the leftover lock", err == NIPC_UDS_OK); + if (err == NIPC_UDS_OK) + nipc_uds_close_listener(&listener); + check("the lock file is 0600 again", lock_file_mode(svc) == 0600); + cleanup_socket(svc); +} + +static void test_listen_lock_restrictive_umask(void) +{ + printf("Test: endpoint lock under a umask without owner read or write\n"); + check_lock_under_umask("test_lock_umask", 0477); + check_lock_under_umask("test_lock_umask", 0277); + check_lock_under_umask("test_lock_umask", 0777); + check_lock_leftover_mode("test_lock_leftover", 0200); + check_lock_leftover_mode("test_lock_leftover", 0400); +} + +/* A child forked by another thread shares the lock's open file description + * until it execs. Closing the listener must still free the endpoint. */ +static void test_listen_close_releases_lock_held_by_child(void) +{ + printf("Test: close frees the endpoint while a forked child holds the lock fd\n"); + const char *svc = "test_listen_child_lock"; + cleanup_socket(svc); + + nipc_uds_server_config_t scfg = default_server_config(); + nipc_uds_listener_t first; + nipc_uds_error_t err = nipc_uds_listen(TEST_RUN_DIR, svc, &scfg, &first); + check("first listen ok", err == NIPC_UDS_OK); + if (err != NIPC_UDS_OK) + return; + + int gate[2]; + check("pipe", pipe(gate) == 0); + pid_t child = fork(); + if (child == 0) { + /* Keep every inherited fd until the parent closes the pipe. */ + char c; + close(gate[1]); + while (read(gate[0], &c, 1) < 0 && errno == EINTR) + ; + _exit(0); + } + check("child forked", child > 0); + close(gate[0]); + + nipc_uds_close_listener(&first); + nipc_uds_listener_t second; + err = nipc_uds_listen(TEST_RUN_DIR, svc, &scfg, &second); + check("endpoint free while the child holds the lock fd", err == NIPC_UDS_OK); + if (err == NIPC_UDS_OK) + nipc_uds_close_listener(&second); + + close(gate[1]); + if (child > 0) + waitpid(child, NULL, 0); + cleanup_socket(svc); +} + /* ------------------------------------------------------------------ */ /* Coverage: listen on invalid path (bind failure) */ /* ------------------------------------------------------------------ */ +/* An empty run_dir or a NULL config/out is a caller error, not a path. */ +static void test_listen_connect_bad_params(void) +{ + printf("Test: empty run_dir and NULL parameters are rejected\n"); + nipc_uds_server_config_t scfg = default_server_config(); + nipc_uds_client_config_t ccfg = default_client_config(); + nipc_uds_listener_t listener; + nipc_uds_session_t session; + + check("listen with empty run_dir", + nipc_uds_listen("", "test_bad_params", &scfg, &listener) == NIPC_UDS_ERR_BAD_PARAM); + check("listen with NULL run_dir", + nipc_uds_listen(NULL, "test_bad_params", &scfg, &listener) == NIPC_UDS_ERR_BAD_PARAM); + check("listen with NULL config", + nipc_uds_listen(TEST_RUN_DIR, "test_bad_params", NULL, &listener) == NIPC_UDS_ERR_BAD_PARAM); + check("listen with NULL out", + nipc_uds_listen(TEST_RUN_DIR, "test_bad_params", &scfg, NULL) == NIPC_UDS_ERR_BAD_PARAM); + check("connect with empty run_dir", + nipc_uds_connect("", "test_bad_params", &ccfg, &session) == NIPC_UDS_ERR_BAD_PARAM); + check("connect with NULL config", + nipc_uds_connect(TEST_RUN_DIR, "test_bad_params", NULL, &session) == NIPC_UDS_ERR_BAD_PARAM); + check("connect with NULL out", + nipc_uds_connect(TEST_RUN_DIR, "test_bad_params", &ccfg, NULL) == NIPC_UDS_ERR_BAD_PARAM); +} + static void test_listen_bind_failure(void) { printf("Test: Listen on invalid path (bind failure)\n"); @@ -2471,10 +3440,7 @@ static void test_batch_corrupt_dir(void) pthread_t tid; pthread_create(&tid, NULL, echo_server_thread, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); nipc_uds_client_config_t ccfg = default_client_config(); @@ -2557,6 +3523,22 @@ static void test_connect_malformed_hello_ack(void) "bad-layout HELLO_ACK rejected as incompatible" }, { "test_ack_payload", MALFORMED_ACK_BAD_PAYLOAD, NIPC_UDS_ERR_PROTOCOL, "truncated HELLO_ACK payload rejected" }, + { "test_ack_pkt_above", MALFORMED_ACK_PACKET_ABOVE_PROPOSAL, NIPC_UDS_ERR_PROTOCOL, + "HELLO_ACK packet size above the client proposal rejected" }, + { "test_ack_sel_unoffered", MALFORMED_ACK_SELECTED_NOT_OFFERED, NIPC_UDS_ERR_PROTOCOL, + "HELLO_ACK selecting a profile the client did not offer rejected" }, + { "test_ack_sel_none", MALFORMED_ACK_SELECTED_NONE, NIPC_UDS_ERR_PROTOCOL, + "HELLO_ACK selecting no profile rejected" }, + { "test_ack_sel_two", MALFORMED_ACK_SELECTED_TWO, NIPC_UDS_ERR_PROTOCOL, + "HELLO_ACK selecting two profiles rejected" }, +#if !TEST_UDS_STREAM + /* A stream has no packet boundary: bytes after the payload are + * read as the next packet. */ + { "test_ack_trailing", MALFORMED_ACK_TRAILING_BYTES, NIPC_UDS_ERR_PROTOCOL, + "HELLO_ACK packet with bytes after its payload rejected" }, +#endif + { "test_ack_long", MALFORMED_ACK_LONG_PAYLOAD, NIPC_UDS_ERR_PROTOCOL, + "HELLO_ACK declaring a 64-byte payload rejected" }, }; for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { @@ -2573,10 +3555,7 @@ static void test_connect_malformed_hello_ack(void) pthread_t tid; pthread_create(&tid, NULL, malformed_ack_server_thread, &sctx); - for (int j = 0; j < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); j++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("malformed-ack server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); @@ -2596,10 +3575,10 @@ static void test_connect_malformed_hello_ack(void) /* Coverage: malformed client HELLO payload on server accept */ /* ------------------------------------------------------------------ */ -static void test_accept_malformed_hello_payload(void) +/* Sends pkt as a raw client's HELLO and returns the server's accept result. */ +static nipc_uds_error_t accept_raw_hello(const char *svc, const void *pkt, + size_t len) { - printf("Test: Accept rejects malformed HELLO payload\n"); - const char *svc = "test_bad_hello_payload"; cleanup_socket(svc); accept_result_server_ctx_t sctx = { @@ -2613,16 +3592,13 @@ static void test_accept_malformed_hello_payload(void) pthread_t tid; pthread_create(&tid, NULL, accept_result_server_thread, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("accept-result server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); char path[256]; build_socket_path(path, sizeof(path), svc); - int fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); + int fd = socket(AF_UNIX, TEST_UDS_SOCK_TYPE, 0); check("raw client socket created", fd >= 0); if (fd >= 0) { @@ -2632,28 +3608,63 @@ static void test_accept_malformed_hello_payload(void) strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); int rc = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); check("raw client connect", rc == 0); - - if (rc == 0) { - uint8_t pkt[NIPC_HEADER_LEN + 4] = {0}; - nipc_header_t hdr = { - .magic = NIPC_MAGIC_MSG, - .version = NIPC_VERSION, - .header_len = NIPC_HEADER_LEN, - .kind = NIPC_KIND_CONTROL, - .code = NIPC_CODE_HELLO, - .payload_len = 4, - .item_count = 1, - }; - nipc_header_encode(&hdr, pkt, sizeof(pkt)); - send(fd, pkt, sizeof(pkt), MSG_NOSIGNAL); - } + if (rc == 0) + send(fd, pkt, len, MSG_NOSIGNAL); close(fd); } pthread_join(tid, NULL); - check("malformed HELLO payload rejected", - sctx.accept_err == NIPC_UDS_ERR_PROTOCOL); cleanup_socket(svc); + return sctx.accept_err; +} + +static void test_accept_malformed_hello_payload(void) +{ + printf("Test: Accept rejects malformed HELLO payload\n"); + + nipc_header_t hdr = { + .magic = NIPC_MAGIC_MSG, + .version = NIPC_VERSION, + .header_len = NIPC_HEADER_LEN, + .kind = NIPC_KIND_CONTROL, + .code = NIPC_CODE_HELLO, + .payload_len = 4, + .item_count = 1, + }; + uint8_t pkt[NIPC_HEADER_LEN + NIPC_HELLO_WIRE_SIZE + 16]; + memset(pkt, 0, sizeof(pkt)); + nipc_header_encode(&hdr, pkt, sizeof(pkt)); + check("malformed HELLO payload rejected", + accept_raw_hello("test_bad_hello_payload", pkt, + NIPC_HEADER_LEN + 4) == NIPC_UDS_ERR_PROTOCOL); + + /* A valid HELLO followed by 16 more bytes in the same packet, then one + * whose header declares those 16 bytes too. */ + nipc_hello_t hello = { + .layout_version = 1, + .supported_profiles = NIPC_PROFILE_BASELINE, + .max_request_payload_bytes = 1024, + .max_request_batch_items = 1, + .max_response_payload_bytes = 1024, + .max_response_batch_items = 1, + .packet_size = 4096, + }; + memset(pkt, 0, sizeof(pkt)); + hdr.payload_len = NIPC_HELLO_WIRE_SIZE; + nipc_header_encode(&hdr, pkt, sizeof(pkt)); + nipc_hello_encode(&hello, pkt + NIPC_HEADER_LEN, NIPC_HELLO_WIRE_SIZE); +#if !TEST_UDS_STREAM + /* SEQPACKET only: on a stream the extra bytes are the next packet. */ + check("HELLO packet with bytes after its payload rejected", + accept_raw_hello("test_hello_trailing", pkt, sizeof(pkt)) == + NIPC_UDS_ERR_PROTOCOL); +#endif + + hdr.payload_len = NIPC_HELLO_WIRE_SIZE + 16; + nipc_header_encode(&hdr, pkt, sizeof(pkt)); + check("HELLO declaring a 60-byte payload rejected", + accept_raw_hello("test_hello_long", pkt, sizeof(pkt)) == + NIPC_UDS_ERR_PROTOCOL); } static void test_accept_incompatible_hello_version(void) @@ -2673,16 +3684,13 @@ static void test_accept_incompatible_hello_version(void) pthread_t tid; pthread_create(&tid, NULL, accept_result_server_thread, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("accept-result server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); char path[256]; build_socket_path(path, sizeof(path), svc); - int fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); + int fd = socket(AF_UNIX, TEST_UDS_SOCK_TYPE, 0); check("raw client socket created", fd >= 0); if (fd >= 0) { @@ -2746,16 +3754,13 @@ static void test_accept_incompatible_hello_layout(void) pthread_t tid; pthread_create(&tid, NULL, accept_result_server_thread, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("accept-result server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); char path[256]; build_socket_path(path, sizeof(path), svc); - int fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); + int fd = socket(AF_UNIX, TEST_UDS_SOCK_TYPE, 0); check("raw client socket created", fd >= 0); if (fd >= 0) { @@ -2823,10 +3828,7 @@ static void test_receive_short_packet_protocol(void) pthread_t tid; pthread_create(&tid, NULL, raw_response_server_thread, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("raw-response server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); @@ -2880,10 +3882,7 @@ static void test_receive_trailing_packet_protocol(void) pthread_t tid; pthread_create(&tid, NULL, raw_response_server_thread, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("raw-response trailing server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); @@ -2910,6 +3909,16 @@ static void test_receive_trailing_packet_protocol(void) size_t resp_payload_len; nipc_uds_error_t rerr = nipc_uds_receive(&session, buf, sizeof(buf), &resp_hdr, &resp_payload, &resp_payload_len); +#if TEST_UDS_STREAM + /* A stream has no packet end to check: the message completes + * and the trailing byte is rejected as the next packet, a + * truncated header once the server closes. */ + check("trailing response message delivered", + rerr == NIPC_UDS_OK && resp_payload_len == 1 && + ((const uint8_t *)resp_payload)[0] == 0xBE); + rerr = nipc_uds_receive(&session, buf, sizeof(buf), + &resp_hdr, &resp_payload, &resp_payload_len); +#endif check("trailing response packet rejected", rerr == NIPC_UDS_ERR_PROTOCOL); } @@ -2937,10 +3946,7 @@ static void test_receive_batch_dir_too_short(void) pthread_t tid; pthread_create(&tid, NULL, raw_response_server_thread, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("raw-response server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); @@ -2998,10 +4004,7 @@ static void test_receive_short_continuation_packet(void) pthread_t tid; pthread_create(&tid, NULL, raw_response_server_thread, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("raw-response server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); @@ -3060,10 +4063,7 @@ static void test_receive_item_count_exceeds_limit(void) pthread_t tid; pthread_create(&tid, NULL, raw_response_server_thread, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("raw-response server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); @@ -3121,10 +4121,7 @@ static void test_receive_bad_continuation_header(void) pthread_t tid; pthread_create(&tid, NULL, raw_response_server_thread, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("raw-response server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); @@ -3185,10 +4182,7 @@ static void test_receive_missing_continuation(void) pthread_t tid; pthread_create(&tid, NULL, raw_response_server_thread, &sctx); - for (int i = 0; i < 2000 - && !__atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) - && !__atomic_load_n(&sctx.done, __ATOMIC_ACQUIRE); i++) - usleep(500); + (void)test_flag_wait_any(&sctx.ready, &sctx.done); check("raw-response server ready", __atomic_load_n(&sctx.ready, __ATOMIC_ACQUIRE) == 1); @@ -3232,6 +4226,322 @@ static void test_receive_missing_continuation(void) /* Coverage: close_session(NULL) */ /* ------------------------------------------------------------------ */ +/* ------------------------------------------------------------------ */ +/* Stream-socket packet I/O (macOS and FreeBSD baseline; tested everywhere) */ +/* ------------------------------------------------------------------ */ + +static size_t encode_test_packet(uint8_t *dst, uint32_t magic, + uint32_t declared, const void *body, + size_t body_len) +{ + memset(dst, 0, NIPC_HEADER_LEN); + if (magic == NIPC_MAGIC_MSG) { + nipc_header_t hdr = { + .magic = NIPC_MAGIC_MSG, .version = NIPC_VERSION, + .header_len = NIPC_HEADER_LEN, .kind = NIPC_KIND_REQUEST, + .code = 1, .item_count = 1, .message_id = 9, + .payload_len = declared, + }; + nipc_header_encode(&hdr, dst, NIPC_HEADER_LEN); + } else if (magic == NIPC_MAGIC_CHUNK) { + nipc_chunk_header_t chk = { + .magic = NIPC_MAGIC_CHUNK, .version = NIPC_VERSION, + .message_id = 9, .chunk_index = 1, .chunk_count = 2, + .chunk_payload_len = declared, + }; + nipc_chunk_header_encode(&chk, dst, NIPC_HEADER_LEN); + } else { + memcpy(dst, &magic, sizeof(magic)); + } + if (body_len) + memcpy(dst + NIPC_HEADER_LEN, body, body_len); + return NIPC_HEADER_LEN + body_len; +} + +static void test_stream_body_len(void) +{ + printf("Test: Stream packet length comes from its header\n"); + uint8_t pkt[NIPC_HEADER_LEN]; + + encode_test_packet(pkt, NIPC_MAGIC_MSG, 100, NULL, 0); + check("outer header: whole payload when it fits the packet", + nipc_uds_stream_body_len(pkt, 1024, 1024) == 100); + check("outer header: first chunk fills the packet", + nipc_uds_stream_body_len(pkt, 1024, 48) == 16); + check("outer header in a handshake: capped by the buffer", + nipc_uds_stream_body_len(pkt, 80, 0) == 48); + check("outer header: bytes past the buffer are not read", + nipc_uds_stream_body_len(pkt, 64, 1024) == 32); + + encode_test_packet(pkt, NIPC_MAGIC_CHUNK, 7, NULL, 0); + check("continuation header: chunk_payload_len", + nipc_uds_stream_body_len(pkt, 1024, 48) == 7); + + encode_test_packet(pkt, 0x12345678u, 0, NULL, 0); + check("unknown magic: header only", + nipc_uds_stream_body_len(pkt, 1024, 1024) == 0); +} + +static void test_stream_packet_io(void) +{ + printf("Test: Stream packet send and receive\n"); + int sv[2]; + check("stream socketpair", socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == 0); + + uint8_t out[NIPC_HEADER_LEN + 16]; + uint8_t in[NIPC_HEADER_LEN + 16]; + size_t len = 0; + + /* Two packets back to back come out one at a time. */ + size_t n1 = encode_test_packet(out, NIPC_MAGIC_MSG, 5, "hello", 5); + check("send outer packet", + nipc_uds_stream_send_iov(sv[0], out, NIPC_HEADER_LEN, + out + NIPC_HEADER_LEN, 5) == NIPC_UDS_OK); + uint8_t chunk[NIPC_HEADER_LEN + 3]; + size_t n2 = encode_test_packet(chunk, NIPC_MAGIC_CHUNK, 3, "abc", 3); + check("send continuation packet", + nipc_uds_stream_send_iov(sv[0], chunk, n2, NULL, 0) == NIPC_UDS_OK); + + check("receive outer packet", + nipc_uds_stream_recv_packet(sv[1], in, sizeof(in), 64, NULL, &len) == + NIPC_UDS_OK && len == n1 && memcmp(in, out, n1) == 0); + check("receive continuation packet", + nipc_uds_stream_recv_packet(sv[1], in, sizeof(in), 64, NULL, &len) == + NIPC_UDS_OK && len == n2 && memcmp(in, chunk, n2) == 0); + + /* A deadline with nothing queued is a timeout, not a failure. */ + nipc_uds_wait_t wait = nipc_uds_wait_init(1, -1); + check("idle stream times out", + nipc_uds_stream_recv_packet(sv[1], in, sizeof(in), 64, &wait, &len) == + NIPC_UDS_ERR_TIMEOUT); + + /* EOF inside a packet is a truncated packet. */ + n1 = encode_test_packet(out, NIPC_MAGIC_MSG, 5, "he", 2); + check("send truncated packet", + nipc_uds_stream_send_iov(sv[0], out, n1, NULL, 0) == NIPC_UDS_OK); + shutdown(sv[0], SHUT_WR); + check("EOF inside the body is a protocol error", + nipc_uds_stream_recv_packet(sv[1], in, sizeof(in), 64, NULL, &len) == + NIPC_UDS_ERR_PROTOCOL); + close(sv[0]); + close(sv[1]); + + check("stream socketpair", socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == 0); + check("send partial header", + nipc_uds_stream_send_iov(sv[0], out, 10, NULL, 0) == NIPC_UDS_OK); + shutdown(sv[0], SHUT_WR); + check("EOF inside the header is a protocol error", + nipc_uds_stream_recv_packet(sv[1], in, sizeof(in), 64, NULL, &len) == + NIPC_UDS_ERR_PROTOCOL); + close(sv[0]); + close(sv[1]); + + check("stream socketpair", socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == 0); + close(sv[0]); + check("EOF between packets is a disconnect", + nipc_uds_stream_recv_packet(sv[1], in, sizeof(in), 64, NULL, &len) == + NIPC_UDS_ERR_RECV); + close(sv[1]); +} + +#ifdef NIPC_TEST_EINTR_WRAP +/* A stream moves any part of the data per call and a signal may interrupt + * any call: one-byte transfers and EINTR must still yield whole packets. */ +static void test_stream_short_io(void) +{ + printf("Test: Stream packets survive one-byte transfers and EINTR\n"); + int sv[2]; + check("stream socketpair", socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == 0); + + uint8_t out[NIPC_HEADER_LEN + 5]; + uint8_t in[NIPC_HEADER_LEN + 5]; + size_t n = encode_test_packet(out, NIPC_MAGIC_MSG, 5, "hello", 5); + + /* 36 one-byte writes leave one byte for the last call; reads of more + * than one byte are cut to one, 31 in the header and 4 in the body. */ + uds_short_io_set(0, n - 1); + uds_eintr_wrap_set(0, 2, 0); + check("send in one-byte writes", + nipc_uds_stream_send_iov(sv[0], out, NIPC_HEADER_LEN, + out + NIPC_HEADER_LEN, 5) == NIPC_UDS_OK); + + uds_short_io_set(n - 2, 0); + uds_eintr_wrap_set(0, 0, 2); + size_t len = 0; + check("receive in one-byte reads", + nipc_uds_stream_recv_packet(sv[1], in, sizeof(in), 64, NULL, &len) == + NIPC_UDS_OK && len == n && memcmp(in, out, n) == 0); + check("every injection was hit", uds_eintr_wrap_pending() == 0); + uds_short_io_set(0, 0); + uds_eintr_wrap_set(0, 0, 0); + close(sv[0]); + close(sv[1]); +} +#endif + +#if TEST_UDS_STREAM +static void stream_server_session(nipc_uds_session_t *s, int fd) +{ + memset(s, 0, sizeof(*s)); + s->fd = fd; + s->role = NIPC_UDS_ROLE_SERVER; + s->packet_size = 1024; + s->max_request_payload_bytes = 1024; + s->max_request_batch_items = 1; + s->max_response_payload_bytes = 1024; + s->max_response_batch_items = 1; +} + +/* A receive that consumed part of a message cannot leave the session usable: + * the rest is still queued and would be read as a header. */ +static void test_stream_partial_receive_ends_reads(void) +{ + printf("Test: Stream receive after a partial message fails\n"); + int sv[2]; + check("stream socketpair", socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == 0); + nipc_uds_session_t session; + stream_server_session(&session, sv[1]); + + uint8_t buf[2048]; + nipc_header_t hdr; + const void *payload; + size_t payload_len; + check("timeout before the first byte", + nipc_uds_receive_timeout(&session, buf, sizeof(buf), &hdr, &payload, + &payload_len, 1, -1) == NIPC_UDS_ERR_TIMEOUT); + + uint8_t pkt[NIPC_HEADER_LEN + 5]; + size_t n = encode_test_packet(pkt, NIPC_MAGIC_MSG, 5, "hello", 5); + check("send a whole request", + nipc_uds_stream_send_iov(sv[0], pkt, n, NULL, 0) == NIPC_UDS_OK); + check("the session still receives after a clean timeout", + nipc_uds_receive(&session, buf, sizeof(buf), &hdr, &payload, + &payload_len) == NIPC_UDS_OK && payload_len == 5); + + check("send part of a request", + nipc_uds_stream_send_iov(sv[0], pkt, 10, NULL, 0) == NIPC_UDS_OK); + check("timeout inside a message", + nipc_uds_receive_timeout(&session, buf, sizeof(buf), &hdr, &payload, + &payload_len, 1, -1) == NIPC_UDS_ERR_TIMEOUT); + check("send the rest of the request", + nipc_uds_stream_send_iov(sv[0], pkt + 10, n - 10, NULL, 0) == NIPC_UDS_OK); + check("later receives fail", + nipc_uds_receive(&session, buf, sizeof(buf), &hdr, &payload, + &payload_len) == NIPC_UDS_ERR_RECV); + + /* Sends always write whole messages, so they stay valid. */ + nipc_header_t resp = {.kind = NIPC_KIND_RESPONSE, .code = 1, + .item_count = 1, .message_id = 9}; + check("sends still work", + nipc_uds_send(&session, &resp, "ok", 2) == NIPC_UDS_OK); + size_t len = 0; + check("the peer gets the whole response", + nipc_uds_stream_recv_packet(sv[0], buf, sizeof(buf), 1024, NULL, &len) == + NIPC_UDS_OK && len == NIPC_HEADER_LEN + 2); + + close(sv[0]); + nipc_uds_close_session(&session); +} +#endif + +#if defined(__APPLE__) +static void bind_dir_path(char *dst, size_t len, const char *svc) +{ + snprintf(dst, len, "%s/%s.d", TEST_RUN_DIR, svc); +} + +static bool listen_and_close(const char *svc, nipc_uds_error_t *err_out, + mode_t *mode_out) +{ + nipc_uds_server_config_t scfg = default_server_config(); + nipc_uds_listener_t listener; + *err_out = nipc_uds_listen(TEST_RUN_DIR, svc, &scfg, &listener); + if (*err_out != NIPC_UDS_OK) + return false; + char path[256]; + build_socket_path(path, sizeof(path), svc); + struct stat st; + *mode_out = stat(path, &st) == 0 ? (st.st_mode & 0777) : 0; + nipc_uds_close_listener(&listener); + return true; +} + +/* macOS binds in {service}.d/ and renames the socket into place. */ +static void test_listen_bind_dir(void) +{ + printf("Test: macOS listen binds through a private directory\n"); + const char *svc = "test_bind_dir"; + char dir[256], inner[300]; + bind_dir_path(dir, sizeof(dir), svc); + snprintf(inner, sizeof(inner), "%s/s", dir); + cleanup_socket(svc); + + nipc_uds_error_t err; + mode_t mode = 0; + struct stat st; + check("listen", listen_and_close(svc, &err, &mode)); + check("socket is owner-only", mode == 0600); + check("bind directory removed", lstat(dir, &st) < 0 && errno == ENOENT); + + /* A crashed server's leftover directory is reclaimed. */ + check("leftover dir", mkdir(dir, 0700) == 0); + int fd = open(inner, O_WRONLY | O_CREAT | O_CLOEXEC, 0600); + check("leftover socket file", fd >= 0); + if (fd >= 0) + close(fd); + check("listen over a leftover directory", listen_and_close(svc, &err, &mode)); + check("leftover directory removed", lstat(dir, &st) < 0 && errno == ENOENT); + + /* A non-directory at the name is junk, as at the socket name. */ + fd = open(dir, O_WRONLY | O_CREAT | O_CLOEXEC, 0600); + check("leftover file", fd >= 0); + if (fd >= 0) + close(fd); + check("listen over a leftover file", listen_and_close(svc, &err, &mode)); + check("leftover file removed", lstat(dir, &st) < 0 && errno == ENOENT); + + /* Foreign content is never deleted: listen fails instead. */ + char other[300]; + snprintf(other, sizeof(other), "%s/other", dir); + check("foreign dir", mkdir(dir, 0700) == 0); + fd = open(other, O_WRONLY | O_CREAT | O_CLOEXEC, 0600); + check("foreign file", fd >= 0); + if (fd >= 0) + close(fd); + check("listen refuses a directory with foreign content", + !listen_and_close(svc, &err, &mode) && err == NIPC_UDS_ERR_SOCKET); + check("foreign content kept", stat(other, &st) == 0); + unlink(other); + + /* A directory where the socket goes is foreign content too. */ + char sub[300]; + snprintf(sub, sizeof(sub), "%s/s", dir); + check("foreign subdirectory", mkdir(sub, 0700) == 0); + check("listen refuses a directory at the socket name", + !listen_and_close(svc, &err, &mode) && err == NIPC_UDS_ERR_SOCKET); + check("foreign subdirectory kept", stat(sub, &st) == 0 && S_ISDIR(st.st_mode)); + rmdir(sub); + rmdir(dir); + + /* The umask still narrows the mode, as with fchmod() on Linux. */ + mode_t old_mask = umask(0277); + bool ok = listen_and_close(svc, &err, &mode); + umask(old_mask); + check("listen under umask 0277", ok); + check("socket mode narrowed by the umask", mode == 0400); + cleanup_socket(svc); + + /* A umask without owner read must not stop the bind directory from + * being opened. */ + old_mask = umask(0477); + ok = listen_and_close(svc, &err, &mode); + umask(old_mask); + check("listen under umask 0477", ok); + check("socket mode narrowed by umask 0477", mode == 0200); + cleanup_socket(svc); +} +#endif + static void test_close_session_null(void) { printf("Test: close_session(NULL)\n"); @@ -3256,14 +4566,31 @@ int main(void) printf("=== L1 POSIX UDS SEQPACKET Transport Tests ===\n\n"); test_ping_pong(); printf("\n"); +#ifdef NIPC_TEST_EINTR_WRAP + test_eintr_retried(); printf("\n"); + test_listen_keeps_process_umask(); printf("\n"); + test_stream_short_io(); printf("\n"); +#endif + test_stream_body_len(); printf("\n"); + test_stream_packet_io(); printf("\n"); +#if TEST_UDS_STREAM + test_stream_partial_receive_ends_reads(); printf("\n"); +#endif +#if defined(__APPLE__) + test_listen_bind_dir(); printf("\n"); +#endif test_multi_client(); printf("\n"); test_pipelining(); printf("\n"); test_chunking(); printf("\n"); test_bad_auth(); printf("\n"); test_profile_mismatch(); printf("\n"); + test_platform_profile_mask(); printf("\n"); + test_unassigned_profile_bit(); printf("\n"); + test_bounded_control_send_idle(); printf("\n"); test_request_payload_over_cap(); printf("\n"); test_stale_recovery(); printf("\n"); test_stale_recovery_reclaims_regular_file(); printf("\n"); + test_stale_recovery_reclaims_empty_directory(); printf("\n"); test_listen_socket_owner_only(); printf("\n"); test_disconnect_inflight(); printf("\n"); test_batch(); printf("\n"); @@ -3277,6 +4604,15 @@ int main(void) test_pipeline_100(); printf("\n"); test_pipeline_mixed_sizes(); printf("\n"); test_pipeline_chunked(); printf("\n"); + test_pipeline_exceeds_socket_buffers(); printf("\n"); + test_pipeline_peer_close_during_send(); printf("\n"); + test_pipeline_pending_bound(); printf("\n"); + test_receive_undersized_buffer_keeps_session(); printf("\n"); +#if TEST_UDS_STREAM + test_stream_failed_receive_drops_pending(); printf("\n"); +#else + test_pending_oversized_datagram_rejected(); printf("\n"); +#endif /* Coverage gap tests */ test_handshake_wrong_kind(); printf("\n"); @@ -3285,6 +4621,10 @@ int main(void) test_receive_payload_exceeds_limit(); printf("\n"); test_receive_unknown_message_id(); printf("\n"); test_stale_live_server(); printf("\n"); + test_listen_excludes_starting_server(); printf("\n"); + test_listen_close_releases_lock_held_by_child(); printf("\n"); + test_listen_lock_restrictive_umask(); printf("\n"); + test_listen_connect_bad_params(); printf("\n"); test_listen_bind_failure(); printf("\n"); test_batch_corrupt_dir(); printf("\n"); test_connect_malformed_hello_ack(); printf("\n"); diff --git a/tests/fixtures/c/test_win_robustness.c b/tests/fixtures/c/test_win_robustness.c new file mode 100644 index 00000000..ad9d6c3e --- /dev/null +++ b/tests/fixtures/c/test_win_robustness.c @@ -0,0 +1,838 @@ +/* + * test_win_robustness.c - Robustness reproduction tests (SOW-0040), Windows. + * + * Each test asserts the correct behavior. Every blocking step runs on a + * helper thread waited with a deadline, so a regression fails instead of + * hanging. Bounds are deliberately lenient so they do not dictate a fix. + * Tests wait on server events and peer signals, never on sleeps. + * + * Tests: + * K2 SHM session released after client crash + * K3 silent client does not block other clients + * K3 silent client does not block shutdown + * K3 silent client's worker is freed at the server handshake deadline + * K4 client bounded when server never sends HELLO_ACK + * K4 a call's reconnect is bounded by the call deadline + * K4 abort ends a refresh waiting for HELLO_ACK + * N2 shutdown not blocked by a client that stopped reading + * N3 stop before run is honored + * N5 SHM client notices a crashed server before its timeout + * N5 SHM attach stops when the server closes the session + * + * The binary re-executes itself with --helper-shm-client / --helper-shm-server + * to simulate a peer process that is killed with TerminateProcess. + */ + +#include "netipc/netipc_named_pipe.h" +#include "netipc/netipc_protocol.h" +#include "netipc/netipc_service.h" +#include "netipc/netipc_win_shm.h" +#include "test_sync.h" + +#include +#include +#include +#include +#include + +#define TEST_RUN_DIR "C:\\Temp\\nipc_win_robustness" +#define AUTH_TOKEN 0xDEADBEEFCAFEBABEull +#define RESPONSE_BUF_SIZE 65536 + +static int g_pass = 0; +static int g_fail = 0; + +static void check(const char *name, int cond) +{ + printf(" %s: %s\n", cond ? "PASS" : "FAIL", name); + fflush(stdout); + if (cond) + g_pass++; + else + g_fail++; +} + +static ULONGLONG now_ms(void) +{ + return GetTickCount64(); +} + +/* Unique service names keep runs independent of leftover pipe instances. */ +static const char *svc_name(const char *prefix) +{ + static char names[16][96]; + static int next; + char *out = names[next++ % 16]; + snprintf(out, 96, "%s_%lu", prefix, (unsigned long)GetCurrentProcessId()); + return out; +} + +/* ------------------------------------------------------------------ */ +/* Server */ +/* ------------------------------------------------------------------ */ + +typedef struct { + size_t raw_len; /* if non-zero, reply with raw_len opaque bytes */ + BOOL block; /* report entry on stdout, then block until killed */ + int handled; /* __atomic: raised once a raw reply is built */ +} handler_opts_t; + +static nipc_error_t robust_handler(void *user, const nipc_header_t *request_hdr, + const uint8_t *request_payload, size_t request_len, + uint8_t *response_buf, size_t response_buf_size, + size_t *response_len_out) +{ + handler_opts_t *opts = (handler_opts_t *)user; + (void)request_hdr; + + if (opts && opts->block) { + printf("HELPER_HANDLER_ENTERED\n"); + fflush(stdout); + Sleep(INFINITE); + } + + if (opts && opts->raw_len) { + if (opts->raw_len > response_buf_size) + return NIPC_ERR_OVERFLOW; + memset(response_buf, 0, opts->raw_len); + *response_len_out = opts->raw_len; + test_flag_set(&opts->handled); + return NIPC_OK; + } + + nipc_cgroups_req_t req; + nipc_error_t err = nipc_cgroups_req_decode(request_payload, request_len, &req); + if (err != NIPC_OK) + return err; + + nipc_cgroups_builder_t builder; + nipc_cgroups_builder_init(&builder, response_buf, response_buf_size, 1, 1, 42); + err = nipc_cgroups_builder_add(&builder, 1001, 0, 1, "rob-cg", 6, "/sys/fs/cgroup/rob", 18); + if (err != NIPC_OK) + return err; + *response_len_out = nipc_cgroups_builder_finish(&builder); + return (*response_len_out > 0) ? NIPC_OK : NIPC_ERR_OVERFLOW; +} + +typedef struct { + const char *service; + int workers; + BOOL shm; + uint32_t max_payload; + handler_opts_t opts; + nipc_managed_server_t server; + test_server_events_t events; + HANDLE thread; +} robust_server_t; + +static nipc_np_server_config_t robust_server_config(const robust_server_t *rs) +{ + nipc_np_server_config_t scfg = { + .supported_profiles = NIPC_PROFILE_BASELINE | + (rs->shm ? NIPC_WIN_SHM_PROFILE_HYBRID : 0), + .preferred_profiles = rs->shm ? NIPC_WIN_SHM_PROFILE_HYBRID : 0, + .max_request_payload_bytes = 4096, + .max_request_batch_items = 1, + .max_response_payload_bytes = rs->max_payload ? rs->max_payload : RESPONSE_BUF_SIZE, + .max_response_batch_items = 1, + .auth_token = AUTH_TOKEN, + }; + return scfg; +} + +static DWORD WINAPI robust_server_fn(LPVOID arg) +{ + robust_server_t *rs = (robust_server_t *)arg; + nipc_server_run(&rs->server); + nipc_server_destroy(&rs->server); + return 0; +} + +static BOOL start_server(robust_server_t *rs) +{ + nipc_np_server_config_t scfg = robust_server_config(rs); + nipc_error_t err = nipc_server_init(&rs->server, TEST_RUN_DIR, rs->service, &scfg, + rs->workers, NIPC_METHOD_CGROUPS_SNAPSHOT, + robust_handler, &rs->opts); + if (err != NIPC_OK) { + fprintf(stderr, "server init failed: %d\n", err); + return FALSE; + } + /* init creates the first pipe instance: clients may connect now. */ + test_server_use_test_timings(&rs->server, &rs->events); + rs->thread = test_create_thread(NULL, 0, robust_server_fn, rs, 0, NULL); + return rs->thread != NULL; +} + +/* Stop and report whether run+destroy finished within limit_ms. */ +static BOOL stop_server_within(robust_server_t *rs, DWORD limit_ms) +{ + nipc_server_stop(&rs->server); + return WaitForSingleObject(rs->thread, limit_ms) == WAIT_OBJECT_0; +} + +/* ------------------------------------------------------------------ */ +/* Clients */ +/* ------------------------------------------------------------------ */ + +static nipc_client_config_t client_config(BOOL shm, uint32_t timeout_ms) +{ + nipc_client_config_t ccfg = { + .supported_profiles = NIPC_PROFILE_BASELINE | (shm ? NIPC_WIN_SHM_PROFILE_HYBRID : 0), + .preferred_profiles = shm ? NIPC_WIN_SHM_PROFILE_HYBRID : 0, + .max_request_batch_items = 1, + .max_response_payload_bytes = RESPONSE_BUF_SIZE, + .auth_token = AUTH_TOKEN, + .call_timeout_ms = timeout_ms, + }; + return ccfg; +} + +/* Connect and complete one snapshot call, in one attempt. */ +static BOOL served_once(const char *service, BOOL shm) +{ + nipc_client_config_t ccfg = client_config(shm, 1000); + nipc_client_ctx_t client; + nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); + nipc_client_refresh(&client); + BOOL ok = FALSE; + if (nipc_client_ready(&client)) { + nipc_cgroups_resp_view_t view; + nipc_error_t err = nipc_client_call_cgroups_snapshot_timeout(&client, &view, 1000); + ok = (err == NIPC_OK && view.item_count == 1); + } + nipc_client_close(&client); + return ok; +} + +typedef struct { + const char *service; + BOOL result; +} served_job_t; + +static DWORD WINAPI served_job_fn(LPVOID arg) +{ + served_job_t *job = (served_job_t *)arg; + job->result = served_once(job->service, FALSE); + return 0; +} + +/* Open the pipe without sending HELLO. While every instance is busy the + * server is creating the next one; WaitNamedPipeW blocks until it exists. */ +static HANDLE open_pipe_silently(const char *service) +{ + wchar_t name[NIPC_NP_MAX_PIPE_NAME]; + nipc_np_build_pipe_name(name, NIPC_NP_MAX_PIPE_NAME, TEST_RUN_DIR, service); + HANDLE h = CreateFileW(name, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); + if (h == INVALID_HANDLE_VALUE && GetLastError() == ERROR_PIPE_BUSY && + WaitNamedPipeW(name, TEST_WAIT_LIMIT_MS)) + h = CreateFileW(name, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); + return h; +} + +/* ------------------------------------------------------------------ */ +/* Helper processes */ +/* ------------------------------------------------------------------ */ + +static int helper_shm_client(const char *service) +{ + nipc_client_config_t ccfg = client_config(TRUE, 2000); + nipc_client_ctx_t client; + nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); + nipc_client_refresh(&client); + nipc_cgroups_resp_view_t view; + if (nipc_client_call_cgroups_snapshot_timeout(&client, &view, 2000) != NIPC_OK) { + printf("HELPER_FAILED\n"); + return 1; + } + printf("HELPER_READY\n"); + fflush(stdout); + Sleep(INFINITE); + return 0; +} + +/* SHM server whose handler stalls, so a client call is in flight when the + * parent kills this process. */ +static int helper_shm_server(const char *service) +{ + static robust_server_t rs; + rs = (robust_server_t){.service = service, .workers = 2, .shm = TRUE, + .opts = {.block = TRUE}}; + if (!start_server(&rs)) { + printf("HELPER_FAILED\n"); + return 1; + } + printf("HELPER_READY\n"); + fflush(stdout); + Sleep(INFINITE); + return 0; +} + +typedef struct { + HANDLE rd; + const char *marker; + BOOL found; +} line_job_t; + +static DWORD WINAPI line_job_fn(LPVOID arg) +{ + line_job_t *job = (line_job_t *)arg; + char buf[256]; + DWORD used = 0; + while (used < sizeof(buf) - 1) { + DWORD n = 0; + /* One byte at a time, so a later marker stays in the pipe. */ + if (!ReadFile(job->rd, buf + used, 1, &n, NULL) || n == 0) + return 0; + used += n; + buf[used] = '\0'; + if (strstr(buf, job->marker)) { + job->found = TRUE; + return 0; + } + if (buf[used - 1] == '\n') + used = 0; + } + return 0; +} + +/* Wait until the helper's stdout carries marker. A blocked reader thread + * ends once the caller kills the helper, which breaks the pipe. */ +static BOOL helper_wait_line(HANDLE rd, const char *marker) +{ + static line_job_t job; + job = (line_job_t){.rd = rd, .marker = marker}; + HANDLE t = test_create_thread(NULL, 0, line_job_fn, &job, 0, NULL); + if (!t) + return FALSE; + BOOL done = WaitForSingleObject(t, TEST_WAIT_LIMIT_MS) == WAIT_OBJECT_0; + CloseHandle(t); + return done && job.found; +} + +/* Spawn this binary in helper mode and wait for HELPER_READY. When out_rd + * is given it receives the helper's stdout for later markers. */ +static HANDLE spawn_helper(const char *mode, const char *service, HANDLE *out_rd) +{ + SECURITY_ATTRIBUTES sa = {sizeof(sa), NULL, TRUE}; + HANDLE rd = NULL, wr = NULL; + if (!CreatePipe(&rd, &wr, &sa, 0)) + return NULL; + SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0); + + char exe[MAX_PATH]; + GetModuleFileNameA(NULL, exe, sizeof(exe)); + char cmd[2 * MAX_PATH]; + snprintf(cmd, sizeof(cmd), "\"%s\" %s %s", exe, mode, service); + + STARTUPINFOA si = {sizeof(si)}; + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = wr; + si.hStdError = GetStdHandle(STD_ERROR_HANDLE); + si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); + PROCESS_INFORMATION pi; + BOOL ok = CreateProcessA(NULL, cmd, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi); + CloseHandle(wr); + if (!ok) { + CloseHandle(rd); + return NULL; + } + CloseHandle(pi.hThread); + + if (!helper_wait_line(rd, "HELPER_READY")) { + TerminateProcess(pi.hProcess, 1); + WaitForSingleObject(pi.hProcess, 5000); + CloseHandle(pi.hProcess); + CloseHandle(rd); + return NULL; + } + if (out_rd) + *out_rd = rd; + else + CloseHandle(rd); + return pi.hProcess; +} + +static void kill_helper(HANDLE process) +{ + TerminateProcess(process, 9); + WaitForSingleObject(process, 5000); + CloseHandle(process); +} + +/* ------------------------------------------------------------------ */ +/* K2: SHM session released after client crash */ +/* ------------------------------------------------------------------ */ + +static void test_shm_session_released_after_client_crash(void) +{ + printf("K2: SHM session released after client crash\n"); + robust_server_t rs = {.service = svc_name("wrob_shm_crash"), .workers = 2, .shm = TRUE}; + if (!start_server(&rs)) { + check("server started", 0); + return; + } + + BOOL spawned = TRUE; + for (int i = 0; i < 2; i++) { + HANDLE p = spawn_helper("--helper-shm-client", rs.service, NULL); + if (!p) { + spawned = FALSE; + break; + } + kill_helper(p); + } + check("crash helpers held SHM sessions", spawned); + check("both workers freed their crashed clients' slots", + spawned && test_server_wait_session_exits(&rs.events, 2)); + check("new client served after 2 crashed SHM clients on 2 workers", + served_once(rs.service, TRUE)); + stop_server_within(&rs, 5000); +} + +/* ------------------------------------------------------------------ */ +/* K3: silent client */ +/* ------------------------------------------------------------------ */ + +static void test_silent_client_does_not_block_other_clients(void) +{ + printf("K3: silent client does not block other clients\n"); + robust_server_t rs = {.service = svc_name("wrob_silent"), .workers = 4}; + if (!start_server(&rs)) { + check("server started", 0); + return; + } + + HANDLE silent = open_pipe_silently(rs.service); + check("silent client opened the pipe", silent != INVALID_HANDLE_VALUE); + check("a worker waits for the silent client's HELLO", + test_server_wait_handshakes(&rs.events, 1)); + + served_job_t job = {.service = rs.service}; + HANDLE t = test_create_thread(NULL, 0, served_job_fn, &job, 0, NULL); + BOOL finished = WaitForSingleObject(t, 5000) == WAIT_OBJECT_0; + + if (silent != INVALID_HANDLE_VALUE) + CloseHandle(silent); + WaitForSingleObject(t, INFINITE); + CloseHandle(t); + check("legitimate client served within 5s while a silent client is connected", + finished && job.result); + stop_server_within(&rs, 5000); +} + +static void test_silent_client_does_not_block_shutdown(void) +{ + printf("K3: silent client does not block shutdown\n"); + robust_server_t rs = {.service = svc_name("wrob_silent_stop"), .workers = 4}; + if (!start_server(&rs)) { + check("server started", 0); + return; + } + + HANDLE silent = open_pipe_silently(rs.service); + check("a worker waits for the silent client's HELLO", + test_server_wait_handshakes(&rs.events, 1)); + BOOL stopped = stop_server_within(&rs, 3000); + + if (silent != INVALID_HANDLE_VALUE) + CloseHandle(silent); + if (!stopped) + WaitForSingleObject(rs.thread, INFINITE); + check("server stop + destroy finish within 3s while a silent client is connected", stopped); +} + +static void test_silent_client_released_at_handshake_deadline(void) +{ + printf("K3: silent client released at the handshake deadline\n"); + robust_server_t rs = {.service = svc_name("wrob_silent_deadline"), .workers = 1}; + if (!start_server(&rs)) { + check("server started", 0); + return; + } + + HANDLE silent = open_pipe_silently(rs.service); + check("silent client opened the pipe", silent != INVALID_HANDLE_VALUE); + ULONGLONG start = now_ms(); + BOOL released = test_server_wait_handshakes(&rs.events, 1) && + test_server_wait_session_exits(&rs.events, 1); + ULONGLONG elapsed = now_ms() - start; + check("the silent client's worker exits", released); + check("the worker waited for the handshake deadline", + elapsed >= TEST_SERVER_HANDSHAKE_MS); + check("the only worker serves a new client once the silent one misses the deadline", + served_once(rs.service, FALSE)); + + if (silent != INVALID_HANDLE_VALUE) + CloseHandle(silent); + stop_server_within(&rs, 5000); +} + +/* ------------------------------------------------------------------ */ +/* K4: client bounded when the server never sends HELLO_ACK */ +/* ------------------------------------------------------------------ */ + +typedef struct { + const char *service; +} bounded_job_t; + +static DWORD WINAPI bounded_client_fn(LPVOID arg) +{ + bounded_job_t *job = (bounded_job_t *)arg; + nipc_client_config_t ccfg = client_config(FALSE, 1000); + nipc_client_ctx_t client; + nipc_client_init(&client, TEST_RUN_DIR, job->service, &ccfg); + nipc_client_refresh(&client); + nipc_cgroups_resp_view_t view; + (void)nipc_client_call_cgroups_snapshot_timeout(&client, &view, 1000); + nipc_client_close(&client); + return 0; +} + +static void test_client_bounded_when_server_never_sends_hello_ack(void) +{ + printf("K4: client bounded when server never sends HELLO_ACK\n"); + const char *svc = svc_name("wrob_nohelloack"); + wchar_t name[NIPC_NP_MAX_PIPE_NAME]; + nipc_np_build_pipe_name(name, NIPC_NP_MAX_PIPE_NAME, TEST_RUN_DIR, svc); + HANDLE pipe = CreateNamedPipeW(name, PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, + 4, 65536, 65536, 0, NULL); + check("fake server pipe created", pipe != INVALID_HANDLE_VALUE); + + bounded_job_t job = {.service = svc}; + HANDLE t = test_create_thread(NULL, 0, bounded_client_fn, &job, 0, NULL); + BOOL finished = WaitForSingleObject(t, 6000) == WAIT_OBJECT_0; + + if (pipe != INVALID_HANDLE_VALUE) + CloseHandle(pipe); + WaitForSingleObject(t, INFINITE); + CloseHandle(t); + check("refresh + call with 1s timeout return within 6s", finished); +} + +/* A pipe that accepts clients but never answers HELLO. */ +static HANDLE fake_server_pipe(const char *service) +{ + wchar_t name[NIPC_NP_MAX_PIPE_NAME]; + nipc_np_build_pipe_name(name, NIPC_NP_MAX_PIPE_NAME, TEST_RUN_DIR, service); + return CreateNamedPipeW(name, PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, + 4, 65536, 65536, 0, NULL); +} + +typedef struct { + nipc_client_ctx_t *client; + nipc_error_t err; + ULONGLONG elapsed_ms; +} call_job_t; + +static DWORD WINAPI snapshot_call_fn(LPVOID arg) +{ + call_job_t *job = (call_job_t *)arg; + nipc_cgroups_resp_view_t view; + ULONGLONG start = GetTickCount64(); + job->err = nipc_client_call_cgroups_snapshot_timeout(job->client, &view, 1000); + job->elapsed_ms = GetTickCount64() - start; + return 0; +} + +static void test_call_reconnect_bounded_by_call_deadline(void) +{ + printf("K4: a call's reconnect is bounded by the call deadline\n"); + robust_server_t rs = {.service = svc_name("wrob_reconnect_noack"), .workers = 1}; + if (!start_server(&rs)) { + check("server started", 0); + return; + } + nipc_client_config_t ccfg = client_config(FALSE, 1000); + nipc_client_ctx_t client; + nipc_client_init(&client, TEST_RUN_DIR, rs.service, &ccfg); + nipc_client_refresh(&client); + check("client ready", nipc_client_ready(&client)); + check("server stops", stop_server_within(&rs, 5000)); + + /* The call fails on the dead session and reconnects to a server that + * accepts but never sends HELLO_ACK. */ + HANDLE pipe = fake_server_pipe(rs.service); + check("fake server pipe created", pipe != INVALID_HANDLE_VALUE); + + call_job_t job = {.client = &client}; + HANDLE t = test_create_thread(NULL, 0, snapshot_call_fn, &job, 0, NULL); + BOOL finished = WaitForSingleObject(t, 6000) == WAIT_OBJECT_0; + + if (pipe != INVALID_HANDLE_VALUE) + CloseHandle(pipe); + WaitForSingleObject(t, INFINITE); + CloseHandle(t); + printf(" call took %llums\n", (unsigned long long)job.elapsed_ms); + check("call with 1s timeout returns within 6s", finished); + check("call reports the timeout", job.err == NIPC_ERR_TIMEOUT); + nipc_client_close(&client); +} + +static DWORD WINAPI refresh_fn(LPVOID arg) +{ + nipc_client_refresh((nipc_client_ctx_t *)arg); + return 0; +} + +typedef struct { + HANDLE pipe; + BOOL connected; +} connect_pipe_job_t; + +static DWORD WINAPI connect_pipe_fn(LPVOID arg) +{ + connect_pipe_job_t *job = (connect_pipe_job_t *)arg; + job->connected = ConnectNamedPipe(job->pipe, NULL) || + GetLastError() == ERROR_PIPE_CONNECTED; + return 0; +} + +static void test_abort_ends_refresh_waiting_for_hello_ack(void) +{ + printf("K4: abort ends a refresh waiting for HELLO_ACK\n"); + const char *svc = svc_name("wrob_abort_refresh"); + HANDLE pipe = fake_server_pipe(svc); + check("fake server pipe created", pipe != INVALID_HANDLE_VALUE); + + /* Only an abort can end the wait early. */ + nipc_client_config_t ccfg = client_config(FALSE, 10000); + nipc_client_ctx_t client; + nipc_client_init(&client, TEST_RUN_DIR, svc, &ccfg); + + HANDLE t = test_create_thread(NULL, 0, refresh_fn, &client, 0, NULL); + /* A client that never connects must fail the test, not hang it. */ + connect_pipe_job_t connect_job = {.pipe = pipe, .connected = FALSE}; + HANDLE ct = test_create_thread(NULL, 0, connect_pipe_fn, &connect_job, 0, NULL); + BOOL connect_done = ct && WaitForSingleObject(ct, 5000) == WAIT_OBJECT_0; + check("client connected to the fake server", + connect_done && connect_job.connected); + if (!connect_done) { + /* The thread may still be blocked on the pipe: report and stop. */ + printf("FATAL: fake server connect did not complete\n"); + fflush(stdout); + ExitProcess(1); + } + CloseHandle(ct); + nipc_client_abort(&client); + BOOL finished = WaitForSingleObject(t, 2000) == WAIT_OBJECT_0; + + if (pipe != INVALID_HANDLE_VALUE) + CloseHandle(pipe); + WaitForSingleObject(t, INFINITE); + CloseHandle(t); + check("refresh returns within 2s of the abort", finished); + check("client is not ready", !nipc_client_ready(&client)); + nipc_client_close(&client); +} + +/* ------------------------------------------------------------------ */ +/* N2: shutdown with a worker blocked writing to a non-reading client */ +/* ------------------------------------------------------------------ */ + +static void test_shutdown_not_blocked_by_non_reading_client(void) +{ + printf("N2: shutdown not blocked by a client that stopped reading\n"); + const uint32_t big = 8u * 1024u * 1024u; + robust_server_t rs = {.service = svc_name("wrob_noread"), .workers = 2, + .max_payload = big, .opts = {.raw_len = 4u * 1024u * 1024u}}; + if (!start_server(&rs)) { + check("server started", 0); + return; + } + + nipc_np_client_config_t ccfg = { + .supported_profiles = NIPC_PROFILE_BASELINE, + .max_request_payload_bytes = 4096, + .max_request_batch_items = 1, + .max_response_payload_bytes = big, + .max_response_batch_items = 1, + .auth_token = AUTH_TOKEN, + }; + nipc_np_session_t session; + BOOL connected = nipc_np_connect(TEST_RUN_DIR, rs.service, &ccfg, &session) == NIPC_NP_OK; + check("raw client connected", connected); + if (connected) { + uint8_t req[64]; + nipc_cgroups_req_t r = {.layout_version = 1}; + size_t req_len = nipc_cgroups_req_encode(&r, req, sizeof(req)); + nipc_header_t hdr = {.kind = NIPC_KIND_REQUEST, .code = NIPC_METHOD_CGROUPS_SNAPSHOT, + .item_count = 1, .message_id = 1}; + check("request sent", nipc_np_send(&session, &hdr, req, req_len) == NIPC_NP_OK); + } + /* Never read the response. Once the handler built it, the worker can + * only be writing, which blocks. */ + check("handler built the 4 MiB response", test_flag_wait(&rs.opts.handled)); + + BOOL stopped = stop_server_within(&rs, 3000); + if (connected) + nipc_np_close_session(&session); + if (!stopped) + WaitForSingleObject(rs.thread, INFINITE); + check("server stop + destroy finish within 3s while a worker is blocked writing", stopped); +} + +/* ------------------------------------------------------------------ */ +/* N3: stop before run */ +/* ------------------------------------------------------------------ */ + +static DWORD WINAPI run_only_fn(LPVOID arg) +{ + nipc_server_run((nipc_managed_server_t *)arg); + return 0; +} + +static void test_stop_before_run_is_honored(void) +{ + printf("N3: stop before run is honored\n"); + robust_server_t rs = {.service = svc_name("wrob_stop_first"), .workers = 2}; + nipc_np_server_config_t scfg = robust_server_config(&rs); + if (nipc_server_init(&rs.server, TEST_RUN_DIR, rs.service, &scfg, rs.workers, + NIPC_METHOD_CGROUPS_SNAPSHOT, robust_handler, &rs.opts) != NIPC_OK) { + check("server init", 0); + return; + } + + nipc_server_stop(&rs.server); + HANDLE t = test_create_thread(NULL, 0, run_only_fn, &rs.server, 0, NULL); + BOOL returned = WaitForSingleObject(t, 2000) == WAIT_OBJECT_0; + if (!returned) { + nipc_server_stop(&rs.server); + WaitForSingleObject(t, INFINITE); + } + CloseHandle(t); + nipc_server_destroy(&rs.server); + check("run after stop returns promptly", returned); +} + +/* ------------------------------------------------------------------ */ +/* N5: SHM client notices a crashed server */ +/* ------------------------------------------------------------------ */ + +typedef struct { + const char *service; + nipc_error_t err; + ULONGLONG elapsed_ms; + int calling; /* __atomic */ +} dead_server_job_t; + +static DWORD WINAPI dead_server_client_fn(LPVOID arg) +{ + dead_server_job_t *job = (dead_server_job_t *)arg; + nipc_client_config_t ccfg = client_config(TRUE, 10000); + nipc_client_ctx_t client; + nipc_client_init(&client, TEST_RUN_DIR, job->service, &ccfg); + nipc_client_refresh(&client); + nipc_cgroups_resp_view_t view; + test_flag_set(&job->calling); + ULONGLONG start = now_ms(); + job->err = nipc_client_call_cgroups_snapshot_timeout(&client, &view, 10000); + job->elapsed_ms = now_ms() - start; + nipc_client_close(&client); + return 0; +} + +static void test_shm_client_notices_crashed_server(void) +{ + printf("N5: SHM client notices a crashed server before its timeout\n"); + const char *svc = svc_name("wrob_dead_server"); + HANDLE helper_out = NULL; + HANDLE server = spawn_helper("--helper-shm-server", svc, &helper_out); + check("server helper started", server != NULL); + if (!server) + return; + + dead_server_job_t job = {.service = svc}; + HANDLE t = test_create_thread(NULL, 0, dead_server_client_fn, &job, 0, NULL); + check("client connected", test_flag_wait(&job.calling)); + check("request parked in the stalled handler", + helper_wait_line(helper_out, "HELPER_HANDLER_ENTERED")); + kill_helper(server); + CloseHandle(helper_out); + + BOOL finished = WaitForSingleObject(t, 15000) == WAIT_OBJECT_0; + if (!finished) + WaitForSingleObject(t, INFINITE); + CloseHandle(t); + printf(" call returned err=%d after %llums\n", job.err, job.elapsed_ms); + check("call fails within 3s of the server crash (timeout is 10s)", + finished && job.err != NIPC_OK && job.elapsed_ms < 3500); + check("call reports the server as disconnected", + finished && job.err == NIPC_ERR_DISCONNECTED); +} + +/* ------------------------------------------------------------------ */ + +/* ------------------------------------------------------------------ */ +/* N5: SHM attach stops when the server closes the session */ +/* ------------------------------------------------------------------ */ + +/* A server at worker capacity completes the handshake, then closes the + * session and destroys the prepared WinSHM objects. The client must not + * spend its attach retry budget (5 s) on objects that will never appear, + * and must keep WinSHM enabled for its next connect. */ +static void test_shm_attach_stops_when_server_closes_session(void) +{ + printf("N5: SHM attach stops when the server closes the session\n"); + robust_server_t rs = {.service = svc_name("wrob_attach_refused"), .workers = 1, .shm = TRUE}; + if (!start_server(&rs)) { + check("server started", 0); + return; + } + + nipc_client_config_t ccfg = client_config(TRUE, 1000); + nipc_client_ctx_t holder; + nipc_client_init(&holder, TEST_RUN_DIR, rs.service, &ccfg); + nipc_client_refresh(&holder); + check("first client holds the only worker", nipc_client_ready(&holder)); + + nipc_client_ctx_t refused; + nipc_client_init(&refused, TEST_RUN_DIR, rs.service, &ccfg); + ULONGLONG start = now_ms(); + nipc_client_refresh(&refused); + ULONGLONG elapsed = now_ms() - start; + printf(" refused refresh took %llums\n", (unsigned long long)elapsed); + check("refused connect returns within 1s (attach retry budget is 5s)", elapsed < 1000); + + nipc_client_close(&holder); + check("holder's session exited", test_server_wait_session_exits(&rs.events, 1)); + nipc_client_refresh(&refused); + BOOL ready = nipc_client_ready(&refused); + check("refused client connects once the worker is free", ready); + check("refused client still uses WinSHM", ready && refused.shm != NULL); + nipc_client_close(&refused); + check("server stops", stop_server_within(&rs, 5000)); +} + +int main(int argc, char **argv) +{ + if (argc == 3 && strcmp(argv[1], "--helper-shm-client") == 0) + return helper_shm_client(argv[2]); + if (argc == 3 && strcmp(argv[1], "--helper-shm-server") == 0) + return helper_shm_server(argv[2]); + + CreateDirectoryA("C:\\Temp", NULL); + CreateDirectoryA(TEST_RUN_DIR, NULL); + setvbuf(stdout, NULL, _IONBF, 0); + + test_shm_session_released_after_client_crash(); + test_silent_client_does_not_block_other_clients(); + test_silent_client_does_not_block_shutdown(); + test_silent_client_released_at_handshake_deadline(); + test_client_bounded_when_server_never_sends_hello_ack(); + test_call_reconnect_bounded_by_call_deadline(); + test_abort_ends_refresh_waiting_for_hello_ack(); + test_shutdown_not_blocked_by_non_reading_client(); + test_stop_before_run_is_honored(); + test_shm_client_notices_crashed_server(); + test_shm_attach_stops_when_server_closes_session(); + + printf("\n%d passed, %d failed\n", g_pass, g_fail); + fflush(stdout); + /* Exit without joining any thread a failed test may have leaked. + * _exit keeps the exit status visible to the MSYS runtime, which + * ExitProcess bypasses. */ + _exit(g_fail ? 1 : 0); +} diff --git a/tests/fixtures/c/test_win_security.h b/tests/fixtures/c/test_win_security.h new file mode 100644 index 00000000..103b55ff --- /dev/null +++ b/tests/fixtures/c/test_win_security.h @@ -0,0 +1,82 @@ +/* + * test_win_security.h - Assertions on the NetIPC Windows access policy. + */ + +#ifndef TEST_WIN_SECURITY_H +#define TEST_WIN_SECURITY_H + +#include +#include +#include +#include +#include + +static inline bool test_sid_is_process_user(PSID sid) +{ + HANDLE token; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) + return false; + DWORD len = 0; + GetTokenInformation(token, TokenUser, NULL, 0, &len); + TOKEN_USER *user = malloc(len); + bool same = user && GetTokenInformation(token, TokenUser, user, len, &len) && + EqualSid(sid, user->User.Sid); + free(user); + CloseHandle(token); + return same; +} + +/* True when the object's DACL is protected and holds exactly one allow ACE, + * with access mask full_access, for each of SYSTEM, Administrators and the + * process user. */ +static inline bool test_object_dacl_owner_only(HANDLE object, DWORD full_access) +{ + PACL dacl = NULL; + PSECURITY_DESCRIPTOR sd = NULL; + if (GetSecurityInfo(object, SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION, + NULL, NULL, &dacl, NULL, &sd) != ERROR_SUCCESS) + return false; + + SECURITY_DESCRIPTOR_CONTROL control = 0; + DWORD revision = 0; + bool ok = GetSecurityDescriptorControl(sd, &control, &revision) && + (control & SE_DACL_PROTECTED) && dacl && dacl->AceCount == 3; + int system = 0, admins = 0, self = 0; + for (DWORD i = 0; ok && i < dacl->AceCount; i++) { + ACCESS_ALLOWED_ACE *ace = NULL; + if (!GetAce(dacl, i, (void **)&ace) || + ace->Header.AceType != ACCESS_ALLOWED_ACE_TYPE || + ace->Mask != full_access) { + ok = false; + break; + } + PSID sid = (PSID)&ace->SidStart; + if (IsWellKnownSid(sid, WinLocalSystemSid)) + system++; + else if (IsWellKnownSid(sid, WinBuiltinAdministratorsSid)) + admins++; + else if (test_sid_is_process_user(sid)) + self++; + else + ok = false; + } + LocalFree(sd); + return ok && system == 1 && admins == 1 && self == 1; +} + +/* The object's owner as a SID string, LocalFree'd by the caller. */ +static inline char *test_object_owner_sid(HANDLE object) +{ + PSID owner = NULL; + PSECURITY_DESCRIPTOR sd = NULL; + char *str = NULL; + if (GetSecurityInfo(object, SE_KERNEL_OBJECT, OWNER_SECURITY_INFORMATION, + &owner, NULL, NULL, NULL, &sd) != ERROR_SUCCESS) + return NULL; + if (!ConvertSidToStringSidA(owner, &str)) + str = NULL; + LocalFree(sd); + return str; +} + +#endif /* TEST_WIN_SECURITY_H */ diff --git a/tests/fixtures/c/test_win_service.c b/tests/fixtures/c/test_win_service.c index 3b0d88e9..e2193f38 100644 --- a/tests/fixtures/c/test_win_service.c +++ b/tests/fixtures/c/test_win_service.c @@ -17,6 +17,7 @@ #include "netipc/netipc_named_pipe.h" #include "netipc/netipc_protocol.h" #include "netipc/netipc_service.h" +#include "test_sync.h" #include #include @@ -158,24 +159,27 @@ static bool on_snapshot_empty(void *user, return true; } +/* Holds the request until the test, having seen the client give up, + * releases it. */ +static int g_slow_handler_release; + static bool on_snapshot_slow(void *user, const nipc_cgroups_req_t *request, nipc_cgroups_builder_t *builder) { - Sleep(150); + (void)test_flag_wait(&g_slow_handler_release); return on_snapshot(user, request, builder); } -static volatile LONG g_blocking_handler_entered; -static volatile LONG g_blocking_handler_release; +static int g_blocking_handler_entered; +static int g_blocking_handler_release; static bool on_snapshot_blocking(void *user, const nipc_cgroups_req_t *request, nipc_cgroups_builder_t *builder) { - InterlockedExchange(&g_blocking_handler_entered, 1); - while (InterlockedCompareExchange(&g_blocking_handler_release, 0, 0) == 0) - Sleep(1); + test_flag_set(&g_blocking_handler_entered); + (void)test_flag_wait(&g_blocking_handler_release); return on_snapshot(user, request, builder); } @@ -249,6 +253,7 @@ static DWORD WINAPI managed_server_thread(LPVOID arg) } InterlockedExchange(&ctx->init_ok, 1); + test_server_use_test_timings(&ctx->server, NULL); SetEvent(ctx->ready_event); nipc_server_run(&ctx->server); return 0; @@ -268,7 +273,7 @@ static HANDLE start_server_named(server_thread_ctx_t *ctx, ctx->ready_event = CreateEvent(NULL, TRUE, FALSE, NULL); InterlockedExchange(&ctx->init_ok, 0); - HANDLE thread = CreateThread(NULL, 0, managed_server_thread, ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, managed_server_thread, ctx, 0, NULL); check("server thread created", thread != NULL); if (!thread) return NULL; @@ -431,7 +436,7 @@ static HANDLE start_raw_hello_ack_version_server(raw_hello_ack_server_ctx_t *ctx if (!ctx->ready_event) return NULL; - HANDLE thread = CreateThread(NULL, 0, raw_hello_ack_version_server_thread, ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, raw_hello_ack_version_server_thread, ctx, 0, NULL); check("raw hello_ack server thread created", thread != NULL); if (!thread) { CloseHandle(ctx->ready_event); @@ -453,16 +458,11 @@ static HANDLE start_raw_hello_ack_version_server(raw_hello_ack_server_ctx_t *ctx return thread; } -static bool refresh_until_ready(nipc_client_ctx_t *client, int max_tries, DWORD sleep_ms) +/* One refresh, then report whether the client is ready. */ +static bool refresh_ready(nipc_client_ctx_t *client) { - for (int i = 0; i < max_tries; i++) { - nipc_client_refresh(client); - if (nipc_client_ready(client)) - return true; - Sleep(sleep_ms); - } - - return false; + nipc_client_refresh(client); + return nipc_client_ready(client); } static void test_client_lifecycle(void) @@ -511,7 +511,7 @@ static void test_client_lifecycle_ready(void) return; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("client reaches READY", refresh_until_ready(&client, 100, 10)); + check("client reaches READY", refresh_ready(&client)); check("ready state cached", nipc_client_ready(&client)); nipc_client_status_t status; @@ -540,7 +540,7 @@ static void test_snapshot_calls(void) nipc_client_ctx_t client; nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("client ready", refresh_until_ready(&client, 100, 10)); + check("client ready", refresh_ready(&client)); if (nipc_client_ready(&client)) { nipc_cgroups_resp_view_t cg_view; @@ -559,6 +559,36 @@ static void test_snapshot_calls(void) stop_server(&sctx, server_thread); } +/* supported_profiles = 0 is the documented default: baseline. */ +static void test_server_zero_profiles_selects_baseline(void) +{ + printf("--- Server with supported_profiles = 0 serves baseline ---\n"); + + char service[64]; + unique_service(service, sizeof(service), "svc_zero_prof"); + + nipc_server_config_t config = default_server_config(); + config.supported_profiles = 0; + config.preferred_profiles = 0; + server_thread_ctx_t sctx; + HANDLE server_thread = start_server_named(&sctx, service, 1, &config, + &full_service_handler); + if (!server_thread) + return; + + nipc_client_ctx_t client; + nipc_client_config_t ccfg = default_client_config(); + nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); + check("client ready against a zero-profile server", refresh_ready(&client)); + nipc_cgroups_resp_view_t view; + check("call against a zero-profile server succeeds", + nipc_client_ready(&client) && + nipc_client_call_cgroups_snapshot(&client, &view) == NIPC_OK); + + nipc_client_close(&client); + stop_server(&sctx, server_thread); +} + static void test_client_call_timeout_on_wedged_peer(void) { printf("--- Client call timeout on wedged peer ---\n"); @@ -567,6 +597,7 @@ static void test_client_call_timeout_on_wedged_peer(void) unique_service(service, sizeof(service), "svc_timeout"); server_thread_ctx_t sctx; + __atomic_store_n(&g_slow_handler_release, 0, __ATOMIC_RELAXED); HANDLE server_thread = start_default_server_named(&sctx, service, 4, &slow_snapshot_service_handler); if (!server_thread) @@ -575,7 +606,7 @@ static void test_client_call_timeout_on_wedged_peer(void) nipc_client_ctx_t client; nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("timeout client ready", refresh_until_ready(&client, 100, 10)); + check("timeout client ready", refresh_ready(&client)); if (nipc_client_ready(&client)) { nipc_cgroups_resp_view_t view; @@ -587,11 +618,150 @@ static void test_client_call_timeout_on_wedged_peer(void) check("wedged peer returns timeout", err == NIPC_ERR_TIMEOUT); check("timeout returns promptly", elapsed < 1000); } + test_flag_set(&g_slow_handler_release); nipc_client_close(&client); stop_server(&sctx, server_thread); } +/* A server that completes the handshake and never reads, so a request + * larger than the pipe buffers blocks the client's write. */ +#define STALLED_READER_REQUEST_BYTES (8u * 1024u * 1024u) +#define STALLED_READER_PIDS (1024u * 1024u) + +typedef struct { + const char *service; + int ready; + int reading_started; + int request_seen; + int release; +} stalled_reader_ctx_t; + +static DWORD WINAPI stalled_reader_server_thread(LPVOID arg) +{ + stalled_reader_ctx_t *ctx = (stalled_reader_ctx_t *)arg; + nipc_np_listener_t listener; + nipc_np_session_t session; + nipc_np_server_config_t scfg = { + .supported_profiles = NIPC_PROFILE_BASELINE, + .max_request_payload_bytes = STALLED_READER_REQUEST_BYTES, + .max_request_batch_items = 16, + .max_response_payload_bytes = RESPONSE_BUF_SIZE, + .max_response_batch_items = 16, + .auth_token = AUTH_TOKEN, + }; + + memset(&listener, 0, sizeof(listener)); + memset(&session, 0, sizeof(session)); + if (nipc_np_listen(TEST_RUN_DIR, ctx->service, &scfg, &listener) != + NIPC_NP_OK) { + test_flag_set(&ctx->ready); + test_flag_set(&ctx->reading_started); + return 0; + } + test_flag_set(&ctx->ready); + + if (nipc_np_accept(&listener, 1, &session) == NIPC_NP_OK) { + /* The request's first bytes arrived: the client is in its write. */ + bool readable = false; + if (nipc_np_wait_readable(&session, 10000, &readable) == NIPC_NP_OK && + readable) + InterlockedExchange((volatile LONG *)&ctx->request_seen, 1); + test_flag_set(&ctx->reading_started); + (void)test_flag_wait(&ctx->release); + nipc_np_close_session(&session); + } else { + test_flag_set(&ctx->reading_started); + } + nipc_np_close_listener(&listener); + return 0; +} + +typedef struct { + nipc_client_ctx_t *client; + const uint32_t *pids; + uint32_t timeout_ms; + nipc_error_t err; +} stalled_send_call_ctx_t; + +static DWORD WINAPI stalled_send_call_thread(LPVOID arg) +{ + stalled_send_call_ctx_t *ctx = (stalled_send_call_ctx_t *)arg; + nipc_apps_lookup_resp_view_t view; + ctx->err = nipc_client_call_apps_lookup_timeout( + ctx->client, ctx->pids, STALLED_READER_PIDS, &view, ctx->timeout_ms); + return 0; +} + +/* A request the server never reads ends at the call deadline, or on abort, + * instead of blocking in the write. */ +static void test_client_send_bounded_by_deadline_and_abort(void) +{ + printf("--- Client send bounded by deadline and abort ---\n"); + + uint32_t *pids = calloc(STALLED_READER_PIDS, sizeof(*pids)); + check("stalled send pids allocated", pids != NULL); + if (!pids) + return; + for (uint32_t i = 0; i < STALLED_READER_PIDS; i++) + pids[i] = i + 1; + + for (int use_abort = 0; use_abort <= 1; use_abort++) { + char service[64]; + unique_service(service, sizeof(service), + use_abort ? "svc_send_abort" : "svc_send_deadline"); + + stalled_reader_ctx_t sctx = {.service = service}; + HANDLE server_thread = test_create_thread( + NULL, 0, stalled_reader_server_thread, &sctx, 0, NULL); + check("stalled reader thread created", server_thread != NULL); + if (!server_thread) + break; + check("stalled reader listening", test_flag_wait(&sctx.ready)); + + nipc_client_ctx_t client; + nipc_client_config_t ccfg = default_client_config(); + ccfg.max_request_payload_bytes = STALLED_READER_REQUEST_BYTES; + ccfg.max_logical_lookup_items = STALLED_READER_PIDS; + nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); + check("stalled send client ready", refresh_ready(&client)); + + stalled_send_call_ctx_t cctx = { + .client = &client, + .pids = pids, + .timeout_ms = use_abort ? 30000u : 200u, + }; + ULONGLONG start = GetTickCount64(); + HANDLE call_thread = test_create_thread( + NULL, 0, stalled_send_call_thread, &cctx, 0, NULL); + check("stalled send call thread created", call_thread != NULL); + if (call_thread) { + if (use_abort) { + check("server saw the request start", + test_flag_wait(&sctx.reading_started) && + InterlockedCompareExchange( + (volatile LONG *)&sctx.request_seen, 0, 0)); + nipc_client_abort(&client); + } + WaitForSingleObject(call_thread, INFINITE); + CloseHandle(call_thread); + } + ULONGLONG elapsed = GetTickCount64() - start; + test_flag_set(&sctx.release); + WaitForSingleObject(server_thread, INFINITE); + CloseHandle(server_thread); + + if (use_abort) + check("blocked send returns aborted", cctx.err == NIPC_ERR_ABORTED); + else + check("blocked send returns timeout", cctx.err == NIPC_ERR_TIMEOUT); + check("blocked send ends promptly", elapsed < 5000); + + nipc_client_close(&client); + } + free(pids); +} + static void test_client_abort_unblocks_call(void) { printf("--- Client abort unblocks in-flight call ---\n"); @@ -599,8 +769,8 @@ static void test_client_abort_unblocks_call(void) char service[64]; unique_service(service, sizeof(service), "svc_abort"); - InterlockedExchange(&g_blocking_handler_entered, 0); - InterlockedExchange(&g_blocking_handler_release, 0); + __atomic_store_n(&g_blocking_handler_entered, 0, __ATOMIC_RELAXED); + __atomic_store_n(&g_blocking_handler_release, 0, __ATOMIC_RELAXED); server_thread_ctx_t sctx; HANDLE server_thread = start_default_server_named( @@ -611,23 +781,18 @@ static void test_client_abort_unblocks_call(void) nipc_client_ctx_t client; nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("abort client ready", refresh_until_ready(&client, 100, 10)); + check("abort client ready", refresh_ready(&client)); snapshot_call_thread_ctx_t call_ctx; memset(&call_ctx, 0, sizeof(call_ctx)); call_ctx.client = &client; - HANDLE call_thread = CreateThread(NULL, 0, snapshot_call_thread_fn, + HANDLE call_thread = test_create_thread(NULL, 0, snapshot_call_thread_fn, &call_ctx, 0, NULL); check("snapshot call thread created", call_thread != NULL); if (call_thread) { - for (int i = 0; i < 2000 - && InterlockedCompareExchange(&g_blocking_handler_entered, - 0, 0) == 0; i++) - Sleep(1); check("blocking handler received request", - InterlockedCompareExchange(&g_blocking_handler_entered, - 0, 0) == 1); + test_flag_wait(&g_blocking_handler_entered)); ULONGLONG start = GetTickCount64(); nipc_client_abort(&client); @@ -640,7 +805,7 @@ static void test_client_abort_unblocks_call(void) CloseHandle(call_thread); } - InterlockedExchange(&g_blocking_handler_release, 1); + test_flag_set(&g_blocking_handler_release); nipc_client_close(&client); stop_server(&sctx, server_thread); } @@ -660,7 +825,7 @@ static void test_retry_on_broken_session(void) nipc_client_ctx_t client; nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("client ready", refresh_until_ready(&client, 100, 10)); + check("client ready", refresh_ready(&client)); if (nipc_client_ready(&client)) { nipc_cgroups_resp_view_t view; @@ -700,7 +865,7 @@ static void test_handler_failure(void) nipc_client_ctx_t client; nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("client ready", refresh_until_ready(&client, 100, 10)); + check("client ready", refresh_ready(&client)); if (nipc_client_ready(&client)) { nipc_cgroups_resp_view_t view; @@ -739,6 +904,31 @@ static void test_client_auth_failure(void) stop_server(&sctx, server_thread); } +static void test_client_untrusted_server(void) +{ + printf("--- Client untrusted server mapping ---\n"); + + char service[64]; + unique_service(service, sizeof(service), "svc_untrusted"); + + server_thread_ctx_t sctx; + HANDLE server_thread = start_default_server_named(&sctx, service, 4, &full_service_handler); + if (!server_thread) + return; + + /* BUILTIN\Users never owns the test pipe. */ + nipc_np_set_trusted_owner_for_tests("S-1-5-32-545"); + nipc_client_ctx_t client; + nipc_client_config_t ccfg = default_client_config(); + nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); + nipc_client_refresh(&client); + nipc_np_set_trusted_owner_for_tests(NULL); + check("untrusted server state is AUTH_FAILED", client.state == NIPC_CLIENT_AUTH_FAILED); + + nipc_client_close(&client); + stop_server(&sctx, server_thread); +} + static void test_client_incompatible(void) { printf("--- Client incompatible profile mapping ---\n"); @@ -816,7 +1006,7 @@ static void test_status_reporting(void) nipc_client_ctx_t client; nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("client ready", refresh_until_ready(&client, 100, 10)); + check("client ready", refresh_ready(&client)); nipc_client_status_t s0; nipc_client_status(&client, &s0); @@ -987,8 +1177,6 @@ static void test_non_request_terminates_session(void) bad.transport_status = NIPC_STATUS_OK; check("send non-request ok", nipc_np_send(&session, &bad, NULL, 0) == NIPC_NP_OK); - Sleep(200); - nipc_header_t good = {0}; good.kind = NIPC_KIND_REQUEST; good.code = NIPC_METHOD_CGROUPS_SNAPSHOT; @@ -1015,7 +1203,7 @@ static void test_non_request_terminates_session(void) nipc_client_ctx_t verify_client; nipc_client_config_t verify_cfg = default_client_config(); nipc_client_init(&verify_client, TEST_RUN_DIR, service, &verify_cfg); - check("verify client ready", refresh_until_ready(&verify_client, 100, 10)); + check("verify client ready", refresh_ready(&verify_client)); if (nipc_client_ready(&verify_client)) { nipc_cgroups_resp_view_t view; nipc_error_t err = nipc_client_call_cgroups_snapshot(&verify_client, &view); @@ -1035,13 +1223,16 @@ int main(void) test_client_lifecycle(); test_client_lifecycle_ready(); test_snapshot_calls(); + test_server_zero_profiles_selects_baseline(); test_client_call_timeout_on_wedged_peer(); + test_client_send_bounded_by_deadline_and_abort(); test_client_abort_unblocks_call(); /* Broken-session retry and cache subcases need a smaller Windows-only * harness. In this monolithic executable they deadlock intermittently * and poison the full ctest pass. */ test_handler_failure(); test_client_auth_failure(); + test_client_untrusted_server(); test_client_incompatible(); test_client_protocol_version_incompatible(); test_status_reporting(); diff --git a/tests/fixtures/c/test_win_service_extra.c b/tests/fixtures/c/test_win_service_extra.c index 9d8333eb..b911a5f9 100644 --- a/tests/fixtures/c/test_win_service_extra.c +++ b/tests/fixtures/c/test_win_service_extra.c @@ -7,17 +7,28 @@ #if defined(_WIN32) || defined(__MSYS__) +#ifdef __MSYS__ +/* pthread_getattr_np */ +#define _GNU_SOURCE +#endif + #include "netipc/netipc_named_pipe.h" #include "netipc/netipc_protocol.h" #include "netipc/netipc_service.h" #include "netipc/netipc_win_shm.h" +#include "test_sync.h" #include "netipc_service_common.h" +#include "netipc_service_win_internal.h" #include #include #include #include +#ifdef __MSYS__ +#include +#endif + static int g_pass = 0; static int g_fail = 0; static volatile LONG g_service_counter = 0; @@ -81,7 +92,7 @@ typedef struct { int calls; int disconnects; int reconnects; - int sleeps; + bool expire_deadline; /* first attempt uses up the call deadline */ bool reconnect_ok; uint32_t reconnect_request_bytes; uint32_t reconnect_response_bytes; @@ -91,9 +102,10 @@ static common_retry_mock_t *g_common_retry_mock; static nipc_error_t common_retry_attempt(nipc_client_ctx_t *ctx, void *state) { - (void)ctx; common_retry_mock_t *mock = (common_retry_mock_t *)state; mock->calls++; + if (mock->expire_deadline) + ctx->op_deadline_ms = 0; if (mock->calls <= mock->result_count) return mock->results[mock->calls - 1]; return NIPC_OK; @@ -128,22 +140,12 @@ static bool common_retry_reconnect_for_call(nipc_client_ctx_t *ctx) return false; } -static void common_retry_sleep(uint32_t ms) -{ - (void)ms; - if (g_common_retry_mock) - g_common_retry_mock->sleeps++; -} - static nipc_service_common_client_ops_t common_retry_ops(void) { return (nipc_service_common_client_ops_t){ .disconnect = common_retry_disconnect, .try_connect = common_retry_try_connect, .reconnect_for_call = common_retry_reconnect_for_call, - .sleep_ms = common_retry_sleep, - .reconnect_drain_ms = 1, - .reconnect_retry_interval_ms = 1, }; } @@ -314,6 +316,7 @@ typedef struct { HANDLE ready_event; volatile LONG init_ok; nipc_managed_server_t server; + test_server_events_t events; } server_thread_ctx_t; static DWORD WINAPI managed_server_thread(LPVOID arg) @@ -331,6 +334,7 @@ static DWORD WINAPI managed_server_thread(LPVOID arg) } InterlockedExchange(&ctx->init_ok, 1); + test_server_use_test_timings(&ctx->server, &ctx->events); SetEvent(ctx->ready_event); nipc_server_run(&ctx->server); return 0; @@ -350,7 +354,7 @@ static HANDLE start_server_named(server_thread_ctx_t *ctx, ctx->ready_event = CreateEvent(NULL, TRUE, FALSE, NULL); InterlockedExchange(&ctx->init_ok, 0); - HANDLE thread = CreateThread(NULL, 0, managed_server_thread, ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, managed_server_thread, ctx, 0, NULL); check("server thread created", thread != NULL); if (!thread) return NULL; @@ -421,6 +425,7 @@ static DWORD WINAPI lookup_server_thread(LPVOID arg) InterlockedExchange(&ctx->init_ok, 1); SetEvent(ctx->ready_event); + test_server_use_test_timings(&ctx->server, NULL); nipc_server_run(&ctx->server); return 0; } @@ -444,7 +449,7 @@ static HANDLE start_lookup_server_named( ctx->ready_event = CreateEvent(NULL, TRUE, FALSE, NULL); InterlockedExchange(&ctx->init_ok, 0); - HANDLE thread = CreateThread(NULL, 0, lookup_server_thread, ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, lookup_server_thread, ctx, 0, NULL); check("lookup server thread created", thread != NULL); if (!thread) return NULL; @@ -482,30 +487,18 @@ static void stop_lookup_server_drain(lookup_server_thread_ctx_t *ctx, HANDLE thr check("lookup server drain completed", nipc_server_drain(&ctx->server, 10000)); } -static bool refresh_until_ready(nipc_client_ctx_t *client, int max_tries, DWORD sleep_ms) +/* One refresh, then report whether the client is ready. */ +static bool refresh_ready(nipc_client_ctx_t *client) { - for (int i = 0; i < max_tries; i++) { - nipc_client_refresh(client); - if (nipc_client_ready(client)) - return true; - Sleep(sleep_ms); - } - - return false; + nipc_client_refresh(client); + return nipc_client_ready(client); } -static bool refresh_until_state(nipc_client_ctx_t *client, - nipc_client_state_t target_state, - int max_tries, - DWORD sleep_ms) +/* One refresh, then report whether the client reached target_state. */ +static bool refresh_to_state(nipc_client_ctx_t *client, + nipc_client_state_t target_state) { - for (int i = 0; i < max_tries; i++) { - nipc_client_refresh(client); - if (client->state == target_state) - return true; - Sleep(sleep_ms); - } - + nipc_client_refresh(client); return client->state == target_state; } @@ -666,7 +659,7 @@ static void test_client_response_buffer_minimum(void) ccfg.max_response_payload_bytes = 1; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("small response client reaches READY", refresh_until_ready(&client, 100, 10)); + check("small response client reaches READY", refresh_ready(&client)); check("small response client buffer rounded up to minimum", client.response_buf_size >= NIPC_HEADER_LEN + 1024u); @@ -750,7 +743,8 @@ static bool apps_lookup_test_handler(void *user, typedef struct { volatile LONG calls; volatile LONG max_items_seen; - volatile LONG second_call_entered; + int second_call_entered; /* __atomic */ + int release_second_call; /* __atomic */ int mixed_generation; int slow_second_call; int signal_second_call; @@ -775,9 +769,9 @@ static void lookup_scale_after_call(lookup_scale_state_t *state, LONG call) return; if (state->signal_second_call) - InterlockedExchange(&state->second_call_entered, 1); + test_flag_set(&state->second_call_entered); if (state->slow_second_call) - Sleep(250); + (void)test_flag_wait(&state->release_second_call); } typedef struct { @@ -812,13 +806,7 @@ static DWORD WINAPI lookup_call_thread(LPVOID arg) static int lookup_scale_wait_for_second_call(lookup_scale_state_t *state) { - for (int i = 0; i < 5000; i++) { - if (InterlockedCompareExchange(&state->second_call_entered, 0, 0) != 0) - return 1; - Sleep(1); - } - - return 0; + return test_flag_wait(&state->second_call_entered) ? 1 : 0; } static bool cgroups_lookup_scale_handler(void *user, @@ -1702,7 +1690,7 @@ static HANDLE start_raw_lookup_partial_disconnect( return NULL; HANDLE thread = - CreateThread(NULL, 0, raw_lookup_partial_disconnect_thread, ctx, 0, + test_create_thread(NULL, 0, raw_lookup_partial_disconnect_thread, ctx, 0, NULL); check("partial-disconnect raw server thread created", thread != NULL); if (!thread) { @@ -1740,7 +1728,7 @@ static void test_lookup_typed_calls(void) nipc_client_ctx_t client; nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("cgroups lookup client ready", refresh_until_ready(&client, 100, 10)); + check("cgroups lookup client ready", refresh_ready(&client)); nipc_str_view_t paths[] = { { .ptr = "/known", .len = 6 }, @@ -1806,7 +1794,7 @@ static void test_lookup_typed_calls(void) nipc_client_ctx_t client; nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("apps lookup client ready", refresh_until_ready(&client, 100, 10)); + check("apps lookup client ready", refresh_ready(&client)); uint32_t pids[] = {1234, 0, 9999}; nipc_apps_lookup_resp_view_t view; @@ -1882,7 +1870,7 @@ static void test_lookup_zero_item_calls(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("zero cgroups lookup client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_cgroups_lookup_resp_view_t view; nipc_error_t err = @@ -1914,7 +1902,7 @@ static void test_lookup_zero_item_calls(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("zero apps lookup client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_apps_lookup_resp_view_t view; nipc_error_t err = nipc_client_call_apps_lookup(&client, NULL, 0, &view); @@ -1951,7 +1939,7 @@ static void test_lookup_payload_exceeded_retry(void) nipc_client_ctx_t client; nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("cgroups scale lookup client ready", refresh_until_ready(&client, 100, 10)); + check("cgroups scale lookup client ready", refresh_ready(&client)); nipc_str_view_t paths[] = { { .ptr = "/a", .len = 2 }, @@ -2018,7 +2006,7 @@ static void test_lookup_payload_exceeded_retry(void) nipc_client_ctx_t client; nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("apps scale lookup client ready", refresh_until_ready(&client, 100, 10)); + check("apps scale lookup client ready", refresh_ready(&client)); uint32_t pids[] = {11, 22, 44, 33}; nipc_apps_lookup_resp_view_t view; @@ -2090,7 +2078,7 @@ static void run_cgroups_lookup_request_boundary_case( nipc_client_config_t ccfg = default_client_config(); ccfg.max_request_payload_bytes = request_cap; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("cgroups split lookup client ready", refresh_until_ready(&client, 100, 10)); + check("cgroups split lookup client ready", refresh_ready(&client)); nipc_str_view_t paths[] = { { .ptr = "/bbbbbb", .len = 7 }, @@ -2152,7 +2140,7 @@ static void run_apps_lookup_request_boundary_case( nipc_client_config_t ccfg = default_client_config(); ccfg.max_request_payload_bytes = request_cap; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("apps split lookup client ready", refresh_until_ready(&client, 100, 10)); + check("apps split lookup client ready", refresh_ready(&client)); uint32_t pids[] = {4, 1, 4, 7, 1, 9, 7}; nipc_apps_lookup_resp_view_t view; @@ -2235,7 +2223,7 @@ static void test_apps_lookup_large_logical_case(const char *prefix, nipc_client_config_t ccfg = default_client_config(); ccfg.max_request_payload_bytes = LOOKUP_SCALE_REQUEST_PAYLOAD_BYTES; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("apps large client ready", refresh_until_ready(&client, 100, 10)); + check("apps large client ready", refresh_ready(&client)); nipc_apps_lookup_resp_view_t view; nipc_error_t err = @@ -2307,7 +2295,7 @@ static void test_cgroups_lookup_large_logical_case(const char *prefix, nipc_client_config_t ccfg = default_client_config(); ccfg.max_request_payload_bytes = LOOKUP_SCALE_REQUEST_PAYLOAD_BYTES; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("cgroups large client ready", refresh_until_ready(&client, 100, 10)); + check("cgroups large client ready", refresh_ready(&client)); nipc_cgroups_lookup_resp_view_t view; nipc_error_t err = @@ -2389,7 +2377,7 @@ static void test_apps_lookup_large_response_split_case(const char *prefix) LOOKUP_RESPONSE_SPLIT_RESPONSE_PAYLOAD_BYTES; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("apps response-split client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_apps_lookup_resp_view_t view; nipc_error_t err = nipc_client_call_apps_lookup( @@ -2473,7 +2461,7 @@ static void test_cgroups_lookup_large_response_split_case(const char *prefix) LOOKUP_RESPONSE_SPLIT_RESPONSE_PAYLOAD_BYTES; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("cgroups response-split client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_cgroups_lookup_resp_view_t view; nipc_error_t err = nipc_client_call_cgroups_lookup( @@ -2543,7 +2531,7 @@ static void test_cgroups_lookup_oversized_request_key(void) ccfg.max_request_payload_bytes = 48; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("cgroups oversized request-key client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_str_view_t paths[] = { { .ptr = "/request-key-too-large-for-configured-cap", .len = 41 }, @@ -2601,7 +2589,7 @@ static void test_lookup_logical_limits(void) nipc_client_config_t ccfg = default_client_config(); ccfg.max_logical_lookup_items = 2; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("logical limit lookup client ready", refresh_until_ready(&client, 100, 10)); + check("logical limit lookup client ready", refresh_ready(&client)); uint32_t pids[] = {1, 2, 3}; nipc_apps_lookup_resp_view_t view; @@ -2651,7 +2639,7 @@ static void test_lookup_logical_limits(void) response_cfg.max_logical_lookup_response_bytes = 1; nipc_client_init(&client, TEST_RUN_DIR, service, &response_cfg); check("apps logical response-limit client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); uint32_t response_pid = 1234; err = nipc_client_call_apps_lookup(&client, &response_pid, 1, &view); check("apps logical response limit rejected", err == NIPC_ERR_OVERFLOW); @@ -2675,7 +2663,7 @@ static void test_lookup_logical_limits(void) response_cfg.max_logical_lookup_response_bytes = 1; nipc_client_init(&client, TEST_RUN_DIR, service, &response_cfg); check("cgroups logical response-limit client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_str_view_t response_path = { .ptr = "/ok", .len = 3 }; err = nipc_client_call_cgroups_lookup(&client, &response_path, 1, &cgroups_view); @@ -2699,7 +2687,7 @@ static void test_lookup_logical_limits(void) subcall_cfg.max_logical_lookup_subcalls = 1; nipc_client_init(&client, TEST_RUN_DIR, service, &subcall_cfg); check("cgroups logical subcall-limit client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_str_view_t subcall_paths[] = { { .ptr = "/a", .len = 2 }, { .ptr = "/huge", .len = 5 }, @@ -2732,7 +2720,7 @@ static void test_lookup_logical_limits(void) subcall_cfg.max_logical_lookup_subcalls = 1; nipc_client_init(&client, TEST_RUN_DIR, service, &subcall_cfg); check("apps logical subcall-limit client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); uint32_t subcall_pids[] = {11, 22, 33}; err = nipc_client_call_apps_lookup(&client, subcall_pids, 3, &view); check("apps logical subcall limit rejected", err == NIPC_ERR_OVERFLOW); @@ -2765,7 +2753,7 @@ static void test_lookup_rejects_no_progress_payload_exceeded(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("cgroups no-progress client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_str_view_t paths[] = { { .ptr = "/a", .len = 2 }, @@ -2800,7 +2788,7 @@ static void test_lookup_rejects_no_progress_payload_exceeded(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("apps no-progress client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); uint32_t pids[] = {10, 11}; nipc_apps_lookup_resp_view_t view; @@ -2839,7 +2827,7 @@ static void run_cgroups_lookup_bad_response_case( nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("cgroups malformed first-response client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_str_view_t paths[] = { { .ptr = "/a", .len = 2 }, @@ -2880,7 +2868,7 @@ static void run_cgroups_lookup_bad_suffix_case( nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("cgroups malformed payload suffix client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_str_view_t paths[] = { { .ptr = "/a", .len = 2 }, @@ -2923,7 +2911,7 @@ static void run_apps_lookup_bad_response_case( nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("apps malformed first-response client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); uint32_t pids[] = {77, 78}; nipc_apps_lookup_resp_view_t view; @@ -3009,7 +2997,7 @@ static void test_lookup_rejects_mixed_generation_retry(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("cgroups mixed-generation client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_str_view_t paths[] = { { .ptr = "/a", .len = 2 }, @@ -3046,7 +3034,7 @@ static void test_lookup_rejects_mixed_generation_retry(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("apps mixed-generation client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); uint32_t pids[] = {11, 22, 33}; nipc_apps_lookup_resp_view_t view; @@ -3083,7 +3071,7 @@ static void test_lookup_rejects_malformed_followup_response(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("cgroups bad-followup client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_str_view_t paths[] = { { .ptr = "/a", .len = 2 }, @@ -3121,7 +3109,7 @@ static void test_lookup_rejects_malformed_followup_response(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("apps bad-followup client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); uint32_t pids[] = {11, 22, 33}; nipc_apps_lookup_resp_view_t view; @@ -3158,7 +3146,7 @@ static void test_lookup_endpoint_gone_after_partial_progress(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("apps partial-disconnect client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); uint32_t pids[] = { 11, 22, 33 }; nipc_apps_lookup_resp_view_t view; @@ -3193,7 +3181,7 @@ static void test_lookup_endpoint_gone_after_partial_progress(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("cgroups partial-disconnect client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_str_view_t paths[] = { { .ptr = "/a", .len = 2 }, @@ -3238,7 +3226,7 @@ static void test_lookup_endpoint_gone_before_first_subcall(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("apps before-call client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); stop_lookup_server_drain(&sctx, server_thread); @@ -3273,7 +3261,7 @@ static void test_lookup_endpoint_gone_before_first_subcall(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("cgroups before-call client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); stop_lookup_server_drain(&sctx, server_thread); @@ -3372,7 +3360,7 @@ static void test_lookup_timeout_during_followup_subcall(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("cgroups follow-up timeout client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_str_view_t paths[] = { { .ptr = "/a", .len = 2 }, @@ -3382,8 +3370,9 @@ static void test_lookup_timeout_during_followup_subcall(void) nipc_cgroups_lookup_resp_view_t view; nipc_error_t err = nipc_client_call_cgroups_lookup_timeout(&client, paths, 3, &view, - 75); + 200); check("cgroups follow-up timeout rejected", err == NIPC_ERR_TIMEOUT); + test_flag_set(&state.release_second_call); check("cgroups follow-up timeout reached second subcall", state.calls >= 2); @@ -3413,13 +3402,14 @@ static void test_lookup_timeout_during_followup_subcall(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("apps follow-up timeout client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); uint32_t pids[] = { 11, 22, 33 }; nipc_apps_lookup_resp_view_t view; nipc_error_t err = - nipc_client_call_apps_lookup_timeout(&client, pids, 3, &view, 75); + nipc_client_call_apps_lookup_timeout(&client, pids, 3, &view, 200); check("apps follow-up timeout rejected", err == NIPC_ERR_TIMEOUT); + test_flag_set(&state.release_second_call); check("apps follow-up timeout reached second subcall", state.calls >= 2); @@ -3457,7 +3447,7 @@ static void test_lookup_abort_during_followup_subcall(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("cgroups follow-up abort client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_str_view_t paths[] = { { .ptr = "/a", .len = 2 }, @@ -3473,7 +3463,7 @@ static void test_lookup_abort_during_followup_subcall(void) call_ctx.timeout_ms = 5000; HANDLE call_thread = - CreateThread(NULL, 0, lookup_call_thread, &call_ctx, 0, NULL); + test_create_thread(NULL, 0, lookup_call_thread, &call_ctx, 0, NULL); check("cgroups follow-up abort call thread created", call_thread != NULL); if (call_thread) { @@ -3482,6 +3472,7 @@ static void test_lookup_abort_during_followup_subcall(void) nipc_client_abort(&client); check("cgroups follow-up abort call thread exited", WaitForSingleObject(call_thread, 10000) == WAIT_OBJECT_0); + test_flag_set(&state.release_second_call); CloseHandle(call_thread); check("cgroups follow-up abort rejected", call_ctx.err == NIPC_ERR_ABORTED); @@ -3518,7 +3509,7 @@ static void test_lookup_abort_during_followup_subcall(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("apps follow-up abort client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); uint32_t pids[] = { 11, 22, 33 }; lookup_call_thread_ctx_t call_ctx; @@ -3530,7 +3521,7 @@ static void test_lookup_abort_during_followup_subcall(void) call_ctx.timeout_ms = 5000; HANDLE call_thread = - CreateThread(NULL, 0, lookup_call_thread, &call_ctx, 0, NULL); + test_create_thread(NULL, 0, lookup_call_thread, &call_ctx, 0, NULL); check("apps follow-up abort call thread created", call_thread != NULL); if (call_thread) { check("apps follow-up abort reached second subcall", @@ -3538,6 +3529,7 @@ static void test_lookup_abort_during_followup_subcall(void) nipc_client_abort(&client); check("apps follow-up abort call thread exited", WaitForSingleObject(call_thread, 10000) == WAIT_OBJECT_0); + test_flag_set(&state.release_second_call); CloseHandle(call_thread); check("apps follow-up abort rejected", call_ctx.err == NIPC_ERR_ABORTED); @@ -3665,7 +3657,7 @@ static void test_lookup_init_and_client_guards(void) nipc_client_ctx_t client; nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("bad cgroups lookup client ready", refresh_until_ready(&client, 100, 10)); + check("bad cgroups lookup client ready", refresh_ready(&client)); nipc_cgroups_lookup_resp_view_t view; nipc_error_t err = nipc_client_call_cgroups_lookup(&client, NULL, 1, &view); @@ -3673,7 +3665,7 @@ static void test_lookup_init_and_client_guards(void) nipc_client_close(&client); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("second bad cgroups lookup client ready", refresh_until_ready(&client, 100, 10)); + check("second bad cgroups lookup client ready", refresh_ready(&client)); nipc_str_view_t bad_paths[] = { { .ptr = NULL, .len = 1 }, @@ -3683,7 +3675,7 @@ static void test_lookup_init_and_client_guards(void) nipc_client_close(&client); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("faulted cgroups lookup client ready", refresh_until_ready(&client, 100, 10)); + check("faulted cgroups lookup client ready", refresh_ready(&client)); nipc_str_view_t valid_paths[] = { { .ptr = "/known", .len = 6 }, @@ -3715,7 +3707,7 @@ static void test_lookup_init_and_client_guards(void) nipc_client_ctx_t client; nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("bad apps lookup client ready", refresh_until_ready(&client, 100, 10)); + check("bad apps lookup client ready", refresh_ready(&client)); nipc_apps_lookup_resp_view_t view; uint32_t pids[] = {1234}; @@ -3727,7 +3719,7 @@ static void test_lookup_init_and_client_guards(void) nipc_client_close(&client); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("second bad apps lookup client ready", refresh_until_ready(&client, 100, 10)); + check("second bad apps lookup client ready", refresh_ready(&client)); err = nipc_client_call_apps_lookup(&client, NULL, 1, &view); check("bad apps lookup request rejected", err == NIPC_ERR_BAD_LAYOUT); @@ -3757,12 +3749,12 @@ static void test_client_fault_injection_disconnects_and_recovers(void) nipc_win_service_test_fault_set( NIPC_WIN_SERVICE_TEST_FAULT_CLIENT_RESPONSE_BUF_REALLOC, 0); check("response buffer alloc fault disconnects client", - refresh_until_state(&client, NIPC_CLIENT_DISCONNECTED, 20, 10)); + refresh_to_state(&client, NIPC_CLIENT_DISCONNECTED)); check("response buffer alloc fault leaves client not ready", !nipc_client_ready(&client)); clear_test_faults(); check("response buffer alloc fault recovers", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_client_close(&client); stop_server_drain(&sctx, server_thread); @@ -3785,10 +3777,10 @@ static void test_client_fault_injection_disconnects_and_recovers(void) nipc_win_service_test_fault_set( NIPC_WIN_SERVICE_TEST_FAULT_CLIENT_SEND_BUF_REALLOC, 0); check("send buffer alloc fault disconnects hybrid client", - refresh_until_state(&client, NIPC_CLIENT_DISCONNECTED, 20, 10)); + refresh_to_state(&client, NIPC_CLIENT_DISCONNECTED)); clear_test_faults(); check("send buffer alloc fault recovers", - refresh_until_ready(&client, 200, 10) && client.shm != NULL); + refresh_ready(&client) && client.shm != NULL); nipc_client_close(&client); stop_server_drain(&sctx, server_thread); @@ -3811,18 +3803,85 @@ static void test_client_fault_injection_disconnects_and_recovers(void) nipc_win_service_test_fault_set( NIPC_WIN_SERVICE_TEST_FAULT_CLIENT_SHM_CTX_CALLOC, 0); check("client SHM ctx alloc fault disconnects hybrid client", - refresh_until_state(&client, NIPC_CLIENT_DISCONNECTED, 20, 10)); + refresh_to_state(&client, NIPC_CLIENT_DISCONNECTED)); check("client SHM ctx alloc fault leaves session invalid", !client.session_valid); clear_test_faults(); check("client SHM ctx alloc fault recovers", - refresh_until_ready(&client, 200, 10) && client.shm != NULL); + refresh_ready(&client) && client.shm != NULL); nipc_client_close(&client); stop_server_drain(&sctx, server_thread); } } +static int shm_regions_created(server_thread_ctx_t *ctx) +{ + test_sync_lock(); + int n = ctx->events.shm_created; + test_sync_unlock(); + return n; +} + +/* The server creates a session's SHM objects only when the handshake + * selects SHM, and falls back to baseline in the same handshake when they + * cannot be created. They exist before HELLO_ACK, so a client that is + * READY has already been counted. */ +static void test_shm_region_only_for_shm_sessions(void) +{ + printf("--- SHM region created only for SHM sessions ---\n"); + + char service[64]; + unique_service(service, sizeof(service), "svc_shm_after_negotiation"); + + server_thread_ctx_t sctx; + nipc_server_config_t scfg = default_typed_hybrid_server_config(); + HANDLE server_thread = start_server_named(&sctx, service, 4, &scfg, &full_service_handler); + if (!server_thread) + return; + + nipc_client_ctx_t client; + nipc_cgroups_resp_view_t view; + + nipc_client_config_t baseline_cfg = default_client_config(); + nipc_client_init(&client, TEST_RUN_DIR, service, &baseline_cfg); + check("baseline client ready", refresh_ready(&client) && client.shm == NULL); + check("baseline client call works", + nipc_client_call_cgroups_snapshot(&client, &view) == NIPC_OK); + check("baseline session created no SHM region", shm_regions_created(&sctx) == 0); + nipc_client_close(&client); + + nipc_client_config_t bad_auth_cfg = default_typed_hybrid_client_config(); + bad_auth_cfg.auth_token = AUTH_TOKEN + 1; + nipc_client_init(&client, TEST_RUN_DIR, service, &bad_auth_cfg); + check("rejected client reports auth failure", + refresh_to_state(&client, NIPC_CLIENT_AUTH_FAILED)); + check("rejected client created no SHM region", shm_regions_created(&sctx) == 0); + nipc_client_close(&client); + + nipc_client_config_t shm_cfg = default_typed_hybrid_client_config(); + nipc_client_init(&client, TEST_RUN_DIR, service, &shm_cfg); + check("SHM client ready on SHM", refresh_ready(&client) && client.shm != NULL); + check("SHM client call works", + nipc_client_call_cgroups_snapshot(&client, &view) == NIPC_OK); + check("SHM session created one region", shm_regions_created(&sctx) == 1); + nipc_client_close(&client); + + nipc_win_service_test_fault_set( + NIPC_WIN_SERVICE_TEST_FAULT_SERVER_SHM_CTX_CALLOC, 0); + nipc_client_init(&client, TEST_RUN_DIR, service, &shm_cfg); + check("failed region falls back to baseline in one handshake", + refresh_ready(&client) && client.shm == NULL && + client.session.selected_profile == NIPC_PROFILE_BASELINE); + clear_test_faults(); + check("fallback session call works", + nipc_client_call_cgroups_snapshot(&client, &view) == NIPC_OK); + check("failed region was not counted", shm_regions_created(&sctx) == 1); + nipc_client_close(&client); + + stop_server_drain(&sctx, server_thread); +} + static void test_server_init_fault_injection(void) { printf("--- Server init fault injection ---\n"); @@ -3857,7 +3916,7 @@ static void test_refresh_from_broken_state(void) nipc_client_ctx_t client; nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("client reaches READY", refresh_until_ready(&client, 100, 10)); + check("client reaches READY", refresh_ready(&client)); if (nipc_client_ready(&client)) { if (client.session_valid) { @@ -3893,7 +3952,7 @@ static void test_retry_on_broken_session(void) nipc_client_ctx_t client; nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("client ready", refresh_until_ready(&client, 100, 10)); + check("client ready", refresh_ready(&client)); if (nipc_client_ready(&client)) { nipc_cgroups_resp_view_t view; @@ -3954,7 +4013,7 @@ static void test_handler_failure(void) nipc_client_ctx_t client; nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("client ready", refresh_until_ready(&client, 100, 10)); + check("client ready", refresh_ready(&client)); if (nipc_client_ready(&client)) { nipc_cgroups_resp_view_t view; @@ -4131,7 +4190,7 @@ static void test_server_shm_create_fault_falls_back_and_recovers(void) nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("server SHM create fault reaches READY via baseline", - refresh_until_ready(&client, 40, 10)); + refresh_ready(&client)); check("server SHM create fault keeps client ready", nipc_client_ready(&client)); check("server SHM create fault leaves session on baseline", @@ -4141,7 +4200,7 @@ static void test_server_shm_create_fault_falls_back_and_recovers(void) nipc_client_close(&client); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("server SHM create fault recovers after reconnect", - refresh_until_ready(&client, 200, 10) && client.shm != NULL); + refresh_ready(&client) && client.shm != NULL); nipc_client_close(&client); stop_server_drain(&sctx, server_thread); @@ -4207,6 +4266,50 @@ static void test_cache_fault_injection(void) } } +typedef struct { + DWORD thread_id; + int runtime_thread; +} library_thread_probe_t; + +static unsigned __stdcall library_thread_probe(void *arg) +{ + library_thread_probe_t *probe = (library_thread_probe_t *)arg; + probe->thread_id = GetCurrentThreadId(); +#ifdef __MSYS__ + /* The runtime knows only threads it started; for others it reports + * ESRCH. */ + pthread_attr_t attr; + probe->runtime_thread = pthread_getattr_np(pthread_self(), &attr) == 0; + if (probe->runtime_thread) + pthread_attr_destroy(&attr); +#else + probe->runtime_thread = 1; +#endif + return 0; +} + +/* Library threads must be threads the C runtime supports, and their handle + * must be a real thread handle: signaled when the thread returns and naming + * the thread that ran. */ +static void test_library_thread_creation(void) +{ + library_thread_probe_t probe = {0}; + unsigned thread_id = 0; + HANDLE thread = (HANDLE)nipc_service_win_beginthreadex( + NULL, 0, library_thread_probe, &probe, 0, &thread_id); + check("library thread created", thread != NULL); + if (!thread) + return; + check("library thread handle signaled when the thread returns", + WaitForSingleObject(thread, TEST_WAIT_LIMIT_MS) == WAIT_OBJECT_0); + check("library thread is a runtime thread", probe.runtime_thread); + check("library thread id matches the thread that ran", + thread_id != 0 && thread_id == probe.thread_id); + check("library thread handle names the thread that ran", + GetThreadId(thread) == probe.thread_id); + CloseHandle(thread); +} + static void test_common_helper_edges(void) { printf("--- Common service helper edge paths ---\n"); @@ -4356,7 +4459,7 @@ static void test_common_helper_edges(void) memset(&client, 0, sizeof(client)); check("retry helper rejects not-ready client", nipc_service_common_call_with_retry( - &client, common_retry_attempt, &mock, &ops) == + &client, common_retry_attempt, &mock, 0, &ops) == NIPC_ERR_NOT_READY && client.error_count == 1); @@ -4367,7 +4470,7 @@ static void test_common_helper_edges(void) mock.result_count = 1; check("retry helper counts successful call", nipc_service_common_call_with_retry( - &client, common_retry_attempt, &mock, &ops) == NIPC_OK && + &client, common_retry_attempt, &mock, 0, &ops) == NIPC_OK && client.call_count == 1); memset(&client, 0, sizeof(client)); @@ -4377,7 +4480,7 @@ static void test_common_helper_edges(void) mock.result_count = 1; check("retry helper breaks client on timeout", nipc_service_common_call_with_retry( - &client, common_retry_attempt, &mock, &ops) == + &client, common_retry_attempt, &mock, 0, &ops) == NIPC_ERR_TIMEOUT && client.state == NIPC_CLIENT_BROKEN && client.error_count == 1 && mock.disconnects == 1); @@ -4391,9 +4494,32 @@ static void test_common_helper_edges(void) mock.reconnect_ok = true; check("retry helper retries one non-overflow failure", nipc_service_common_call_with_retry( - &client, common_retry_attempt, &mock, &ops) == NIPC_OK && + &client, common_retry_attempt, &mock, 0, &ops) == NIPC_OK && client.call_count == 1 && client.reconnect_count == 1 && - mock.sleeps >= 1); + mock.reconnects == 1); + + memset(&client, 0, sizeof(client)); + memset(&mock, 0, sizeof(mock)); + client.state = NIPC_CLIENT_READY; + mock.results[0] = NIPC_ERR_BAD_LAYOUT; + mock.result_count = 1; + mock.expire_deadline = true; + check("retry helper reports the call deadline when its reconnect fails", + nipc_service_common_call_with_retry( + &client, common_retry_attempt, &mock, 0, &ops) == + NIPC_ERR_TIMEOUT && + mock.reconnects == 1); + + memset(&client, 0, sizeof(client)); + memset(&mock, 0, sizeof(mock)); + client.state = NIPC_CLIENT_READY; + mock.results[0] = NIPC_ERR_BAD_LAYOUT; + mock.result_count = 1; + check("retry helper keeps the attempt error when its reconnect fails in time", + nipc_service_common_call_with_retry( + &client, common_retry_attempt, &mock, 0, &ops) == + NIPC_ERR_BAD_LAYOUT && + mock.reconnects == 1); memset(&client, 0, sizeof(client)); memset(&mock, 0, sizeof(mock)); @@ -4403,7 +4529,7 @@ static void test_common_helper_edges(void) mock.reconnect_ok = true; check("retry helper rejects overflow without learned growth", nipc_service_common_call_with_retry( - &client, common_retry_attempt, &mock, &ops) == + &client, common_retry_attempt, &mock, 0, &ops) == NIPC_ERR_OVERFLOW && client.state == NIPC_CLIENT_BROKEN && client.error_count == 1); @@ -4419,7 +4545,7 @@ static void test_common_helper_edges(void) mock.reconnect_request_bytes = 64; check("retry helper accepts overflow after learned growth", nipc_service_common_call_with_retry( - &client, common_retry_attempt, &mock, &ops) == NIPC_OK && + &client, common_retry_attempt, &mock, 0, &ops) == NIPC_OK && client.call_count == 1 && client.reconnect_count == 1); memset(&client, 0, sizeof(client)); @@ -4427,14 +4553,14 @@ static void test_common_helper_edges(void) __atomic_store_n(&client.abort_requested, 1u, __ATOMIC_RELEASE); check("ensure request capacity honors abort", nipc_service_common_client_ensure_request_capacity( - &client, 16, &ops) == NIPC_ERR_ABORTED && + &client, 16, 0, &ops) == NIPC_ERR_ABORTED && client.state == NIPC_CLIENT_BROKEN); memset(&client, 0, sizeof(client)); client.state = NIPC_CLIENT_READY; check("ensure request capacity rejects impossible size", nipc_service_common_client_ensure_request_capacity( - &client, (size_t)UINT32_MAX + 1u, &ops) == + &client, (size_t)UINT32_MAX + 1u, 0, &ops) == NIPC_ERR_OVERFLOW); memset(&client, 0, sizeof(client)); @@ -4442,7 +4568,7 @@ static void test_common_helper_edges(void) client.session.max_request_payload_bytes = 32; check("ensure request capacity accepts current session cap", nipc_service_common_client_ensure_request_capacity( - &client, 16, &ops) == NIPC_OK); + &client, 16, 0, &ops) == NIPC_OK); memset(&client, 0, sizeof(client)); client.state = NIPC_CLIENT_READY; @@ -4450,7 +4576,7 @@ static void test_common_helper_edges(void) client.transport_config.max_request_payload_bytes = 32; check("ensure request capacity rejects configured cap", nipc_service_common_client_ensure_request_capacity( - &client, 64, &ops) == NIPC_ERR_OVERFLOW); + &client, 64, 0, &ops) == NIPC_ERR_OVERFLOW); memset(&client, 0, sizeof(client)); memset(&mock, 0, sizeof(mock)); @@ -4461,7 +4587,7 @@ static void test_common_helper_edges(void) mock.reconnect_request_bytes = 64; check("ensure request capacity reconnects for learned cap", nipc_service_common_client_ensure_request_capacity( - &client, 64, &ops) == NIPC_OK && + &client, 64, 0, &ops) == NIPC_OK && client.reconnect_count == 1); g_common_retry_mock = NULL; @@ -4585,7 +4711,7 @@ static void test_lookup_request_capacity_reconnect_edges(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("apps reqcap reconnect client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_client_clear_abort(&client); client.session.max_request_payload_bytes = 1; uint32_t pid = 1234; @@ -4614,7 +4740,7 @@ static void test_lookup_request_capacity_reconnect_edges(void) nipc_client_config_t cgroups_ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &cgroups_ccfg); check("cgroups reqcap reconnect client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_client_clear_abort(&client); client.session.max_request_payload_bytes = 1; nipc_str_view_t path = {.ptr = "/ok", .len = 3}; @@ -4704,7 +4830,7 @@ static void test_lookup_stitched_response_buffer_fault_edges(void) ccfg.max_response_payload_bytes = 1200; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("apps stitched-response fault client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); uint32_t pids[] = {1, 2, 3, 4}; nipc_apps_lookup_resp_view_t view; @@ -4743,7 +4869,7 @@ static void test_lookup_stitched_response_buffer_fault_edges(void) ccfg.max_response_payload_bytes = 1200; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("cgroups stitched-response fault client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_str_view_t paths[] = { {.ptr = "/a", .len = 2}, @@ -4784,7 +4910,7 @@ static void test_lookup_missing_handler_dispatch(void) nipc_client_config_t ccfg = default_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("apps missing-handler client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); uint32_t pid = 1234; nipc_apps_lookup_resp_view_t apps_view; check("apps missing handler fails call", @@ -4802,7 +4928,7 @@ static void test_lookup_missing_handler_dispatch(void) nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("cgroups missing-handler client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); nipc_str_view_t path = {.ptr = "/ok", .len = 3}; nipc_cgroups_lookup_resp_view_t cgroups_view; check("cgroups missing handler fails call", @@ -4818,6 +4944,7 @@ int main(void) CreateDirectoryA(TEST_RUN_DIR, NULL); g_test_filter = getenv("NIPC_TEST_FILTER"); + RUN_TEST(test_library_thread_creation); RUN_TEST(test_common_helper_edges); RUN_TEST(test_lookup_remaining_timeout_helpers); RUN_TEST(test_client_init_defaults_and_truncation); @@ -4850,6 +4977,7 @@ int main(void) RUN_TEST(test_lookup_stitched_response_buffer_fault_edges); RUN_TEST(test_lookup_missing_handler_dispatch); RUN_TEST(test_client_fault_injection_disconnects_and_recovers); + RUN_TEST(test_shm_region_only_for_shm_sessions); RUN_TEST(test_server_init_fault_injection); RUN_TEST(test_refresh_from_broken_state); RUN_TEST(test_retry_on_broken_session); diff --git a/tests/fixtures/c/test_win_service_guards.c b/tests/fixtures/c/test_win_service_guards.c index dfd132a6..c57112cb 100644 --- a/tests/fixtures/c/test_win_service_guards.c +++ b/tests/fixtures/c/test_win_service_guards.c @@ -13,6 +13,7 @@ #include "netipc/netipc_protocol.h" #include "netipc/netipc_service.h" #include "netipc/netipc_win_shm.h" +#include "test_sync.h" #include "test_win_raw_client_helpers.h" #include @@ -535,6 +536,7 @@ typedef struct { HANDLE ready_event; volatile LONG init_ok; nipc_managed_server_t server; + test_server_events_t events; } server_thread_ctx_t; static DWORD WINAPI managed_server_thread(LPVOID arg) @@ -562,6 +564,7 @@ static DWORD WINAPI managed_server_thread(LPVOID arg) InterlockedExchange(&ctx->init_ok, 1); SetEvent(ctx->ready_event); + test_server_use_test_timings(&ctx->server, &ctx->events); nipc_server_run(&ctx->server); return 0; } @@ -581,7 +584,7 @@ static HANDLE start_server_named(server_thread_ctx_t *ctx, ctx->ready_event = CreateEvent(NULL, TRUE, FALSE, NULL); InterlockedExchange(&ctx->init_ok, 0); - HANDLE thread = CreateThread(NULL, 0, managed_server_thread, ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, managed_server_thread, ctx, 0, NULL); check("guard server thread created", thread != NULL); if (!thread) return NULL; @@ -619,7 +622,7 @@ static HANDLE start_raw_server_named(server_thread_ctx_t *ctx, ctx->ready_event = CreateEvent(NULL, TRUE, FALSE, NULL); InterlockedExchange(&ctx->init_ok, 0); - HANDLE thread = CreateThread(NULL, 0, managed_server_thread, ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, managed_server_thread, ctx, 0, NULL); check("guard raw server thread created", thread != NULL); if (!thread) return NULL; @@ -650,52 +653,29 @@ static void stop_server_drain(server_thread_ctx_t *ctx, HANDLE thread) nipc_server_drain(&ctx->server, 10000)); } -static bool refresh_until_ready(nipc_client_ctx_t *client, int max_tries, DWORD sleep_ms) +/* One refresh, then report whether the client is ready. */ +static bool refresh_ready(nipc_client_ctx_t *client) { - for (int i = 0; i < max_tries; i++) { - nipc_client_refresh(client); - if (nipc_client_ready(client)) - return true; - Sleep(sleep_ms); - } - - return false; + nipc_client_refresh(client); + return nipc_client_ready(client); } -static bool refresh_until_state(nipc_client_ctx_t *client, - nipc_client_state_t target_state, - int max_tries, - DWORD sleep_ms) +/* One refresh, then report whether the client reached target_state. */ +static bool refresh_to_state(nipc_client_ctx_t *client, + nipc_client_state_t target_state) { - for (int i = 0; i < max_tries; i++) { - nipc_client_refresh(client); - if (client->state == target_state) - return true; - Sleep(sleep_ms); - } - + nipc_client_refresh(client); return client->state == target_state; } -static bool wait_for_server_sessions(nipc_managed_server_t *server, - int target_count, - int max_tries, - DWORD sleep_ms) +/* Callers wait for a handler or handshake first, so the sessions they + * count are already registered. */ +static bool server_has_sessions(nipc_managed_server_t *server, int target_count) { - for (int i = 0; i < max_tries; i++) { - int count; - - EnterCriticalSection(&server->sessions_lock); - count = server->session_count; - LeaveCriticalSection(&server->sessions_lock); - - if (count >= target_count) - return true; - - Sleep(sleep_ms); - } - - return false; + EnterCriticalSection(&server->sessions_lock); + int count = server->session_count; + LeaveCriticalSection(&server->sessions_lock); + return count >= target_count; } typedef struct { @@ -712,7 +692,7 @@ static DWORD WINAPI blocking_client_thread(LPVOID arg) nipc_client_config_t ccfg = default_typed_client_config(); nipc_client_init(&client, TEST_RUN_DIR, ctx->service, &ccfg); - if (refresh_until_ready(&client, 100, 10)) + if (refresh_ready(&client)) ctx->err = test_win_client_call_increment_raw(&client, 41, &ctx->value_out); else ctx->err = NIPC_ERR_NOT_READY; @@ -897,7 +877,7 @@ static HANDLE start_fake_hybrid_server(fake_hybrid_server_ctx_t *ctx, ctx->ready_event = CreateEvent(NULL, TRUE, FALSE, NULL); InterlockedExchange(&ctx->listen_ok, 0); - HANDLE thread = CreateThread(NULL, 0, fake_hybrid_server_thread, ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, fake_hybrid_server_thread, ctx, 0, NULL); check("fake hybrid server thread created", thread != NULL); if (!thread) return NULL; @@ -948,7 +928,7 @@ static void test_client_batch_and_string_guards(void) nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); client.transport_config.max_request_payload_bytes = 8; check("guard batch-overflow client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); if (nipc_client_ready(&client)) { nipc_error_t err = test_win_client_call_increment_batch_raw( @@ -991,7 +971,7 @@ static void test_client_batch_and_string_guards(void) nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); client.transport_config.max_request_payload_bytes = 16; check("guard string-overflow client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); if (nipc_client_ready(&client)) { nipc_error_t err = test_win_client_call_string_reverse_raw( @@ -1030,7 +1010,7 @@ static void test_hybrid_attach_failure_falls_back_to_baseline(void) nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("hybrid attach failure eventually reaches READY", - refresh_until_ready(&client, 50, 10)); + refresh_ready(&client)); check("hybrid attach failure leaves client ready on baseline", nipc_client_ready(&client)); check("hybrid attach failure maps to READY baseline", @@ -1092,7 +1072,7 @@ static void test_hybrid_client_rejects_malformed_responses(void) nipc_client_ctx_t client; nipc_client_config_t ccfg = default_typed_hybrid_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check(cases[i].label, refresh_until_ready(&client, 200, 10) && client.shm != NULL); + check(cases[i].label, refresh_ready(&client) && client.shm != NULL); if (nipc_client_ready(&client) && client.shm != NULL) { uint64_t value_out = 0; @@ -1143,7 +1123,7 @@ static void test_hybrid_typed_snapshot_response_guards(void) nipc_client_config_t ccfg = default_typed_hybrid_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check(cases[i].label, - refresh_until_ready(&client, 200, 10) && client.shm != NULL); + refresh_ready(&client) && client.shm != NULL); if (nipc_client_ready(&client) && client.shm != NULL) { nipc_cgroups_resp_view_t view; @@ -1185,7 +1165,7 @@ static void test_hybrid_client_send_buffer_guard(void) nipc_client_config_t ccfg = default_typed_hybrid_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("hybrid send-guard client ready", - refresh_until_ready(&client, 200, 10) && client.shm != NULL); + refresh_ready(&client) && client.shm != NULL); if (nipc_client_ready(&client) && client.shm != NULL) { uint64_t value_out = 0; @@ -1222,7 +1202,7 @@ static void test_hybrid_client_send_capacity_guard(void) nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); client.transport_config.max_request_payload_bytes = 16; check("hybrid send-capacity client ready", - refresh_until_ready(&client, 200, 10) && client.shm != NULL); + refresh_ready(&client) && client.shm != NULL); if (nipc_client_ready(&client) && client.shm != NULL) { nipc_string_reverse_view_t view; @@ -1265,7 +1245,7 @@ static void test_hybrid_batch_send_capacity_guard(void) nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); client.transport_config.max_request_payload_bytes = 16; check("hybrid batch send-capacity client ready", - refresh_until_ready(&client, 200, 10) && client.shm != NULL); + refresh_ready(&client) && client.shm != NULL); if (nipc_client_ready(&client) && client.shm != NULL) { uint64_t req[4] = { 41, 99, 123, 777 }; @@ -1304,7 +1284,7 @@ static void test_hybrid_batch_receive_failure(void) nipc_client_config_t ccfg = default_typed_hybrid_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("hybrid batch-recv client ready", - refresh_until_ready(&client, 200, 10) && client.shm != NULL); + refresh_ready(&client) && client.shm != NULL); if (nipc_client_ready(&client) && client.shm != NULL) { uint64_t req = 41; @@ -1339,7 +1319,7 @@ static void test_hybrid_string_raw_call_failure(void) nipc_client_config_t ccfg = default_typed_hybrid_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("hybrid string-fail client ready", - refresh_until_ready(&client, 200, 10) && client.shm != NULL); + refresh_ready(&client) && client.shm != NULL); if (nipc_client_ready(&client) && client.shm != NULL) { nipc_string_reverse_view_t view; @@ -1391,20 +1371,21 @@ static void test_hybrid_server_rejects_malformed_requests(void) nipc_client_config_t ccfg = default_typed_hybrid_client_config(); nipc_client_init(&bad_client, TEST_RUN_DIR, service, &ccfg); check(cases[i].label, - refresh_until_ready(&bad_client, 200, 10) && bad_client.shm != NULL); + refresh_ready(&bad_client) && bad_client.shm != NULL); if (nipc_client_ready(&bad_client) && bad_client.shm != NULL) { check("send malformed hybrid request", nipc_win_shm_send(bad_client.shm, cases[i].msg, cases[i].msg_len) == NIPC_WIN_SHM_OK); - Sleep(100); } nipc_client_close(&bad_client); + check("malformed hybrid session exited", + test_server_wait_session_exits(&sctx.events, 1)); nipc_client_ctx_t good_client; nipc_client_init(&good_client, TEST_RUN_DIR, service, &ccfg); check("replacement hybrid client ready", - refresh_until_ready(&good_client, 200, 10) && good_client.shm != NULL); + refresh_ready(&good_client) && good_client.shm != NULL); if (nipc_client_ready(&good_client) && good_client.shm != NULL) { uint64_t value_out = 0; nipc_error_t err = test_win_client_call_increment_raw(&good_client, 9, &value_out); @@ -1435,7 +1416,7 @@ static void test_snapshot_default_max_items(void) nipc_client_config_t ccfg = default_typed_hybrid_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("snapshot default client ready", - refresh_until_ready(&client, 200, 10) && client.shm != NULL); + refresh_ready(&client) && client.shm != NULL); if (nipc_client_ready(&client)) { nipc_cgroups_resp_view_t view; @@ -1468,7 +1449,7 @@ static void test_typed_request_overflow_reconnect_guards(void) nipc_client_config_t ccfg = default_typed_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("typed baseline request-overflow client ready", - refresh_until_ready(&client, 200, 10) && client.shm == NULL); + refresh_ready(&client) && client.shm == NULL); if (nipc_client_ready(&client)) { nipc_cgroups_resp_view_t view = {0}; @@ -1505,7 +1486,7 @@ static void test_typed_request_overflow_reconnect_guards(void) nipc_client_config_t ccfg = default_typed_hybrid_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("typed hybrid request-overflow client ready", - refresh_until_ready(&client, 200, 10) && client.shm != NULL); + refresh_ready(&client) && client.shm != NULL); if (nipc_client_ready(&client) && client.shm != NULL) { nipc_cgroups_resp_view_t view = {0}; @@ -1544,7 +1525,7 @@ static void test_typed_request_overflow_reconnect_guards(void) nipc_client_config_t ccfg = default_typed_hybrid_client_config(); nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("typed hybrid no-growth overflow client ready", - refresh_until_ready(&client, 200, 10) && client.shm != NULL); + refresh_ready(&client) && client.shm != NULL); if (nipc_client_ready(&client) && client.shm != NULL) { nipc_cgroups_resp_view_t view = {0}; @@ -1855,7 +1836,7 @@ static void test_drain_timeout_on_blocked_handler(void) }; strncpy(client_ctx.service, service, sizeof(client_ctx.service) - 1); - HANDLE client_thread = CreateThread(NULL, 0, blocking_client_thread, &client_ctx, 0, NULL); + HANDLE client_thread = test_create_thread(NULL, 0, blocking_client_thread, &client_ctx, 0, NULL); check("blocking client thread created", client_thread != NULL); check("blocking handler entered", client_thread != NULL && WaitForSingleObject(g_block_entered, 10000) == WAIT_OBJECT_0); @@ -1865,10 +1846,9 @@ static void test_drain_timeout_on_blocked_handler(void) .done_event = CreateEvent(NULL, TRUE, FALSE, NULL), .drained = TRUE, }; - HANDLE drain_thread = CreateThread(NULL, 0, drain_thread_proc, &drain_ctx, 0, NULL); + HANDLE drain_thread = test_create_thread(NULL, 0, drain_thread_proc, &drain_ctx, 0, NULL); check("drain thread created", drain_thread != NULL); - Sleep(50); SetEvent(g_block_release); check("drain thread finished", diff --git a/tests/fixtures/c/test_win_service_guards_extra.c b/tests/fixtures/c/test_win_service_guards_extra.c index 204327ce..6850365e 100644 --- a/tests/fixtures/c/test_win_service_guards_extra.c +++ b/tests/fixtures/c/test_win_service_guards_extra.c @@ -11,6 +11,7 @@ #include "netipc/netipc_named_pipe.h" #include "netipc/netipc_protocol.h" #include "netipc/netipc_service.h" +#include "test_sync.h" #include "test_win_raw_client_helpers.h" #include @@ -478,6 +479,7 @@ typedef struct { HANDLE ready_event; volatile LONG init_ok; nipc_managed_server_t server; + test_server_events_t events; } server_thread_ctx_t; static DWORD WINAPI managed_server_thread(LPVOID arg) @@ -505,6 +507,7 @@ static DWORD WINAPI managed_server_thread(LPVOID arg) InterlockedExchange(&ctx->init_ok, 1); SetEvent(ctx->ready_event); + test_server_use_test_timings(&ctx->server, &ctx->events); nipc_server_run(&ctx->server); return 0; } @@ -524,7 +527,7 @@ static HANDLE start_server_named(server_thread_ctx_t *ctx, ctx->ready_event = CreateEvent(NULL, TRUE, FALSE, NULL); InterlockedExchange(&ctx->init_ok, 0); - HANDLE thread = CreateThread(NULL, 0, managed_server_thread, ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, managed_server_thread, ctx, 0, NULL); check("extra guard server thread created", thread != NULL); if (!thread) return NULL; @@ -562,7 +565,7 @@ static HANDLE start_raw_server_named(server_thread_ctx_t *ctx, ctx->ready_event = CreateEvent(NULL, TRUE, FALSE, NULL); InterlockedExchange(&ctx->init_ok, 0); - HANDLE thread = CreateThread(NULL, 0, managed_server_thread, ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, managed_server_thread, ctx, 0, NULL); check("extra guard raw server thread created", thread != NULL); if (!thread) return NULL; @@ -593,37 +596,21 @@ static void stop_server_drain(server_thread_ctx_t *ctx, HANDLE thread) nipc_server_drain(&ctx->server, 10000)); } -static bool refresh_until_ready(nipc_client_ctx_t *client, int max_tries, DWORD sleep_ms) +/* One refresh, then report whether the client is ready. */ +static bool refresh_ready(nipc_client_ctx_t *client) { - for (int i = 0; i < max_tries; i++) { - nipc_client_refresh(client); - if (nipc_client_ready(client)) - return true; - Sleep(sleep_ms); - } - - return false; + nipc_client_refresh(client); + return nipc_client_ready(client); } -static bool wait_for_server_sessions(nipc_managed_server_t *server, - int target_count, - int max_tries, - DWORD sleep_ms) +/* Callers wait for a handler or handshake first, so the sessions they + * count are already registered. */ +static bool server_has_sessions(nipc_managed_server_t *server, int target_count) { - for (int i = 0; i < max_tries; i++) { - int count; - - EnterCriticalSection(&server->sessions_lock); - count = server->session_count; - LeaveCriticalSection(&server->sessions_lock); - - if (count >= target_count) - return true; - - Sleep(sleep_ms); - } - - return false; + EnterCriticalSection(&server->sessions_lock); + int count = server->session_count; + LeaveCriticalSection(&server->sessions_lock); + return count >= target_count; } typedef struct { @@ -640,7 +627,7 @@ static DWORD WINAPI blocking_client_thread(LPVOID arg) nipc_client_config_t ccfg = default_typed_client_config(); nipc_client_init(&client, TEST_RUN_DIR, ctx->service, &ccfg); - if (refresh_until_ready(&client, 100, 10)) + if (refresh_ready(&client)) ctx->err = test_win_client_call_increment_raw(&client, 41, &ctx->value_out); else ctx->err = NIPC_ERR_NOT_READY; @@ -712,12 +699,12 @@ static void test_worker_limit_rejects_extra_client(void) }; strncpy(client_ctx.service, service, sizeof(client_ctx.service) - 1); - HANDLE client_thread = CreateThread(NULL, 0, blocking_client_thread, &client_ctx, 0, NULL); + HANDLE client_thread = test_create_thread(NULL, 0, blocking_client_thread, &client_ctx, 0, NULL); check("worker-limit client thread created", client_thread != NULL); check("worker-limit handler entered", client_thread != NULL && WaitForSingleObject(g_block_entered, 10000) == WAIT_OBJECT_0); check("worker-limit server tracks active session", - wait_for_server_sessions(&sctx.server, 1, 100, 10)); + server_has_sessions(&sctx.server, 1)); nipc_np_client_config_t ccfg = default_client_config(); nipc_np_session_t second = { .pipe = INVALID_HANDLE_VALUE }; @@ -789,7 +776,7 @@ static void test_session_thread_create_fault_recovers(void) nipc_client_init(&first_client, TEST_RUN_DIR, service, &ccfg); check("thread-create skip client ready", - refresh_until_ready(&first_client, 100, 10)); + refresh_ready(&first_client)); check("thread-create skip increment succeeds", test_win_client_call_increment_raw(&first_client, 10, &value) == NIPC_OK && value == 11); @@ -808,7 +795,7 @@ static void test_session_thread_create_fault_recovers(void) value = 0; nipc_client_init(&recovery_client, TEST_RUN_DIR, service, &ccfg); check("thread-create recovery client ready", - refresh_until_ready(&recovery_client, 100, 10)); + refresh_ready(&recovery_client)); check("thread-create recovery increment succeeds", test_win_client_call_increment_raw(&recovery_client, 20, &value) == NIPC_OK && value == 21); @@ -842,12 +829,12 @@ static void test_server_destroy_joins_active_session(void) }; strncpy(client_ctx.service, service, sizeof(client_ctx.service) - 1); - HANDLE client_thread = CreateThread(NULL, 0, blocking_client_thread, &client_ctx, 0, NULL); + HANDLE client_thread = test_create_thread(NULL, 0, blocking_client_thread, &client_ctx, 0, NULL); check("destroy-join client thread created", client_thread != NULL); check("destroy-join handler entered", client_thread != NULL && WaitForSingleObject(g_block_entered, 10000) == WAIT_OBJECT_0); check("destroy-join server tracks active session", - wait_for_server_sessions(&sctx.server, 1, 100, 10)); + server_has_sessions(&sctx.server, 1)); nipc_server_stop(&sctx.server); check("destroy-join server thread exits after stop", @@ -857,10 +844,9 @@ static void test_server_destroy_joins_active_session(void) .server = &sctx.server, .done_event = CreateEvent(NULL, TRUE, FALSE, NULL), }; - HANDLE destroy_thread = CreateThread(NULL, 0, destroy_thread_proc, &destroy_ctx, 0, NULL); + HANDLE destroy_thread = test_create_thread(NULL, 0, destroy_thread_proc, &destroy_ctx, 0, NULL); check("destroy-join destroy thread created", destroy_thread != NULL); - Sleep(50); SetEvent(g_block_release); check("destroy-join destroy finished", @@ -923,7 +909,9 @@ static void test_named_pipe_send_failure_recovers(void) SetEvent(g_block_release); } - Sleep(100); + /* The only worker frees its slot once its send to the closed pipe fails. */ + check("send-failure session exited", + test_server_wait_session_exits(&sctx.events, 1)); { nipc_client_ctx_t client; @@ -931,7 +919,7 @@ static void test_named_pipe_send_failure_recovers(void) uint64_t value_out = 0; nipc_client_init(&client, TEST_RUN_DIR, service, &typed_ccfg); check("send-failure replacement client ready", - refresh_until_ready(&client, 100, 10)); + refresh_ready(&client)); if (nipc_client_ready(&client)) { nipc_error_t err = test_win_client_call_increment_raw(&client, 5, &value_out); check("send-failure replacement increment succeeds", @@ -1199,7 +1187,7 @@ static void test_lookup_client_rejects_bad_responses(void) nipc_client_ctx_t client; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("short cgroups lookup client ready", refresh_until_ready(&client, 100, 10)); + check("short cgroups lookup client ready", refresh_ready(&client)); nipc_str_view_t paths[] = {{ .ptr = "/known", .len = 6 }}; nipc_cgroups_lookup_resp_view_t view; check("short cgroups lookup response rejected", @@ -1220,7 +1208,7 @@ static void test_lookup_client_rejects_bad_responses(void) nipc_client_ctx_t client; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("count cgroups lookup client ready", refresh_until_ready(&client, 100, 10)); + check("count cgroups lookup client ready", refresh_ready(&client)); nipc_str_view_t paths[] = { { .ptr = "/known", .len = 6 }, { .ptr = "/missing", .len = 8 }, @@ -1244,7 +1232,7 @@ static void test_lookup_client_rejects_bad_responses(void) nipc_client_ctx_t client; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("path cgroups lookup client ready", refresh_until_ready(&client, 100, 10)); + check("path cgroups lookup client ready", refresh_ready(&client)); nipc_str_view_t paths[] = {{ .ptr = "/known", .len = 6 }}; nipc_cgroups_lookup_resp_view_t view; check("path cgroups lookup response rejected", @@ -1265,7 +1253,7 @@ static void test_lookup_client_rejects_bad_responses(void) nipc_client_ctx_t client; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("short apps lookup client ready", refresh_until_ready(&client, 100, 10)); + check("short apps lookup client ready", refresh_ready(&client)); uint32_t pids[] = {1}; nipc_apps_lookup_resp_view_t view; check("short apps lookup response rejected", @@ -1286,7 +1274,7 @@ static void test_lookup_client_rejects_bad_responses(void) nipc_client_ctx_t client; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("count apps lookup client ready", refresh_until_ready(&client, 100, 10)); + check("count apps lookup client ready", refresh_ready(&client)); uint32_t pids[] = {1, 2}; nipc_apps_lookup_resp_view_t view; check("count apps lookup response rejected", @@ -1307,7 +1295,7 @@ static void test_lookup_client_rejects_bad_responses(void) nipc_client_ctx_t client; nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); - check("pid apps lookup client ready", refresh_until_ready(&client, 100, 10)); + check("pid apps lookup client ready", refresh_ready(&client)); uint32_t pids[] = {1}; nipc_apps_lookup_resp_view_t view; check("pid apps lookup response rejected", @@ -1371,7 +1359,7 @@ static void test_drain_timeout_on_blocked_handler(void) }; strncpy(client_ctx.service, service, sizeof(client_ctx.service) - 1); - HANDLE client_thread = CreateThread(NULL, 0, blocking_client_thread, &client_ctx, 0, NULL); + HANDLE client_thread = test_create_thread(NULL, 0, blocking_client_thread, &client_ctx, 0, NULL); check("blocking client thread created", client_thread != NULL); check("blocking handler entered", client_thread != NULL && WaitForSingleObject(g_block_entered, 10000) == WAIT_OBJECT_0); @@ -1381,10 +1369,9 @@ static void test_drain_timeout_on_blocked_handler(void) .done_event = CreateEvent(NULL, TRUE, FALSE, NULL), .drained = TRUE, }; - HANDLE drain_thread = CreateThread(NULL, 0, drain_thread_proc, &drain_ctx, 0, NULL); + HANDLE drain_thread = test_create_thread(NULL, 0, drain_thread_proc, &drain_ctx, 0, NULL); check("drain thread created", drain_thread != NULL); - Sleep(50); SetEvent(g_block_release); check("drain thread finished", diff --git a/tests/fixtures/c/test_win_service_payload_limits.c b/tests/fixtures/c/test_win_service_payload_limits.c index 55efdf8b..72d8b567 100644 --- a/tests/fixtures/c/test_win_service_payload_limits.c +++ b/tests/fixtures/c/test_win_service_payload_limits.c @@ -9,6 +9,7 @@ #include "netipc/netipc_protocol.h" #include "netipc/netipc_service.h" +#include "test_sync.h" #include #include @@ -133,6 +134,7 @@ static DWORD WINAPI managed_server_thread(LPVOID arg) InterlockedExchange(&ctx->init_ok, 1); SetEvent(ctx->ready_event); + test_server_use_test_timings(&ctx->server, NULL); nipc_server_run(&ctx->server); return 0; } @@ -149,7 +151,7 @@ static HANDLE start_server_named(server_thread_ctx_t *ctx, ctx->ready_event = CreateEvent(NULL, TRUE, FALSE, NULL); InterlockedExchange(&ctx->init_ok, 0); - HANDLE thread = CreateThread(NULL, 0, managed_server_thread, ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, managed_server_thread, ctx, 0, NULL); check("payload-limit server thread created", thread != NULL); if (!thread) return NULL; @@ -181,16 +183,11 @@ static void stop_server_drain(server_thread_ctx_t *ctx, HANDLE thread) nipc_server_drain(&ctx->server, 10000)); } -static bool refresh_until_ready(nipc_client_ctx_t *client, int max_tries, DWORD sleep_ms) +/* One refresh, then report whether the client is ready. */ +static bool refresh_ready(nipc_client_ctx_t *client) { - for (int i = 0; i < max_tries; i++) { - nipc_client_refresh(client); - if (nipc_client_ready(client)) - return true; - Sleep(sleep_ms); - } - - return false; + nipc_client_refresh(client); + return nipc_client_ready(client); } static void test_response_limit_reconnect(bool hybrid) @@ -220,7 +217,7 @@ static void test_response_limit_reconnect(bool hybrid) nipc_client_init(&client, TEST_RUN_DIR, service, &ccfg); check("payload-limit client negotiated expected profile", - refresh_until_ready(&client, 200, 10) && + refresh_ready(&client) && (hybrid ? client.shm != NULL : client.shm == NULL)); if (nipc_client_ready(&client)) { diff --git a/tests/fixtures/c/test_win_shm.c b/tests/fixtures/c/test_win_shm.c index e22b127b..4b5bb2fe 100644 --- a/tests/fixtures/c/test_win_shm.c +++ b/tests/fixtures/c/test_win_shm.c @@ -10,6 +10,8 @@ #include "netipc/netipc_win_shm.h" #include "netipc/netipc_named_pipe.h" #include "netipc/netipc_protocol.h" +#include "test_sync.h" +#include "test_win_security.h" #include #include @@ -64,22 +66,14 @@ static void fill_long_run_dir(char *buf, size_t len) buf[len - 1] = '\0'; } -static nipc_win_shm_error_t attach_client_retry(const char *service, - uint64_t session_id, - uint32_t profile, - nipc_win_shm_ctx_t *ctx) +/* Callers create the server region first, so one attach succeeds. */ +static nipc_win_shm_error_t attach_client(const char *service, + uint64_t session_id, + uint32_t profile, + nipc_win_shm_ctx_t *ctx) { - nipc_win_shm_error_t err = NIPC_WIN_SHM_ERR_OPEN_MAPPING; - - for (int i = 0; i < 100; i++) { - err = nipc_win_shm_client_attach(TEST_RUN_DIR, service, AUTH_TOKEN, - session_id, profile, ctx); - if (err == NIPC_WIN_SHM_OK) - break; - Sleep(10); - } - - return err; + return nipc_win_shm_client_attach(TEST_RUN_DIR, service, AUTH_TOKEN, + session_id, profile, ctx); } /* Build a complete message (outer header + payload) for SHM. */ @@ -256,6 +250,7 @@ static void test_header_layout(void) typedef struct { const char *service; int result; + int created; /* __atomic: raised once the server tried to create */ } server_args_t; static DWORD WINAPI server_thread(LPVOID arg) @@ -267,6 +262,7 @@ static DWORD WINAPI server_thread(LPVOID arg) nipc_win_shm_error_t err = nipc_win_shm_server_create( TEST_RUN_DIR, sa->service, AUTH_TOKEN, 1, NIPC_WIN_SHM_PROFILE_HYBRID, 65536, 65536, &ctx); + test_flag_set(&sa->created); if (err != NIPC_WIN_SHM_OK) { fprintf(stderr, "server: create failed: %d\n", err); return 1; @@ -317,22 +313,17 @@ static void test_basic_roundtrip(void) unique_service(service, sizeof(service)); server_args_t sa = { .service = service }; - HANDLE thread = CreateThread(NULL, 0, server_thread, &sa, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, server_thread, &sa, 0, NULL); check("server thread created", thread != NULL); if (!thread) return; - /* Give server time to create mapping */ - Sleep(100); + (void)test_flag_wait(&sa.created); /* Client side */ nipc_win_shm_ctx_t client; - nipc_win_shm_error_t err = NIPC_WIN_SHM_ERR_OPEN_MAPPING; - for (int i = 0; i < 100; i++) { - err = nipc_win_shm_client_attach(TEST_RUN_DIR, service, AUTH_TOKEN, - 1, NIPC_WIN_SHM_PROFILE_HYBRID, &client); - if (err == NIPC_WIN_SHM_OK) break; - Sleep(10); - } + nipc_win_shm_error_t err = nipc_win_shm_client_attach(TEST_RUN_DIR, service, AUTH_TOKEN, + 1, NIPC_WIN_SHM_PROFILE_HYBRID, + &client); check("client attach", err == NIPC_WIN_SHM_OK); if (err == NIPC_WIN_SHM_OK) { @@ -379,6 +370,7 @@ typedef struct { const char *service; int count; int result; + int created; /* __atomic: raised once the server tried to create */ } multi_server_args_t; static DWORD WINAPI multi_server_thread(LPVOID arg) @@ -390,6 +382,7 @@ static DWORD WINAPI multi_server_thread(LPVOID arg) nipc_win_shm_error_t err = nipc_win_shm_server_create( TEST_RUN_DIR, sa->service, AUTH_TOKEN, 1, NIPC_WIN_SHM_PROFILE_HYBRID, 65536, 65536, &ctx); + test_flag_set(&sa->created); if (err != NIPC_WIN_SHM_OK) return 1; uint8_t msg[65536]; @@ -430,19 +423,14 @@ static void test_multiple_roundtrips(void) unique_service(service, sizeof(service)); multi_server_args_t sa = { .service = service, .count = 10 }; - HANDLE thread = CreateThread(NULL, 0, multi_server_thread, &sa, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, multi_server_thread, &sa, 0, NULL); if (!thread) { g_fail++; return; } - Sleep(100); + (void)test_flag_wait(&sa.created); nipc_win_shm_ctx_t client; - nipc_win_shm_error_t err = NIPC_WIN_SHM_ERR_OPEN_MAPPING; - for (int i = 0; i < 100; i++) { - err = nipc_win_shm_client_attach(TEST_RUN_DIR, service, AUTH_TOKEN, + nipc_win_shm_error_t err = nipc_win_shm_client_attach(TEST_RUN_DIR, service, AUTH_TOKEN, 1, NIPC_WIN_SHM_PROFILE_HYBRID, &client); - if (err == NIPC_WIN_SHM_OK) break; - Sleep(10); - } int all_ok = (err == NIPC_WIN_SHM_OK); @@ -489,6 +477,7 @@ static DWORD WINAPI busywait_server_thread(LPVOID arg) nipc_win_shm_error_t err = nipc_win_shm_server_create( TEST_RUN_DIR, sa->service, AUTH_TOKEN, 2, NIPC_WIN_SHM_PROFILE_BUSYWAIT, 65536, 65536, &ctx); + test_flag_set(&sa->created); if (err != NIPC_WIN_SHM_OK) return 1; @@ -526,22 +515,16 @@ static void test_busywait_roundtrip(void) unique_service(service, sizeof(service)); server_args_t sa = { .service = service }; - HANDLE thread = CreateThread(NULL, 0, busywait_server_thread, &sa, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, busywait_server_thread, &sa, 0, NULL); check("busywait server thread created", thread != NULL); if (!thread) return; - Sleep(100); + (void)test_flag_wait(&sa.created); nipc_win_shm_ctx_t client; - nipc_win_shm_error_t err = NIPC_WIN_SHM_ERR_OPEN_MAPPING; - for (int i = 0; i < 100; i++) { - err = nipc_win_shm_client_attach(TEST_RUN_DIR, service, AUTH_TOKEN, + nipc_win_shm_error_t err = nipc_win_shm_client_attach(TEST_RUN_DIR, service, AUTH_TOKEN, 2, NIPC_WIN_SHM_PROFILE_BUSYWAIT, &client); - if (err == NIPC_WIN_SHM_OK) - break; - Sleep(10); - } check("busywait client attach", err == NIPC_WIN_SHM_OK); if (err == NIPC_WIN_SHM_OK) { @@ -585,6 +568,7 @@ static void test_busywait_roundtrip(void) typedef struct { const char *service; int result; + int created; /* __atomic: raised once the server tried to create */ } small_recv_args_t; static DWORD WINAPI small_recv_server_thread(LPVOID arg) @@ -596,6 +580,7 @@ static DWORD WINAPI small_recv_server_thread(LPVOID arg) nipc_win_shm_error_t err = nipc_win_shm_server_create( TEST_RUN_DIR, sa->service, AUTH_TOKEN, 3, NIPC_WIN_SHM_PROFILE_HYBRID, 4096, 4096, &ctx); + test_flag_set(&sa->created); if (err != NIPC_WIN_SHM_OK) return 1; @@ -616,22 +601,16 @@ static void test_receive_msg_too_large(void) unique_service(service, sizeof(service)); small_recv_args_t sa = { .service = service }; - HANDLE thread = CreateThread(NULL, 0, small_recv_server_thread, &sa, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, small_recv_server_thread, &sa, 0, NULL); check("small-recv server thread created", thread != NULL); if (!thread) return; - Sleep(100); + (void)test_flag_wait(&sa.created); nipc_win_shm_ctx_t client; - nipc_win_shm_error_t err = NIPC_WIN_SHM_ERR_OPEN_MAPPING; - for (int i = 0; i < 100; i++) { - err = nipc_win_shm_client_attach(TEST_RUN_DIR, service, AUTH_TOKEN, + nipc_win_shm_error_t err = nipc_win_shm_client_attach(TEST_RUN_DIR, service, AUTH_TOKEN, 3, NIPC_WIN_SHM_PROFILE_HYBRID, &client); - if (err == NIPC_WIN_SHM_OK) - break; - Sleep(10); - } check("small-recv client attach", err == NIPC_WIN_SHM_OK); if (err == NIPC_WIN_SHM_OK) { @@ -1069,6 +1048,31 @@ static void test_server_create_rejects_existing_objects(void) nipc_win_shm_destroy(&first); } +static void test_server_objects_dacl_owner_only(void) +{ + printf("--- SHM mapping and events grant only SYSTEM, Administrators and self ---\n"); + + char service[64]; + unique_service(service, sizeof(service)); + + nipc_win_shm_ctx_t ctx = {0}; + nipc_win_shm_error_t err = nipc_win_shm_server_create( + TEST_RUN_DIR, service, AUTH_TOKEN, 79, + NIPC_WIN_SHM_PROFILE_HYBRID, 4096, 4096, &ctx); + check("server create for DACL check", err == NIPC_WIN_SHM_OK); + if (err != NIPC_WIN_SHM_OK) + return; + + check("mapping DACL is owner-only", + test_object_dacl_owner_only(ctx.mapping, SECTION_ALL_ACCESS)); + check("request event DACL is owner-only", + test_object_dacl_owner_only(ctx.req_event, EVENT_ALL_ACCESS)); + check("response event DACL is owner-only", + test_object_dacl_owner_only(ctx.resp_event, EVENT_ALL_ACCESS)); + + nipc_win_shm_destroy(&ctx); +} + static void test_server_create_rejects_region_size_overflow(void) { printf("--- Server create rejects region size overflow ---\n"); @@ -1329,7 +1333,7 @@ static void test_hybrid_receive_timeout_and_disconnect(void) return; nipc_win_shm_ctx_t client; - err = attach_client_retry(service, 6, NIPC_WIN_SHM_PROFILE_HYBRID, &client); + err = attach_client(service, 6, NIPC_WIN_SHM_PROFILE_HYBRID, &client); check("hybrid timeout client attach", err == NIPC_WIN_SHM_OK); if (err != NIPC_WIN_SHM_OK) { nipc_win_shm_destroy(&server); @@ -1395,7 +1399,7 @@ static void test_hybrid_receive_zero_timeout_waits_for_data(void) return; nipc_win_shm_ctx_t client; - err = attach_client_retry(service, 16, NIPC_WIN_SHM_PROFILE_HYBRID, &client); + err = attach_client(service, 16, NIPC_WIN_SHM_PROFILE_HYBRID, &client); check("hybrid zero-timeout client attach", err == NIPC_WIN_SHM_OK); if (err != NIPC_WIN_SHM_OK) { nipc_win_shm_destroy(&server); @@ -1413,7 +1417,7 @@ static void test_hybrid_receive_zero_timeout_waits_for_data(void) .payload_len = 4, .result = 1, }; - HANDLE thread = CreateThread(NULL, 0, delayed_send_thread, &sa, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, delayed_send_thread, &sa, 0, NULL); check("hybrid zero-timeout sender thread created", thread != NULL); if (!thread) { nipc_win_shm_close(&client); @@ -1462,7 +1466,7 @@ static void test_hybrid_receive_recheck_observes_ready_data(void) return; nipc_win_shm_ctx_t client; - err = attach_client_retry(service, 17, NIPC_WIN_SHM_PROFILE_HYBRID, &client); + err = attach_client(service, 17, NIPC_WIN_SHM_PROFILE_HYBRID, &client); check("hybrid recheck client attach", err == NIPC_WIN_SHM_OK); if (err != NIPC_WIN_SHM_OK) { nipc_win_shm_destroy(&server); @@ -1523,7 +1527,7 @@ static void test_busywait_receive_timeout_and_disconnect(void) return; nipc_win_shm_ctx_t client; - err = attach_client_retry(service, 7, NIPC_WIN_SHM_PROFILE_BUSYWAIT, &client); + err = attach_client(service, 7, NIPC_WIN_SHM_PROFILE_BUSYWAIT, &client); check("busywait timeout client attach", err == NIPC_WIN_SHM_OK); if (err != NIPC_WIN_SHM_OK) { nipc_win_shm_destroy(&server); @@ -1564,7 +1568,7 @@ static void test_hybrid_server_receive_disconnect_advances_req_seq(void) return; nipc_win_shm_ctx_t client; - err = attach_client_retry(service, 18, NIPC_WIN_SHM_PROFILE_HYBRID, &client); + err = attach_client(service, 18, NIPC_WIN_SHM_PROFILE_HYBRID, &client); check("hybrid server-disconnect client attach", err == NIPC_WIN_SHM_OK); if (err != NIPC_WIN_SHM_OK) { nipc_win_shm_destroy(&server); @@ -1601,7 +1605,7 @@ static void test_busywait_server_receive_disconnect_advances_req_seq(void) return; nipc_win_shm_ctx_t client; - err = attach_client_retry(service, 17, NIPC_WIN_SHM_PROFILE_BUSYWAIT, &client); + err = attach_client(service, 17, NIPC_WIN_SHM_PROFILE_BUSYWAIT, &client); check("busywait server-disconnect client attach", err == NIPC_WIN_SHM_OK); if (err != NIPC_WIN_SHM_OK) { nipc_win_shm_destroy(&server); @@ -1638,7 +1642,7 @@ static void test_client_receive_msg_too_large_response(void) return; nipc_win_shm_ctx_t client; - err = attach_client_retry(service, 8, NIPC_WIN_SHM_PROFILE_HYBRID, &client); + err = attach_client(service, 8, NIPC_WIN_SHM_PROFILE_HYBRID, &client); check("large-response client attach", err == NIPC_WIN_SHM_OK); if (err != NIPC_WIN_SHM_OK) { nipc_win_shm_destroy(&server); @@ -1699,6 +1703,7 @@ int main(void) test_header_layout(); test_service_name_validation(); test_server_create_rejects_existing_objects(); + test_server_objects_dacl_owner_only(); test_server_create_rejects_region_size_overflow(); test_client_attach_validation(); test_win32_fault_injection(); diff --git a/tests/fixtures/c/test_win_stress.c b/tests/fixtures/c/test_win_stress.c index de87c7bd..794dd201 100644 --- a/tests/fixtures/c/test_win_stress.c +++ b/tests/fixtures/c/test_win_stress.c @@ -16,6 +16,7 @@ #include "netipc/netipc_protocol.h" #include "netipc/netipc_service.h" #include "netipc/netipc_win_shm.h" +#include "test_sync.h" #include "test_win_raw_client_helpers.h" #include @@ -179,7 +180,7 @@ static HANDLE start_server(server_thread_ctx_t *ctx, ctx->ready_event = CreateEvent(NULL, TRUE, FALSE, NULL); InterlockedExchange(&ctx->init_ok, 0); - HANDLE thread = CreateThread(NULL, 0, managed_server_thread, ctx, 0, NULL); + HANDLE thread = test_create_thread(NULL, 0, managed_server_thread, ctx, 0, NULL); check("server thread created", thread != NULL); if (!thread) return NULL; diff --git a/tests/fixtures/c/uds_eintr_wrap.c b/tests/fixtures/c/uds_eintr_wrap.c new file mode 100644 index 00000000..ac726e4f --- /dev/null +++ b/tests/fixtures/c/uds_eintr_wrap.c @@ -0,0 +1,187 @@ +/* Test-only linker wrappers that make chosen socket calls fail once with + * EINTR, as a signal arriving before any data moved does. */ + +#include "uds_eintr_wrap.h" + +#include +#include +#include +#include +#include +#include +#include + +static unsigned g_send_eintr; +static unsigned g_sendmsg_eintr; +static unsigned g_recv_eintr; +static unsigned g_short_recv; +static unsigned g_short_sendmsg; + +void uds_eintr_wrap_set(unsigned send_count, unsigned sendmsg_count, + unsigned recv_count) +{ + __atomic_store_n(&g_send_eintr, send_count, __ATOMIC_RELEASE); + __atomic_store_n(&g_sendmsg_eintr, sendmsg_count, __ATOMIC_RELEASE); + __atomic_store_n(&g_recv_eintr, recv_count, __ATOMIC_RELEASE); +} + +void uds_short_io_set(unsigned recv_count, unsigned sendmsg_count) +{ + __atomic_store_n(&g_short_recv, recv_count, __ATOMIC_RELEASE); + __atomic_store_n(&g_short_sendmsg, sendmsg_count, __ATOMIC_RELEASE); +} + +unsigned uds_eintr_wrap_pending(void) +{ + return __atomic_load_n(&g_send_eintr, __ATOMIC_ACQUIRE) + + __atomic_load_n(&g_sendmsg_eintr, __ATOMIC_ACQUIRE) + + __atomic_load_n(&g_recv_eintr, __ATOMIC_ACQUIRE) + + __atomic_load_n(&g_short_recv, __ATOMIC_ACQUIRE) + + __atomic_load_n(&g_short_sendmsg, __ATOMIC_ACQUIRE); +} + +/* Consume one pending injection for a call site, if any. */ +static bool take_one(unsigned *count) +{ + unsigned left = __atomic_load_n(count, __ATOMIC_ACQUIRE); + while (left > 0) { + if (__atomic_compare_exchange_n(count, &left, left - 1, false, + __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE)) + return true; + } + return false; +} + +/* Consume one pending EINTR for a call site, if any. */ +static bool take(unsigned *count) +{ + unsigned left = __atomic_load_n(count, __ATOMIC_ACQUIRE); + while (left > 0) { + if (__atomic_compare_exchange_n(count, &left, left - 1, false, + __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE)) { + errno = EINTR; + return true; + } + } + return false; +} + +ssize_t __real_send(int fd, const void *buf, size_t len, int flags); +ssize_t __real_sendmsg(int fd, const struct msghdr *msg, int flags); +ssize_t __real_recv(int fd, void *buf, size_t len, int flags); + +ssize_t __wrap_send(int fd, const void *buf, size_t len, int flags) +{ + if (take(&g_send_eintr)) + return -1; + return __real_send(fd, buf, len, flags); +} + +/* The first byte of msg's data as a one-byte message, if it has any. */ +static bool first_byte_only(const struct msghdr *msg, struct msghdr *one, + struct iovec *iov) +{ + for (size_t i = 0; i < (size_t)msg->msg_iovlen; i++) { + if (msg->msg_iov[i].iov_len == 0) + continue; + *one = *msg; + iov->iov_base = msg->msg_iov[i].iov_base; + iov->iov_len = 1; + one->msg_iov = iov; + one->msg_iovlen = 1; + return true; + } + return false; +} + +ssize_t __wrap_sendmsg(int fd, const struct msghdr *msg, int flags) +{ + if (take(&g_sendmsg_eintr)) + return -1; + struct msghdr one; + struct iovec iov; + if (first_byte_only(msg, &one, &iov) && take_one(&g_short_sendmsg)) + return __real_sendmsg(fd, &one, flags); + return __real_sendmsg(fd, msg, flags); +} + +ssize_t __wrap_recv(int fd, void *buf, size_t len, int flags) +{ + if (take(&g_recv_eintr)) + return -1; + if (len > 1 && take_one(&g_short_recv)) + len = 1; + return __real_recv(fd, buf, len, flags); +} + +static unsigned g_accept_eintr; + +void uds_eintr_wrap_set_accept(unsigned accept_count) +{ + __atomic_store_n(&g_accept_eintr, accept_count, __ATOMIC_RELEASE); +} + +unsigned uds_eintr_wrap_accept_pending(void) +{ + return __atomic_load_n(&g_accept_eintr, __ATOMIC_ACQUIRE); +} + +int __real_accept4(int fd, struct sockaddr *addr, socklen_t *len, int flags); + +int __wrap_accept4(int fd, struct sockaddr *addr, socklen_t *len, int flags) +{ + if (take(&g_accept_eintr)) + return -1; + return __real_accept4(fd, addr, len, flags); +} + +static const char *g_bind_probe_path; +static int g_bind_probe_mode = -1; + +void uds_bind_probe_arm(const char *path) +{ + g_bind_probe_mode = -1; + __atomic_store_n(&g_bind_probe_path, path, __ATOMIC_RELEASE); +} + +int uds_bind_probe_mode(void) +{ + return g_bind_probe_mode; +} + +int __real_bind(int fd, const struct sockaddr *addr, socklen_t len); + +int __wrap_bind(int fd, const struct sockaddr *addr, socklen_t len) +{ + const char *path = __atomic_exchange_n(&g_bind_probe_path, NULL, + __ATOMIC_ACQ_REL); + if (path) { + int saved_errno = errno; + int pfd = open(path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0666); + struct stat st; + if (pfd >= 0 && fstat(pfd, &st) == 0) + g_bind_probe_mode = (int)(st.st_mode & 0777); + if (pfd >= 0) + close(pfd); + unlink(path); + errno = saved_errno; + } + return __real_bind(fd, addr, len); +} + +#if defined(__GLIBC__) && defined(__USE_TIME_BITS64) +/* With 64-bit time_t on 32-bit glibc, sys/socket.h redirects sendmsg to + * __sendmsg64, so the library references that symbol instead. */ +ssize_t __real___sendmsg64(int fd, const struct msghdr *msg, int flags); + +ssize_t __wrap___sendmsg64(int fd, const struct msghdr *msg, int flags) +{ + if (take(&g_sendmsg_eintr)) + return -1; + struct msghdr one; + struct iovec iov; + if (first_byte_only(msg, &one, &iov) && take_one(&g_short_sendmsg)) + return __real___sendmsg64(fd, &one, flags); + return __real___sendmsg64(fd, msg, flags); +} +#endif diff --git a/tests/fixtures/c/uds_eintr_wrap.h b/tests/fixtures/c/uds_eintr_wrap.h new file mode 100644 index 00000000..f885ba47 --- /dev/null +++ b/tests/fixtures/c/uds_eintr_wrap.h @@ -0,0 +1,29 @@ +#ifndef NETIPC_UDS_EINTR_WRAP_H +#define NETIPC_UDS_EINTR_WRAP_H + +/* Make the next send_count send(), sendmsg_count sendmsg() and recv_count + * recv() calls fail with EINTR. Linked only where --wrap is available. */ +void uds_eintr_wrap_set(unsigned send_count, unsigned sendmsg_count, + unsigned recv_count); + +/* Make the next recv_count recv() calls read at most one byte and the next + * sendmsg_count sendmsg() calls write at most one byte, as a stream socket + * may. */ +void uds_short_io_set(unsigned recv_count, unsigned sendmsg_count); + +/* Make the next accept_count accept4() calls fail with EINTR, as a signal + * arriving while the server waits for a client does. */ +void uds_eintr_wrap_set_accept(unsigned accept_count); +unsigned uds_eintr_wrap_accept_pending(void); + +/* Injections not consumed yet (EINTR and short I/O). */ +unsigned uds_eintr_wrap_pending(void); + +/* Inside the next bind(), create the unrelated file `path` with mode 0666, + * as another thread of the process could, and record its resulting mode. */ +void uds_bind_probe_arm(const char *path); + +/* Mode bits of the file the armed probe created, or -1 if no bind ran. */ +int uds_bind_probe_mode(void); + +#endif diff --git a/tests/fixtures/c/uds_test_socket.h b/tests/fixtures/c/uds_test_socket.h new file mode 100644 index 00000000..c3923c42 --- /dev/null +++ b/tests/fixtures/c/uds_test_socket.h @@ -0,0 +1,17 @@ +/* Socket type of the UDS transport on this platform, for tests that open + * raw sockets: macOS and FreeBSD carry the same packets over a stream + * (docs/level1-posix-uds.md, "Stream baseline"). */ +#ifndef UDS_TEST_SOCKET_H +#define UDS_TEST_SOCKET_H + +#include + +#if defined(__APPLE__) || defined(__FreeBSD__) +#define TEST_UDS_SOCK_TYPE SOCK_STREAM +#define TEST_UDS_STREAM 1 +#else +#define TEST_UDS_SOCK_TYPE SOCK_SEQPACKET +#define TEST_UDS_STREAM 0 +#endif + +#endif /* UDS_TEST_SOCKET_H */ diff --git a/tests/fixtures/go/cmd/interop_cache/main.go b/tests/fixtures/go/cmd/interop_cache/main.go index a7030bb7..e640cbd9 100644 --- a/tests/fixtures/go/cmd/interop_cache/main.go +++ b/tests/fixtures/go/cmd/interop_cache/main.go @@ -87,16 +87,26 @@ func testHandler() cgroups.Handler { } } -func waitForSocket(runDir, service string, timeout time.Duration) bool { - deadline := time.Now().Add(timeout) - path := fmt.Sprintf("%s/%s.sock", runDir, service) - for time.Now().Before(deadline) { - if info, err := os.Stat(path); err == nil && info.Mode()&os.ModeSocket != 0 { // #nosec G703 -- interop fixture probes caller-provided temporary socket path. - return true - } - time.Sleep(10 * time.Millisecond) +type listeningServer interface { + Listening() <-chan struct{} + Stop() +} + +// waitListening reports whether server accepts connections. On false it +// has reported why and Run has returned. +func waitListening(server listeningServer, serverErr chan error) bool { + select { + case <-server.Listening(): + return true + case err := <-serverErr: + fmt.Fprintf(os.Stderr, "server: Run returned before listening: %v\n", err) + return false + case <-time.After(5 * time.Second): + server.Stop() + <-serverErr + fmt.Fprintf(os.Stderr, "server: not listening after 5s\n") + return false } - return false } func runServer(runDir, service string) int { @@ -112,13 +122,7 @@ func runServer(runDir, service string) int { serverErr <- server.Run() }() - if !waitForSocket(runDir, service, 5*time.Second) { - server.Stop() - if err := <-serverErr; err != nil { - fmt.Fprintf(os.Stderr, "server: %v\n", err) - } else { - fmt.Fprintf(os.Stderr, "server: socket not ready\n") - } + if !waitListening(server, serverErr) { return 1 } @@ -134,14 +138,7 @@ func runServer(runDir, service string) int { func runClient(runDir, service string) int { cache := cgroups.NewCache(runDir, service, clientConfig()) - updated := false - for i := 0; i < 200; i++ { - if cache.Refresh() { - updated = true - break - } - time.Sleep(10 * time.Millisecond) - } + updated := cache.Refresh() if !updated || !cache.Ready() { fmt.Fprintf(os.Stderr, "client: cache not ready after refresh\n") fmt.Println("FAIL") diff --git a/tests/fixtures/go/cmd/interop_cache_win/main.go b/tests/fixtures/go/cmd/interop_cache_win/main.go index 7d68c55f..eb1e243f 100644 --- a/tests/fixtures/go/cmd/interop_cache_win/main.go +++ b/tests/fixtures/go/cmd/interop_cache_win/main.go @@ -74,17 +74,48 @@ func testHandler() cgroups.Handler { } } +type listeningServer interface { + Listening() <-chan struct{} + Stop() +} + +// waitListening reports whether server accepts connections. On false it +// has reported why and Run has returned. +func waitListening(server listeningServer, serverErr chan error) bool { + select { + case <-server.Listening(): + return true + case err := <-serverErr: + fmt.Fprintf(os.Stderr, "server: Run returned before listening: %v\n", err) + return false + case <-time.After(5 * time.Second): + server.Stop() + <-serverErr + fmt.Fprintf(os.Stderr, "server: not listening after 5s\n") + return false + } +} + func runServer(runDir, service string) int { server := cgroups.NewServer(runDir, service, serverConfig(), testHandler()) - fmt.Println("READY") - go func() { time.Sleep(10 * time.Second) server.Stop() }() - if err := server.Run(); err != nil { + serverErr := make(chan error, 1) + go func() { + serverErr <- server.Run() + }() + + if !waitListening(server, serverErr) { + return 1 + } + + fmt.Println("READY") + + if err := <-serverErr; err != nil { fmt.Fprintf(os.Stderr, "server: %v\n", err) return 1 } @@ -94,14 +125,7 @@ func runServer(runDir, service string) int { func runClient(runDir, service string) int { cache := cgroups.NewCache(runDir, service, clientConfig()) - updated := false - for i := 0; i < 200; i++ { - if cache.Refresh() { - updated = true - break - } - time.Sleep(10 * time.Millisecond) - } + updated := cache.Refresh() if !updated || !cache.Ready() { fmt.Fprintf(os.Stderr, "client: cache not ready after refresh\n") cache.Close() diff --git a/tests/fixtures/go/cmd/interop_named_pipe/main.go b/tests/fixtures/go/cmd/interop_named_pipe/main.go index 0820bed2..a4bdbc27 100644 --- a/tests/fixtures/go/cmd/interop_named_pipe/main.go +++ b/tests/fixtures/go/cmd/interop_named_pipe/main.go @@ -9,6 +9,13 @@ // // interop_named_pipe client // Connects, sends 1 message, verifies echo, exits 0 on success. +// +// interop_named_pipe pipeline-server +// Like server, but echoes pipelineRequests messages one at a time. +// +// interop_named_pipe pipeline-client +// Sends pipelineRequests messages before reading any response (more +// than the pipe buffers hold), then verifies the echoes in order. package main import ( @@ -22,6 +29,11 @@ import ( const authToken uint64 = 0xDEADBEEFCAFEBABE +const ( + pipelinePayload = 16 * 1024 + pipelineRequests = 32 // 512 KiB each way, 8x the 64 KiB pipe buffers. +) + func serverConfig() windows.ServerConfig { return windows.ServerConfig{ SupportedProfiles: protocol.ProfileBaseline, @@ -149,9 +161,93 @@ func runClient(runDir, service string) int { return 1 } +func pipelinePayloadFor(id uint64) []byte { + p := make([]byte, pipelinePayload) + for i := range p { + p[i] = byte(id) + byte(i) + } + return p +} + +func runPipelineServer(runDir, service string) int { + listener, err := windows.Listen(runDir, service, serverConfig()) + if err != nil { + fmt.Fprintf(os.Stderr, "server: listen failed: %v\n", err) + return 1 + } + defer listener.Close() + + fmt.Println("READY") + + session, err := listener.Accept() + if err != nil { + fmt.Fprintf(os.Stderr, "server: accept failed: %v\n", err) + return 1 + } + defer session.Close() + + buf := make([]byte, 65600) + for i := 0; i < pipelineRequests; i++ { + hdr, payload, err := session.Receive(buf) + if err != nil { + fmt.Fprintf(os.Stderr, "server: receive %d failed: %v\n", i, err) + return 1 + } + resp := hdr + resp.Kind = protocol.KindResponse + resp.TransportStatus = protocol.StatusOK + if err := session.Send(&resp, payload); err != nil { + fmt.Fprintf(os.Stderr, "server: send %d failed: %v\n", i, err) + return 1 + } + } + return 0 +} + +func runPipelineClient(runDir, service string) int { + cfg := clientConfig() + session, err := windows.Connect(runDir, service, &cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "client: connect failed: %v\n", err) + return 1 + } + defer session.Close() + + for id := uint64(1); id <= pipelineRequests; id++ { + hdr := protocol.Header{ + Kind: protocol.KindRequest, + Code: protocol.MethodIncrement, + ItemCount: 1, + MessageID: id, + } + if err := session.Send(&hdr, pipelinePayloadFor(id)); err != nil { + fmt.Fprintf(os.Stderr, "client: send %d failed: %v\n", id, err) + return 1 + } + } + + rbuf := make([]byte, 65600) + for id := uint64(1); id <= pipelineRequests; id++ { + rhdr, rpayload, err := session.Receive(rbuf) + if err != nil { + fmt.Fprintf(os.Stderr, "client: receive %d failed: %v\n", id, err) + fmt.Println("FAIL") + return 1 + } + if rhdr.Kind != protocol.KindResponse || rhdr.MessageID != id || + !bytes.Equal(rpayload, pipelinePayloadFor(id)) { + fmt.Fprintf(os.Stderr, "client: response %d out of order or corrupted\n", id) + fmt.Println("FAIL") + return 1 + } + } + fmt.Println("PASS") + return 0 +} + func main() { if len(os.Args) != 4 { - fmt.Fprintf(os.Stderr, "Usage: %s \n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Usage: %s \n", os.Args[0]) os.Exit(1) } @@ -165,6 +261,10 @@ func main() { rc = runServer(runDir, service) case "client": rc = runClient(runDir, service) + case "pipeline-server": + rc = runPipelineServer(runDir, service) + case "pipeline-client": + rc = runPipelineClient(runDir, service) default: fmt.Fprintf(os.Stderr, "Unknown mode: %s\n", mode) rc = 1 diff --git a/tests/fixtures/go/cmd/interop_service/main.go b/tests/fixtures/go/cmd/interop_service/main.go index 4d312e8c..cf9ebcfb 100644 --- a/tests/fixtures/go/cmd/interop_service/main.go +++ b/tests/fixtures/go/cmd/interop_service/main.go @@ -248,9 +248,19 @@ func cgroupsLookupMixedHandler() cgroupslookup.Handler { } } +// handlerDelay honors NIPC_HANDLER_DELAY_MS so interop runs prove that +// clients wait past their internal 100 ms abort-poll slice. +func handlerDelay() { + ms, err := strconv.Atoi(os.Getenv("NIPC_HANDLER_DELAY_MS")) + if err == nil && ms > 0 && ms <= 10000 { + time.Sleep(time.Duration(ms) * time.Millisecond) + } +} + func testHandler() cgroups.Handler { return cgroups.Handler{ Handle: func(request *protocol.CgroupsRequest, builder *protocol.CgroupsBuilder) bool { + handlerDelay() if request.LayoutVersion != 1 || request.Flags != 0 { return false } @@ -276,16 +286,26 @@ func testHandler() cgroups.Handler { } } -func waitForSocket(runDir, service string, timeout time.Duration) bool { - deadline := time.Now().Add(timeout) - path := fmt.Sprintf("%s/%s.sock", runDir, service) - for time.Now().Before(deadline) { - if info, err := os.Stat(path); err == nil && info.Mode()&os.ModeSocket != 0 { // #nosec G703 -- interop fixture probes caller-provided temporary socket path. - return true - } - time.Sleep(10 * time.Millisecond) +type listeningServer interface { + Listening() <-chan struct{} + Stop() +} + +// waitListening reports whether server accepts connections. On false it +// has reported why and Run has returned. +func waitListening(server listeningServer, serverErr chan error) bool { + select { + case <-server.Listening(): + return true + case err := <-serverErr: + fmt.Fprintf(os.Stderr, "server: Run returned before listening: %v\n", err) + return false + case <-time.After(5 * time.Second): + server.Stop() + <-serverErr + fmt.Fprintf(os.Stderr, "server: not listening after 5s\n") + return false } - return false } func runServer(runDir, service string) int { @@ -302,13 +322,7 @@ func runServer(runDir, service string) int { serverErr <- server.Run() }() - if !waitForSocket(runDir, service, 5*time.Second) { - server.Stop() - if err := <-serverErr; err != nil { - fmt.Fprintf(os.Stderr, "server: %v\n", err) - } else { - fmt.Fprintf(os.Stderr, "server: socket not ready\n") - } + if !waitListening(server, serverErr) { return 1 } @@ -323,6 +337,7 @@ func runServer(runDir, service string) int { type stoppableServer interface { Run() error + Listening() <-chan struct{} Stop() } @@ -337,13 +352,7 @@ func runLookupServer(server stoppableServer, runDir, service string) int { serverErr <- server.Run() }() - if !waitForSocket(runDir, service, 5*time.Second) { - server.Stop() - if err := <-serverErr; err != nil { - fmt.Fprintf(os.Stderr, "server: %v\n", err) - } else { - fmt.Fprintf(os.Stderr, "server: socket not ready\n") - } + if !waitListening(server, serverErr) { return 1 } @@ -406,26 +415,16 @@ func largeLookupPaths(count int) [][]byte { return paths } +// The server printed READY after binding, so one refresh connects. func waitAppsClientReady(client *appslookup.Client) bool { - for i := 0; i < 200; i++ { - client.Refresh() - if client.Ready() { - return true - } - time.Sleep(10 * time.Millisecond) - } - return false + client.Refresh() + return client.Ready() } +// The server printed READY after binding, so one refresh connects. func waitCgroupsLookupClientReady(client *cgroupslookup.Client) bool { - for i := 0; i < 200; i++ { - client.Refresh() - if client.Ready() { - return true - } - time.Sleep(10 * time.Millisecond) - } - return false + client.Refresh() + return client.Ready() } func runAppsClient(runDir, service string) int { @@ -621,13 +620,7 @@ func runCgroupsMixedClient(runDir, service string) int { func runClient(runDir, service string) int { client := cgroups.NewClient(runDir, service, clientConfig()) - for i := 0; i < 200; i++ { - client.Refresh() - if client.Ready() { - break - } - time.Sleep(10 * time.Millisecond) - } + client.Refresh() if !client.Ready() { fmt.Fprintf(os.Stderr, "client: not ready\n") diff --git a/tests/fixtures/go/cmd/interop_service_win/main.go b/tests/fixtures/go/cmd/interop_service_win/main.go index 905635c5..4289e98c 100644 --- a/tests/fixtures/go/cmd/interop_service_win/main.go +++ b/tests/fixtures/go/cmd/interop_service_win/main.go @@ -236,9 +236,19 @@ func cgroupsLookupMixedHandler() cgroupslookup.Handler { } } +// handlerDelay honors NIPC_HANDLER_DELAY_MS so interop runs prove that +// clients wait past their internal 100 ms abort-poll slice. +func handlerDelay() { + ms, err := strconv.Atoi(os.Getenv("NIPC_HANDLER_DELAY_MS")) + if err == nil && ms > 0 && ms <= 10000 { + time.Sleep(time.Duration(ms) * time.Millisecond) + } +} + func testHandler() cgroups.Handler { return cgroups.Handler{ Handle: func(request *protocol.CgroupsRequest, builder *protocol.CgroupsBuilder) bool { + handlerDelay() if request.LayoutVersion != 1 || request.Flags != 0 { return false } @@ -264,17 +274,48 @@ func testHandler() cgroups.Handler { } } +type listeningServer interface { + Listening() <-chan struct{} + Stop() +} + +// waitListening reports whether server accepts connections. On false it +// has reported why and Run has returned. +func waitListening(server listeningServer, serverErr chan error) bool { + select { + case <-server.Listening(): + return true + case err := <-serverErr: + fmt.Fprintf(os.Stderr, "server: Run returned before listening: %v\n", err) + return false + case <-time.After(5 * time.Second): + server.Stop() + <-serverErr + fmt.Fprintf(os.Stderr, "server: not listening after 5s\n") + return false + } +} + func runServer(runDir, service string) int { server := cgroups.NewServer(runDir, service, serverConfig(), testHandler()) - fmt.Println("READY") - go func() { time.Sleep(10 * time.Second) server.Stop() }() - if err := server.Run(); err != nil { + serverErr := make(chan error, 1) + go func() { + serverErr <- server.Run() + }() + + if !waitListening(server, serverErr) { + return 1 + } + + fmt.Println("READY") + + if err := <-serverErr; err != nil { fmt.Fprintf(os.Stderr, "server: %v\n", err) return 1 } @@ -283,18 +324,28 @@ func runServer(runDir, service string) int { type stoppableServer interface { Run() error + Listening() <-chan struct{} Stop() } func runLookupServer(server stoppableServer) int { - fmt.Println("READY") - go func() { time.Sleep(120 * time.Second) server.Stop() }() - if err := server.Run(); err != nil { + serverErr := make(chan error, 1) + go func() { + serverErr <- server.Run() + }() + + if !waitListening(server, serverErr) { + return 1 + } + + fmt.Println("READY") + + if err := <-serverErr; err != nil { fmt.Fprintf(os.Stderr, "server: %v\n", err) return 1 } @@ -343,26 +394,16 @@ func largeLookupPaths(count int) [][]byte { return paths } +// The server printed READY after binding, so one refresh connects. func waitAppsClientReady(client *appslookup.Client) bool { - for i := 0; i < 200; i++ { - client.Refresh() - if client.Ready() { - return true - } - time.Sleep(10 * time.Millisecond) - } - return false + client.Refresh() + return client.Ready() } +// The server printed READY after binding, so one refresh connects. func waitCgroupsLookupClientReady(client *cgroupslookup.Client) bool { - for i := 0; i < 200; i++ { - client.Refresh() - if client.Ready() { - return true - } - time.Sleep(10 * time.Millisecond) - } - return false + client.Refresh() + return client.Ready() } func runAppsClient(runDir, service string) int { @@ -558,13 +599,7 @@ func runCgroupsMixedClient(runDir, service string) int { func runClient(runDir, service string) int { client := cgroups.NewClient(runDir, service, clientConfig()) - for i := 0; i < 200; i++ { - client.Refresh() - if client.Ready() { - break - } - time.Sleep(10 * time.Millisecond) - } + client.Refresh() if !client.Ready() { fmt.Fprintf(os.Stderr, "client: not ready\n") diff --git a/tests/fixtures/go/cmd/interop_shm/main.go b/tests/fixtures/go/cmd/interop_shm/main.go index 1db8972a..8536b406 100644 --- a/tests/fixtures/go/cmd/interop_shm/main.go +++ b/tests/fixtures/go/cmd/interop_shm/main.go @@ -1,4 +1,4 @@ -//go:build linux +//go:build linux || (freebsd && (amd64 || 386)) // Simple server/client for cross-language SHM interop tests. // @@ -18,7 +18,6 @@ import ( "os" "os/signal" "syscall" - "time" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/posix" @@ -88,22 +87,8 @@ func runServer(runDir, service string) int { } func runClient(runDir, service string) int { - // Retry attach -- server may not be fully ready yet - var ctx *posix.ShmContext - var err error - for i := 0; i < 500; i++ { - ctx, err = posix.ShmClientAttach(runDir, service, 1) - if err == nil { - break - } - if errors.Is(err, posix.ErrShmNotReady) || - errors.Is(err, posix.ErrShmOpen) || - errors.Is(err, posix.ErrShmBadMagic) { - time.Sleep(10 * time.Millisecond) - continue - } - break - } + // The server prints READY only after creating the region. + ctx, err := posix.ShmClientAttach(runDir, service, 1) if err != nil { fmt.Fprintf(os.Stderr, "client: attach failed: %v\n", err) return 1 diff --git a/tests/fixtures/go/cmd/interop_win_shm/main.go b/tests/fixtures/go/cmd/interop_win_shm/main.go index 1025ab43..468dbf86 100644 --- a/tests/fixtures/go/cmd/interop_win_shm/main.go +++ b/tests/fixtures/go/cmd/interop_win_shm/main.go @@ -15,7 +15,6 @@ import ( "bytes" "fmt" "os" - "time" "github.com/netdata/plugin-ipc/go/pkg/netipc/protocol" "github.com/netdata/plugin-ipc/go/pkg/netipc/transport/windows" @@ -86,17 +85,9 @@ func runServer(runDir, service string) int { } func runClient(runDir, service string) int { - // Retry attach - var ctx *windows.WinShmContext - var err error - for i := 0; i < 500; i++ { - ctx, err = windows.WinShmClientAttach(runDir, service, authToken, 1, - windows.WinShmProfileHybrid) - if err == nil { - break - } - time.Sleep(10 * time.Millisecond) - } + // The server prints READY only after creating the region. + ctx, err := windows.WinShmClientAttach(runDir, service, authToken, 1, + windows.WinShmProfileHybrid) if err != nil { fmt.Fprintf(os.Stderr, "client: attach failed: %v\n", err) return 1 diff --git a/tests/fixtures/go/go.mod b/tests/fixtures/go/go.mod index 66ae30d4..87d3a058 100644 --- a/tests/fixtures/go/go.mod +++ b/tests/fixtures/go/go.mod @@ -1,7 +1,9 @@ module github.com/netdata/plugin-ipc/tests/fixtures/go -go 1.26.0 +go 1.27.0 require github.com/netdata/plugin-ipc/go v0.0.0 +require golang.org/x/sys v0.49.0 // indirect + replace github.com/netdata/plugin-ipc/go => ../../../src/go diff --git a/tests/fixtures/go/go.sum b/tests/fixtures/go/go.sum new file mode 100644 index 00000000..d61accff --- /dev/null +++ b/tests/fixtures/go/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/tests/fixtures/go/osv-scanner.toml b/tests/fixtures/go/osv-scanner.toml index 7f2a1225..9e02749b 100644 --- a/tests/fixtures/go/osv-scanner.toml +++ b/tests/fixtures/go/osv-scanner.toml @@ -1,6 +1,5 @@ -# CI/dev toolchains build with the latest Go 1.26.x patch release. The go -# directive in go.mod must stay 1.26.0 until netdata (which vendors this -# source) raises its own Go toolchain; bumping it would break netdata builds. -# This override scans the stdlib at the toolchain version actually used. -# Remove when netdata moves past Go 1.26.0 and go.mod is bumped. -GoVersionOverride = "1.26.4" +# Scan the Go standard library Netdata ships. go.mod keeps Netdata's +# `go 1.27.0` directive, while Netdata builds with the patch release pinned +# in netdata/netdata packaging/check-for-go-toolchain.sh. Keep this value +# equal to that pin and bump it whenever Netdata bumps it. +GoVersionOverride = "1.27.3" diff --git a/tests/fixtures/rust/src/bin/interop_cache.rs b/tests/fixtures/rust/src/bin/interop_cache.rs index e5566927..77d329f4 100644 --- a/tests/fixtures/rust/src/bin/interop_cache.rs +++ b/tests/fixtures/rust/src/bin/interop_cache.rs @@ -95,33 +95,43 @@ mod posix_only { } } - fn run_server(run_dir: &str, service: &str) -> Result<(), Box> { - let mut server = ManagedServer::new(run_dir, service, server_config(), server_handler()); - + /// Run a server on its own thread, print READY once it accepts + /// connections, and stop it after 10 s if the test never kills this + /// process. + fn serve( + listening: netipc::service::ListeningSignal, + stop_flag: std::sync::Arc, + run: impl FnOnce() -> Result<(), netipc::protocol::NipcError> + Send + 'static, + ) -> Result<(), Box> { + let runner = std::thread::spawn(run); + if !listening.wait(std::time::Duration::from_secs(5)) { + stop_flag.store(false, std::sync::atomic::Ordering::Release); + let _ = runner.join(); + return Err("server not listening after 5s".into()); + } println!("READY"); - let stop_flag = server.running_flag(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_secs(10)); stop_flag.store(false, std::sync::atomic::Ordering::Release); }); - server.run()?; + runner.join().map_err(|_| "server thread panicked")??; Ok(()) } + fn run_server(run_dir: &str, service: &str) -> Result<(), Box> { + let mut server = ManagedServer::new(run_dir, service, server_config(), server_handler()); + let listening = server.listening_signal(); + let stop_flag = server.running_flag(); + serve(listening, stop_flag, move || server.run()) + } + fn run_client(run_dir: &str, service: &str) -> Result<(), Box> { let cache = CgroupsCache::new(run_dir, service, client_config()); // Refresh to populate cache - let mut updated = false; - for _ in 0..200 { - if cache.refresh() { - updated = true; - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } + let updated = cache.refresh(); if !updated || !cache.ready() { return Err("cache not ready after refresh".into()); } diff --git a/tests/fixtures/rust/src/bin/interop_cache_win.rs b/tests/fixtures/rust/src/bin/interop_cache_win.rs index e9fe104f..77a159fe 100644 --- a/tests/fixtures/rust/src/bin/interop_cache_win.rs +++ b/tests/fixtures/rust/src/bin/interop_cache_win.rs @@ -87,17 +87,26 @@ fn run_server(run_dir: &str, service: &str) -> i32 { ..ClientConfig::default() }; + let listening = server.listening_signal(); + let runner = std::thread::spawn(move || server.run()); + if !listening.wait(std::time::Duration::from_secs(5)) { + stop_flag.store(false, std::sync::atomic::Ordering::Release); + let _ = runner.join(); + eprintln!("server: not listening after 5s"); + return 1; + } println!("READY"); - let sf = stop_flag.clone(); + // Stop after 10 s if the test never kills this process; the wake call + // unblocks the pending accept. std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_secs(10)); - sf.store(false, std::sync::atomic::Ordering::Release); + stop_flag.store(false, std::sync::atomic::Ordering::Release); let mut wake = CgroupsClient::new(&wake_run_dir, &wake_service, wake_config); let _ = wake.refresh(); }); - let _ = server.run(); + let _ = runner.join(); 0 } @@ -114,16 +123,13 @@ fn run_client(run_dir: &str, service: &str) -> i32 { let cache = CgroupsCache::new(run_dir, service, config); - let mut updated = false; - for _ in 0..200 { - if cache.refresh() { - updated = true; - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } + let updated = cache.refresh(); if !updated || !cache.ready() { - eprintln!("client: cache not ready after refresh"); + let status = cache.status(); + eprintln!( + "client: cache not ready after refresh (connection {:?}, failures {})", + status.connection_state, status.refresh_failure_count + ); cache.close(); println!("FAIL"); return 1; diff --git a/tests/fixtures/rust/src/bin/interop_named_pipe.rs b/tests/fixtures/rust/src/bin/interop_named_pipe.rs index 3d559c02..01395613 100644 --- a/tests/fixtures/rust/src/bin/interop_named_pipe.rs +++ b/tests/fixtures/rust/src/bin/interop_named_pipe.rs @@ -6,6 +6,13 @@ //! //! interop_named_pipe client //! Connects, sends 1 message, verifies echo, exits 0 on success. +//! +//! interop_named_pipe pipeline-server +//! Like server, but echoes PIPELINE_REQUESTS messages one at a time. +//! +//! interop_named_pipe pipeline-client +//! Sends PIPELINE_REQUESTS messages before reading any response (more +//! than the pipe buffers hold), then verifies the echoes in order. #[cfg(windows)] use netipc::protocol::{self, Header}; @@ -14,6 +21,11 @@ use netipc::transport::windows::{ClientConfig, NpListener, NpSession, ServerConf #[cfg(windows)] const AUTH_TOKEN: u64 = 0xDEADBEEFCAFEBABE; +#[cfg(windows)] +const PIPELINE_PAYLOAD: usize = 16 * 1024; +/// 512 KiB each way, 8x the 64 KiB pipe buffers. +#[cfg(windows)] +const PIPELINE_REQUESTS: u64 = 32; #[cfg(windows)] fn server_config() -> ServerConfig { @@ -129,6 +141,67 @@ fn run_client(run_dir: &str, service: &str) -> Result<(), Box Vec { + (0..PIPELINE_PAYLOAD) + .map(|i| (id as u8).wrapping_add(i as u8)) + .collect() +} + +#[cfg(windows)] +fn run_pipeline_server(run_dir: &str, service: &str) -> Result<(), Box> { + let mut listener = NpListener::bind(run_dir, service, server_config())?; + + println!("READY"); + + let mut session = listener.accept()?; + let mut buf = vec![0u8; 65600]; + for _ in 0..PIPELINE_REQUESTS { + let (hdr, payload) = session.receive(&mut buf)?; + let payload = payload.to_vec(); + let mut resp = hdr; + resp.kind = protocol::KIND_RESPONSE; + resp.transport_status = protocol::STATUS_OK; + session.send(&mut resp, &payload)?; + } + + session.close(); + listener.close(); + Ok(()) +} + +#[cfg(windows)] +fn run_pipeline_client(run_dir: &str, service: &str) -> Result<(), Box> { + let mut session = NpSession::connect(run_dir, service, &client_config())?; + + for id in 1..=PIPELINE_REQUESTS { + let mut hdr = Header { + kind: protocol::KIND_REQUEST, + code: protocol::METHOD_INCREMENT, + item_count: 1, + message_id: id, + ..Header::default() + }; + session.send(&mut hdr, &pipeline_payload(id))?; + } + + let mut rbuf = vec![0u8; 65600]; + for id in 1..=PIPELINE_REQUESTS { + let (rhdr, rpayload) = session.receive(&mut rbuf)?; + if rhdr.kind != protocol::KIND_RESPONSE + || rhdr.message_id != id + || rpayload != pipeline_payload(id).as_slice() + { + println!("FAIL"); + return Err(format!("response {id} out of order or corrupted").into()); + } + } + + session.close(); + println!("PASS"); + Ok(()) +} + fn main() { #[cfg(not(windows))] { @@ -141,7 +214,7 @@ fn main() { let args: Vec = std::env::args().collect(); if args.len() != 4 { eprintln!( - "Usage: {} ", + "Usage: {} ", args[0] ); std::process::exit(1); @@ -154,6 +227,8 @@ fn main() { let result = match mode.as_str() { "server" => run_server(run_dir, service), "client" => run_client(run_dir, service), + "pipeline-server" => run_pipeline_server(run_dir, service), + "pipeline-client" => run_pipeline_client(run_dir, service), _ => { eprintln!("Unknown mode: {mode}"); std::process::exit(1); diff --git a/tests/fixtures/rust/src/bin/interop_service.rs b/tests/fixtures/rust/src/bin/interop_service.rs index 6f10db02..4f8ab93a 100644 --- a/tests/fixtures/rust/src/bin/interop_service.rs +++ b/tests/fixtures/rust/src/bin/interop_service.rs @@ -49,9 +49,22 @@ mod posix_only { } } + /// NIPC_HANDLER_DELAY_MS makes every snapshot handler slow, so interop runs + /// prove that clients wait past their internal 100 ms abort-poll slice. + fn handler_delay() { + if let Some(ms) = std::env::var("NIPC_HANDLER_DELAY_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|&ms| ms > 0 && ms <= 10_000) + { + std::thread::sleep(std::time::Duration::from_millis(ms)); + } + } + fn server_handler() -> Handler { Handler { handle: Some(std::sync::Arc::new(|request, builder| { + handler_delay(); if request.layout_version != 1 || request.flags != 0 { return false; } @@ -375,23 +388,38 @@ mod posix_only { } } - fn run_server(run_dir: &str, service: &str) -> Result<(), Box> { - let mut server = ManagedServer::new(run_dir, service, server_config(), server_handler()); - - // Signal readiness + /// Run a server on its own thread, print READY once it accepts + /// connections, and stop it after 10 s if the test never kills this + /// process. + fn serve( + listening: netipc::service::ListeningSignal, + stop_flag: std::sync::Arc, + run: impl FnOnce() -> Result<(), netipc::protocol::NipcError> + Send + 'static, + ) -> Result<(), Box> { + let runner = std::thread::spawn(run); + if !listening.wait(std::time::Duration::from_secs(5)) { + stop_flag.store(false, std::sync::atomic::Ordering::Release); + let _ = runner.join(); + return Err("server not listening after 5s".into()); + } println!("READY"); - // Stop after 10s timeout (interop test should finish sooner) - let stop_flag = server.running_flag(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_secs(10)); stop_flag.store(false, std::sync::atomic::Ordering::Release); }); - server.run()?; + runner.join().map_err(|_| "server thread panicked")??; Ok(()) } + fn run_server(run_dir: &str, service: &str) -> Result<(), Box> { + let mut server = ManagedServer::new(run_dir, service, server_config(), server_handler()); + let listening = server.listening_signal(); + let stop_flag = server.running_flag(); + serve(listening, stop_flag, move || server.run()) + } + fn run_apps_server(run_dir: &str, service: &str) -> Result<(), Box> { let mut server = AppsLookupServer::with_workers( run_dir, @@ -400,17 +428,9 @@ mod posix_only { apps_lookup_handler(), 8, ); - - println!("READY"); - + let listening = server.listening_signal(); let stop_flag = server.running_flag(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(10)); - stop_flag.store(false, std::sync::atomic::Ordering::Release); - }); - - server.run()?; - Ok(()) + serve(listening, stop_flag, move || server.run()) } fn run_cgroups_lookup_server( @@ -424,17 +444,9 @@ mod posix_only { cgroups_lookup_handler(), 8, ); - - println!("READY"); - + let listening = server.listening_signal(); let stop_flag = server.running_flag(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(10)); - stop_flag.store(false, std::sync::atomic::Ordering::Release); - }); - - server.run()?; - Ok(()) + serve(listening, stop_flag, move || server.run()) } fn run_apps_mixed_server( @@ -448,17 +460,9 @@ mod posix_only { apps_lookup_mixed_handler(), 2, ); - - println!("READY"); - + let listening = server.listening_signal(); let stop_flag = server.running_flag(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(10)); - stop_flag.store(false, std::sync::atomic::Ordering::Release); - }); - - server.run()?; - Ok(()) + serve(listening, stop_flag, move || server.run()) } fn run_cgroups_mixed_server( @@ -472,17 +476,9 @@ mod posix_only { cgroups_lookup_mixed_handler(), 2, ); - - println!("READY"); - + let listening = server.listening_signal(); let stop_flag = server.running_flag(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(10)); - stop_flag.store(false, std::sync::atomic::Ordering::Release); - }); - - server.run()?; - Ok(()) + serve(listening, stop_flag, move || server.run()) } fn large_lookup_pids(item_count: usize) -> Vec { @@ -495,26 +491,16 @@ mod posix_only { .collect() } + /// The server printed READY after binding, so one refresh connects. fn wait_apps_client_ready(client: &mut AppsLookupClient) -> bool { - for _ in 0..200 { - client.refresh(); - if client.ready() { - return true; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - false + client.refresh(); + client.ready() } + /// The server printed READY after binding, so one refresh connects. fn wait_cgroups_lookup_client_ready(client: &mut CgroupsLookupClient) -> bool { - for _ in 0..200 { - client.refresh(); - if client.ready() { - return true; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - false + client.refresh(); + client.ready() } fn run_apps_client(run_dir: &str, service: &str) -> Result<(), Box> { @@ -749,13 +735,7 @@ mod posix_only { fn run_client(run_dir: &str, service: &str) -> Result<(), Box> { let mut client = CgroupsClient::new(run_dir, service, client_config()); - for _ in 0..200 { - client.refresh(); - if client.ready() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } + client.refresh(); if !client.ready() { return Err("client not ready".into()); diff --git a/tests/fixtures/rust/src/bin/interop_service_win.rs b/tests/fixtures/rust/src/bin/interop_service_win.rs index 62705a1d..db7f83eb 100644 --- a/tests/fixtures/rust/src/bin/interop_service_win.rs +++ b/tests/fixtures/rust/src/bin/interop_service_win.rs @@ -312,10 +312,24 @@ fn cgroups_lookup_mixed_handler() -> cgroups_lookup::Handler { } } +/// NIPC_HANDLER_DELAY_MS makes every snapshot handler slow, so interop runs +/// prove that clients wait past their internal 100 ms abort-poll slice. +#[cfg(windows)] +fn handler_delay() { + if let Some(ms) = std::env::var("NIPC_HANDLER_DELAY_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|&ms| ms > 0 && ms <= 10_000) + { + std::thread::sleep(std::time::Duration::from_millis(ms)); + } +} + #[cfg(windows)] fn server_handler() -> Handler { Handler { handle: Some(std::sync::Arc::new(|request, builder| { + handler_delay(); if request.layout_version != 1 || request.flags != 0 { return false; } @@ -351,6 +365,36 @@ fn server_handler() -> Handler { } } +/// Run a server on its own thread and print READY once it accepts +/// connections. If the test never kills this process, stop the server after +/// `self_stop` and wake its blocked accept with `wake`. +#[cfg(windows)] +fn serve( + listening: netipc::service::ListeningSignal, + stop_flag: std::sync::Arc, + self_stop: Duration, + wake: impl FnOnce() + Send + 'static, + run: impl FnOnce() -> Result<(), netipc::protocol::NipcError> + Send + 'static, +) -> i32 { + let runner = thread::spawn(run); + if !listening.wait(Duration::from_secs(5)) { + stop_flag.store(false, std::sync::atomic::Ordering::Release); + let _ = runner.join(); + eprintln!("server: not listening after 5s"); + return 1; + } + println!("READY"); + + thread::spawn(move || { + thread::sleep(self_stop); + stop_flag.store(false, std::sync::atomic::Ordering::Release); + wake(); + }); + + let _ = runner.join(); + 0 +} + #[cfg(windows)] fn run_server(run_dir: &str, service: &str) -> i32 { let profiles = detect_profiles(); @@ -374,19 +418,17 @@ fn run_server(run_dir: &str, service: &str) -> i32 { ..ClientConfig::default() }; - println!("READY"); - - // Auto-stop after 10 seconds - let sf = stop_flag.clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(10)); - sf.store(false, std::sync::atomic::Ordering::Release); - let mut wake = CgroupsClient::new(&wake_run_dir, &wake_service, wake_config); - let _ = wake.refresh(); - }); - - let _ = server.run(); - 0 + let listening = server.listening_signal(); + serve( + listening, + stop_flag, + Duration::from_secs(10), + move || { + let mut wake = CgroupsClient::new(&wake_run_dir, &wake_service, wake_config); + let _ = wake.refresh(); + }, + move || server.run(), + ) } #[cfg(windows)] @@ -403,19 +445,18 @@ fn run_apps_server(run_dir: &str, service: &str) -> i32 { let wake_service = service.to_string(); let wake_config = apps_lookup_client_config(1); - println!("READY"); - - let sf = stop_flag.clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(120)); - sf.store(false, std::sync::atomic::Ordering::Release); - let mut wake = - apps_lookup::AppsLookupClient::new(&wake_run_dir, &wake_service, wake_config); - let _ = wake.refresh(); - }); - - let _ = server.run(); - 0 + let listening = server.listening_signal(); + serve( + listening, + stop_flag, + Duration::from_secs(120), + move || { + let mut wake = + apps_lookup::AppsLookupClient::new(&wake_run_dir, &wake_service, wake_config); + let _ = wake.refresh(); + }, + move || server.run(), + ) } #[cfg(windows)] @@ -432,19 +473,18 @@ fn run_cgroups_lookup_server(run_dir: &str, service: &str) -> i32 { let wake_service = service.to_string(); let wake_config = cgroups_lookup_client_config(1); - println!("READY"); - - let sf = stop_flag.clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(120)); - sf.store(false, std::sync::atomic::Ordering::Release); - let mut wake = - cgroups_lookup::CgroupsLookupClient::new(&wake_run_dir, &wake_service, wake_config); - let _ = wake.refresh(); - }); - - let _ = server.run(); - 0 + let listening = server.listening_signal(); + serve( + listening, + stop_flag, + Duration::from_secs(120), + move || { + let mut wake = + cgroups_lookup::CgroupsLookupClient::new(&wake_run_dir, &wake_service, wake_config); + let _ = wake.refresh(); + }, + move || server.run(), + ) } #[cfg(windows)] @@ -461,19 +501,18 @@ fn run_apps_mixed_server(run_dir: &str, service: &str) -> i32 { let wake_service = service.to_string(); let wake_config = apps_lookup_client_config(1); - println!("READY"); - - let sf = stop_flag.clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(120)); - sf.store(false, std::sync::atomic::Ordering::Release); - let mut wake = - apps_lookup::AppsLookupClient::new(&wake_run_dir, &wake_service, wake_config); - let _ = wake.refresh(); - }); - - let _ = server.run(); - 0 + let listening = server.listening_signal(); + serve( + listening, + stop_flag, + Duration::from_secs(120), + move || { + let mut wake = + apps_lookup::AppsLookupClient::new(&wake_run_dir, &wake_service, wake_config); + let _ = wake.refresh(); + }, + move || server.run(), + ) } #[cfg(windows)] @@ -490,19 +529,18 @@ fn run_cgroups_mixed_server(run_dir: &str, service: &str) -> i32 { let wake_service = service.to_string(); let wake_config = cgroups_lookup_client_config(1); - println!("READY"); - - let sf = stop_flag.clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(120)); - sf.store(false, std::sync::atomic::Ordering::Release); - let mut wake = - cgroups_lookup::CgroupsLookupClient::new(&wake_run_dir, &wake_service, wake_config); - let _ = wake.refresh(); - }); - - let _ = server.run(); - 0 + let listening = server.listening_signal(); + serve( + listening, + stop_flag, + Duration::from_secs(120), + move || { + let mut wake = + cgroups_lookup::CgroupsLookupClient::new(&wake_run_dir, &wake_service, wake_config); + let _ = wake.refresh(); + }, + move || server.run(), + ) } #[cfg(windows)] @@ -517,28 +555,18 @@ fn large_lookup_paths(item_count: usize) -> Vec> { .collect() } +/// The server printed READY after binding, so one refresh connects. #[cfg(windows)] fn wait_apps_client_ready(client: &mut apps_lookup::AppsLookupClient) -> bool { - for _ in 0..200 { - client.refresh(); - if client.ready() { - return true; - } - thread::sleep(Duration::from_millis(10)); - } - false + client.refresh(); + client.ready() } +/// The server printed READY after binding, so one refresh connects. #[cfg(windows)] fn wait_cgroups_lookup_client_ready(client: &mut cgroups_lookup::CgroupsLookupClient) -> bool { - for _ in 0..200 { - client.refresh(); - if client.ready() { - return true; - } - thread::sleep(Duration::from_millis(10)); - } - false + client.refresh(); + client.ready() } #[cfg(windows)] @@ -853,13 +881,7 @@ fn run_client(run_dir: &str, service: &str) -> i32 { }; let mut client = CgroupsClient::new(run_dir, service, config); - for _ in 0..200 { - client.refresh(); - if client.ready() { - break; - } - thread::sleep(Duration::from_millis(10)); - } + client.refresh(); if !client.ready() { eprintln!("client: not ready"); diff --git a/tests/fixtures/rust/src/bin/interop_shm.rs b/tests/fixtures/rust/src/bin/interop_shm.rs index bbd9bd76..a479be3a 100644 --- a/tests/fixtures/rust/src/bin/interop_shm.rs +++ b/tests/fixtures/rust/src/bin/interop_shm.rs @@ -7,13 +7,13 @@ //! interop_shm client //! Attaches to SHM, sends 1 message, verifies echo, exits 0 on success. -#[cfg(windows)] +#[cfg(not(any(target_os = "linux", target_os = "freebsd")))] fn main() { - eprintln!("interop_shm is only supported on Linux"); + eprintln!("interop_shm is only supported on Linux and FreeBSD"); std::process::exit(1); } -#[cfg(not(windows))] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] mod linux_only { use netipc::protocol::{self, Header}; @@ -75,24 +75,8 @@ mod linux_only { } fn run_client(run_dir: &str, service: &str) -> Result<(), Box> { - // Retry attach -- server may not be fully ready yet - let mut ctx = None; - for _ in 0..500 { - let attempt = ShmContext::client_attach(run_dir, service, 1); - match attempt { - Ok(c) => { - ctx = Some(c); - break; - } - Err(netipc::transport::shm::ShmError::NotReady) - | Err(netipc::transport::shm::ShmError::Open(_)) - | Err(netipc::transport::shm::ShmError::BadMagic) => { - std::thread::sleep(std::time::Duration::from_millis(10)); - } - Err(e) => return Err(e.into()), - } - } - let mut ctx = ctx.ok_or("client: attach failed after retries")?; + // The server prints READY only after creating the region. + let mut ctx = ShmContext::client_attach(run_dir, service, 1)?; // Build payload with known pattern let payload: Vec = (0..256).map(|i| (i & 0xFF) as u8).collect(); @@ -186,7 +170,7 @@ mod linux_only { } } -#[cfg(not(windows))] +#[cfg(any(target_os = "linux", target_os = "freebsd"))] fn main() { linux_only::main(); } diff --git a/tests/fixtures/rust/src/bin/interop_uds.rs b/tests/fixtures/rust/src/bin/interop_uds.rs index c2da4b2d..0db6adf7 100644 --- a/tests/fixtures/rust/src/bin/interop_uds.rs +++ b/tests/fixtures/rust/src/bin/interop_uds.rs @@ -226,9 +226,9 @@ mod posix_only { let args: Vec = std::env::args().collect(); if args.len() < 4 { eprintln!( - "Usage:\n {} server \n {} client \n {} pipeline-server \n {} pipeline-client ", - args[0], args[0], args[0], args[0] - ); + "Usage:\n {} server \n {} client \n {} pipeline-server \n {} pipeline-client ", + args[0], args[0], args[0], args[0] + ); std::process::exit(1); } diff --git a/tests/fixtures/rust/src/bin/interop_win_shm.rs b/tests/fixtures/rust/src/bin/interop_win_shm.rs index 4f8193ac..c1631ad3 100644 --- a/tests/fixtures/rust/src/bin/interop_win_shm.rs +++ b/tests/fixtures/rust/src/bin/interop_win_shm.rs @@ -10,7 +10,7 @@ #[cfg(windows)] use netipc::protocol::{self, Header}; #[cfg(windows)] -use netipc::transport::win_shm::{WinShmContext, PROFILE_HYBRID}; +use netipc::transport::win_shm::{PROFILE_HYBRID, WinShmContext}; #[cfg(windows)] const AUTH_TOKEN: u64 = 0xDEADBEEFCAFEBABE; @@ -75,23 +75,8 @@ fn run_server(run_dir: &str, service: &str) -> Result<(), Box Result<(), Box> { - // Retry attach -- server may not be fully ready yet - let mut ctx = None; - for _ in 0..500 { - let attempt = WinShmContext::client_attach(run_dir, service, AUTH_TOKEN, 1, PROFILE_HYBRID); - match attempt { - Ok(c) => { - ctx = Some(c); - break; - } - Err(netipc::transport::win_shm::WinShmError::OpenMapping(_)) - | Err(netipc::transport::win_shm::WinShmError::BadMagic) => { - std::thread::sleep(std::time::Duration::from_millis(10)); - } - Err(e) => return Err(e.into()), - } - } - let mut ctx = ctx.ok_or("client: attach failed after retries")?; + // The server prints READY only after creating the region. + let mut ctx = WinShmContext::client_attach(run_dir, service, AUTH_TOKEN, 1, PROFILE_HYBRID)?; // Build payload with known pattern let payload: Vec = (0..256).map(|i| (i & 0xFF) as u8).collect(); diff --git a/tests/generate-codacy-coverage.sh b/tests/generate-codacy-coverage.sh index 71ff8505..de41a237 100755 --- a/tests/generate-codacy-coverage.sh +++ b/tests/generate-codacy-coverage.sh @@ -147,7 +147,7 @@ generate_rust_coverage() { ( cd "$RUST_CRATE_DIR" run cargo llvm-cov clean --workspace - run cargo llvm-cov --lib --no-report -- --test-threads=1 + run cargo llvm-cov --lib --no-report run cargo llvm-cov report \ --lcov \ --ignore-filename-regex "$RUST_IGNORE_REGEX" \ diff --git a/tests/interop-lib.sh b/tests/interop-lib.sh new file mode 100644 index 00000000..c121d92c --- /dev/null +++ b/tests/interop-lib.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# +# interop-lib.sh - shared helpers for the cross-language interop scripts. +# +# Fixture servers print READY only once their listener accepts connections +# (or, for L1 SHM, once the region exists), so a script blocks on that line +# instead of polling for it. + +# interop_start_server TIMEOUT_S LOG CMD [ARGS...] +# +# Start CMD in the background and return once it printed READY. The server's +# output goes to LOG. Sets INTEROP_SERVER_PID. Returns 1 when the server +# exited, or printed nothing for TIMEOUT_S seconds, before READY. +# +# Runs on bash 3.2 (macOS): no {fd} redirections, and no $! for a process +# substitution. The wrapper shell prints its PID, then execs CMD, so the PID +# is the server's. +interop_start_server() { + local timeout_s="$1" + local log="$2" + shift 2 + local line + + : > "$log" + INTEROP_SERVER_EXE="$(interop_command_binary "$@")" + INTEROP_SERVER_PID="" + INTEROP_DRAIN_PID="" + exec 9< <(exec sh -c 'echo "$$"; exec "$@"' sh "$@" 2>&1) + if ! IFS= read -r -t "$timeout_s" -u 9 INTEROP_SERVER_PID; then + exec 9<&- + return 1 + fi + local ready=1 + while IFS= read -r -t "$timeout_s" -u 9 line; do + printf '%s\n' "$line" >> "$log" + if [[ "$line" == "READY" ]]; then + ready=0 + break + fi + done + # Keep draining so later server output never blocks on the pipe. The + # drain ends when the server exits, so waiting for it waits for the + # server too, also for one that never got ready and was killed. + cat <&9 >> "$log" & + INTEROP_DRAIN_PID=$! + exec 9<&- + return "$ready" +} + +# interop_mktemp_run_dir PREFIX +# +# Create and print a private run directory. macOS's $TMPDIR is long enough +# to push a socket path past the 104-byte sun_path limit, so there the +# directory goes under /tmp. +interop_mktemp_run_dir() { + local root="${TMPDIR:-/tmp}" + [[ "$(uname -s)" == Darwin ]] && root=/tmp + mktemp -d "${root%/}/$1.XXXXXX" +} + +# interop_command_binary CMD [ARGS...] +# +# Print the basename of the program CMD runs, skipping an env prefix. +interop_command_binary() { + local arg + local skip_env=1 + for arg in "$@"; do + if [[ $skip_env -eq 1 && ( "$arg" == env || "$arg" == *=* ) ]]; then + continue + fi + skip_env=0 + printf '%s\n' "${arg##*/}" + return + done +} + +# interop_pid_runs PID BINARY +# +# Report whether PID still runs BINARY. A server that exited may have been +# reaped already and its PID reused, so a kill must check identity first. +# Under a binfmt interpreter (QEMU for the cross targets) /proc/PID/exe names +# the interpreter, and the program path is argv[1]. Systems without a Linux +# style /proc (FreeBSD, macOS) report the command line through ps. +interop_pid_runs() { + local pid="$1" + local binary="${2%.exe}" + local exe="" + local -a argv=() + [[ -n "$binary" ]] || return 1 + if [[ ! -d "/proc/$pid" ]]; then + read -r exe _ < <(ps -o args= -p "$pid" 2>/dev/null) || exe="" + elif [[ -r "/proc/$pid/exename" ]]; then + exe="$(cat "/proc/$pid/exename" 2>/dev/null)" || exe="" + else + exe="$(readlink "/proc/$pid/exe" 2>/dev/null)" || exe="" + mapfile -d '' -n 2 argv < "/proc/$pid/cmdline" 2>/dev/null || argv=() + fi + exe="${exe##*/}" + [[ "${exe%.exe}" == "$binary" ]] && return 0 + [[ ${#argv[@]} -ge 2 && "${argv[1]##*/}" == "$binary" ]] +} + +# interop_kill PID BINARY +# +# Kill PID at once if it still runs BINARY. On MSYS2 a Windows process blocked +# in a Win32 wait never acts on a POSIX signal, so terminate the Windows +# process instead. +interop_kill() { + local pid="$1" + local binary="$2" + local win_pid="" + interop_pid_runs "$pid" "$binary" || return 0 + if [[ -r "/proc/$pid/winpid" ]]; then + win_pid="$(cat "/proc/$pid/winpid" 2>/dev/null)" || win_pid="" + fi + if [[ -n "$win_pid" ]]; then + "$(interop_taskkill_bin)" //PID "$win_pid" //F >/dev/null 2>&1 || true + else + kill "$pid" 2>/dev/null || true + fi +} + +# taskkill lives in System32, which a minimal PATH may lack. +interop_taskkill_bin() { + if command -v taskkill.exe >/dev/null 2>&1; then + command -v taskkill.exe + else + cygpath -u "${SYSTEMROOT:-C:\\Windows}\\System32\\taskkill.exe" + fi +} + +# interop_stop_server +# +# Kill the server started by interop_start_server and wait for it and its +# output drain. +interop_stop_server() { + if [[ -n "${INTEROP_SERVER_PID:-}" ]]; then + interop_kill "$INTEROP_SERVER_PID" "${INTEROP_SERVER_EXE:-}" + fi + interop_wait_server +} + +# interop_wait_server +# +# Wait for a server that exits on its own: its output drain ends when it +# does. The server is not a child of this shell, so wait cannot reap it. +interop_wait_server() { + if [[ -n "${INTEROP_DRAIN_PID:-}" ]]; then + wait "$INTEROP_DRAIN_PID" 2>/dev/null || true + fi + INTEROP_SERVER_PID="" + INTEROP_DRAIN_PID="" + INTEROP_SERVER_EXE="" +} diff --git a/tests/run-coverage-c-windows.sh b/tests/run-coverage-c-windows.sh index cd42518e..fc003b1c 100644 --- a/tests/run-coverage-c-windows.sh +++ b/tests/run-coverage-c-windows.sh @@ -29,6 +29,7 @@ LIB_SOURCES=( "src/libnetdata/netipc/src/service/netipc_service_apps_lookup.c" "src/libnetdata/netipc/src/service/netipc_service_cgroups_lookup.c" "src/libnetdata/netipc/src/transport/windows/netipc_named_pipe.c" + "src/libnetdata/netipc/src/transport/windows/netipc_win_security.c" "src/libnetdata/netipc/src/transport/windows/netipc_win_shm.c" ) diff --git a/tests/run-coverage-rust-windows.sh b/tests/run-coverage-rust-windows.sh index c98ed883..f9f82c3e 100644 --- a/tests/run-coverage-rust-windows.sh +++ b/tests/run-coverage-rust-windows.sh @@ -126,7 +126,7 @@ run cargo llvm-cov clean --workspace echo -e "${YELLOW}Running Rust unit tests with coverage...${NC}" cd "$ROOT_DIR" -run cargo test --manifest-path "$CRATE_DIR/Cargo.toml" --lib -- --test-threads=1 +run cargo test --manifest-path "$CRATE_DIR/Cargo.toml" --lib echo -e "${YELLOW}Preparing dedicated Windows Rust coverage build...${NC}" run rm -rf "$BUILD_DIR" diff --git a/tests/run-coverage-rust.sh b/tests/run-coverage-rust.sh index c48d731e..d684b356 100755 --- a/tests/run-coverage-rust.sh +++ b/tests/run-coverage-rust.sh @@ -51,7 +51,7 @@ echo -e "${YELLOW}Cleaning previous llvm-cov artifacts...${NC}" run cargo llvm-cov clean --workspace echo -e "${YELLOW}Running tests with llvm-cov coverage...${NC}" -run cargo llvm-cov --lib --no-report -- --test-threads=1 +run cargo llvm-cov --lib --no-report echo echo -e "${CYAN}=== Coverage Report ===${NC}" diff --git a/tests/run-extended-fuzz.sh b/tests/run-extended-fuzz.sh index e73b6e3e..fa69be1b 100755 --- a/tests/run-extended-fuzz.sh +++ b/tests/run-extended-fuzz.sh @@ -2,8 +2,10 @@ # # run-extended-fuzz.sh - Extended fuzz testing for netipc protocol. # -# Runs all fuzz targets with longer durations than CTest: -# - Go: 8 fuzz targets x 60s each +# Fuzz targets are not correctness tests: Go fuzz files build only with the +# netipc_extended tag and Rust proptests are #[ignore]d, so default test runs +# skip them. This script runs them with long durations: +# - Go: 12 fuzz targets x 60s each # - C: standalone fuzz harness with 1MB random data # - Rust: proptest with 100000 cases (via env var) # @@ -40,8 +42,9 @@ run() { printf >&2 "%q " "$@" printf >&2 "${NC}\n" - if ! "$@"; then - local exit_code=$? + local exit_code=0 + "$@" || exit_code=$? + if [ "$exit_code" -ne 0 ]; then echo -e >&2 "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo -e >&2 "${RED}[ERROR]${NC} Command failed with exit code ${exit_code}: ${YELLOW}$1${NC}" echo -e >&2 "${RED} Full command:${NC} $*" @@ -91,7 +94,7 @@ run_go_fuzz() { for target in "${targets[@]}"; do log " ${target} (${FUZZTIME}s)..." local output - if output=$(cd "${GO_PKG_DIR}" && CGO_ENABLED=0 go test \ + if output=$(cd "${GO_PKG_DIR}" && CGO_ENABLED=0 go test -tags netipc_extended \ -fuzz="^${target}$" -fuzztime="${FUZZTIME}s" \ ./pkg/netipc/protocol/ 2>&1); then pass "Go ${target}" @@ -164,7 +167,7 @@ run_rust_fuzz() { log " Running proptest with PROPTEST_CASES=100000..." if (cd "${RUST_CRATE_DIR}" && \ - PROPTEST_CASES=100000 run cargo test protocol -- --test-threads=1 2>&1); then + PROPTEST_CASES=100000 run cargo test proptests -- --ignored --test-threads=1 2>&1); then pass "Rust proptest (100000 cases)" else fail "Rust proptest (100000 cases)" diff --git a/tests/run-extended-stress.sh b/tests/run-extended-stress.sh new file mode 100755 index 00000000..f463d714 --- /dev/null +++ b/tests/run-extended-stress.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# run-extended-stress.sh - Stress, chaos, scale and benchmark-policy suites. +# +# These suites are not correctness tests. Default ctest, cargo and go test +# runs exclude them; run this script before Netdata vendoring when a change +# can affect what they exercise (see AGENTS.md Test Cadence). +# +# Usage: +# ./tests/run-extended-stress.sh [build_dir] +# +# Default build dir: build-extended (configured with NETIPC_EXTENDED_TESTS=ON). + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/run-low-priority.sh" +netipc_low_priority_self "$@" + +RED='\033[0;31m' +YELLOW='\033[1;33m' +GRAY='\033[0;90m' +NC='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BUILD_DIR="${1:-${ROOT_DIR}/build-extended}" + +run() { + printf >&2 "${GRAY}$(pwd) >${NC} " + printf >&2 "${YELLOW}" + printf >&2 "%q " "$@" + printf >&2 "${NC}\n" + + local exit_code=0 + "$@" || exit_code=$? + if [ "$exit_code" -ne 0 ]; then + echo -e >&2 "${RED}[ERROR]${NC} Command failed with exit code ${exit_code}: ${YELLOW}$1${NC}" + echo -e >&2 "${RED} Full command:${NC} $*" + echo -e >&2 "${RED} Working dir:${NC} $(pwd)" + return $exit_code + fi +} + +cd "$ROOT_DIR" +run cmake -S . -B "$BUILD_DIR" -DNETIPC_EXTENDED_TESTS=ON +run cmake --build "$BUILD_DIR" -j"$(nproc 2>/dev/null || echo 4)" +run ctest --test-dir "$BUILD_DIR" --output-on-failure \ + -R '^(test_stress.*|test_win_stress|test_chaos_extended|test_lookup_scale.*|test_windows_bench_stability_policy)$' diff --git a/tests/run-go-race.sh b/tests/run-go-race.sh index 77e641bc..d48923fc 100755 --- a/tests/run-go-race.sh +++ b/tests/run-go-race.sh @@ -23,8 +23,9 @@ run() { printf >&2 "${YELLOW}" printf >&2 "%q " "$@" printf >&2 "${NC}\n" - if ! "$@"; then - local exit_code=$? + local exit_code=0 + "$@" || exit_code=$? + if [ "$exit_code" -ne 0 ]; then echo -e >&2 "${RED}[ERROR]${NC} Command failed with exit code ${exit_code}: $*" return $exit_code fi diff --git a/tests/run-linux-target.sh b/tests/run-linux-target.sh new file mode 100755 index 00000000..ee310e52 --- /dev/null +++ b/tests/run-linux-target.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +# +# run-linux-target.sh - Build and run the C, Rust and Go suites plus the +# cross-language interop tests for one Netdata-supported Linux target. +# +# Usage: tests/run-linux-target.sh TARGET [c] [rust] [go] [interop] +# +# TARGET is one of: +# x86_64-musl i386-gnu i386-musl armv7-gnu armv7-gnu-time64 +# armv7-musl armv6-musl aarch64-gnu aarch64-musl +# (x86_64 glibc is the host build covered by the regular CTest jobs.) +# 32-bit glibc C builds use the legacy 32-bit time_t/off_t layout, as Netdata +# builds without _TIME_BITS/_FILE_OFFSET_BITS on Debian 12; armv7-gnu-time64 +# is the layout newer distributions' compilers default to. +# Without parts, all four run. Every part runs even if an earlier one fails; +# the exit status is non-zero if any part failed. +# +# Requirements: +# - glibc cross compilers: i686-linux-gnu-gcc, arm-linux-gnueabihf-gcc, +# aarch64-linux-gnu-gcc (a versioned name such as -gcc-14 is also found); +# - zig on PATH for musl C builds; +# - rustup targets for the selected target; Go; cmake and ninja; +# - QEMU user-mode binfmt handlers for foreign architectures. binfmt is +# required rather than an explicit runner because tests re-exec their own +# binaries and interop scripts start foreign servers and clients. +# +# All binaries are linked statically, so no target sysroot is needed at run +# time. QEMU emulates the baseline CPU of each target (arm1176, cortex-a7, +# cortex-a53) rather than its default "max" model, so an instruction newer +# than the target baseline traps. +# +# Environment: +# NIPC_TARGET_BUILD_ROOT build directory root (default: build-targets/) +# NIPC_TARGET_CC override the C compiler for the target +# NIPC_TARGET_RUSTUP_ADD set to 1 to run `rustup target add` (CI) + +set -uo pipefail + +# shellcheck disable=SC1091 +source "$(dirname "${BASH_SOURCE[0]}")/run-low-priority.sh" +netipc_low_priority_self "$@" + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) + +usage() { + sed -n '3,16p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2 + exit 2 +} + +(( $# >= 1 )) || usage +TARGET=$1 +shift +PARTS=("$@") +(( ${#PARTS[@]} > 0 )) || PARTS=(c rust go interop) + +LIBC=musl +[[ "$TARGET" == *-gnu* ]] && LIBC=gnu +C_LAYOUT_FLAGS= +case "$TARGET" in + i386-gnu|armv7-gnu) C_LAYOUT_FLAGS="-U_TIME_BITS -U_FILE_OFFSET_BITS" ;; + armv7-gnu-time64) C_LAYOUT_FLAGS="-D_FILE_OFFSET_BITS=64 -D_TIME_BITS=64" ;; +esac +case "$TARGET" in + x86_64-musl) + PROC=x86_64; RUST_TARGET=x86_64-unknown-linux-musl + ZIG_TARGET=x86_64-linux-musl; ZIG_CPU=; GNU_PREFIX= + GOARCH_VAL=amd64; GOARM_VAL=; BINFMT= ;; + i386-gnu|i386-musl) + PROC=i686; RUST_TARGET=i686-unknown-linux-$LIBC + ZIG_TARGET=x86-linux-musl; ZIG_CPU=i686; GNU_PREFIX=i686-linux-gnu + GOARCH_VAL=386; GOARM_VAL=; BINFMT= ;; + armv7-gnu|armv7-gnu-time64) + PROC=arm; RUST_TARGET=armv7-unknown-linux-gnueabihf + ZIG_TARGET=; ZIG_CPU=; GNU_PREFIX=arm-linux-gnueabihf + GOARCH_VAL=arm; GOARM_VAL=7; BINFMT=qemu-arm + export QEMU_CPU=cortex-a7 ;; + armv7-musl) + PROC=arm; RUST_TARGET=armv7-unknown-linux-musleabihf + ZIG_TARGET=arm-linux-musleabihf; ZIG_CPU=cortex_a7; GNU_PREFIX=arm-linux-gnueabihf + GOARCH_VAL=arm; GOARM_VAL=7; BINFMT=qemu-arm + export QEMU_CPU=cortex-a7 ;; + armv6-musl) + PROC=arm; RUST_TARGET=arm-unknown-linux-musleabihf + ZIG_TARGET=arm-linux-musleabihf; ZIG_CPU=arm1176jzf_s; GNU_PREFIX=arm-linux-gnueabihf + GOARCH_VAL=arm; GOARM_VAL=6; BINFMT=qemu-arm + export QEMU_CPU=arm1176 ;; + aarch64-gnu|aarch64-musl) + PROC=aarch64; RUST_TARGET=aarch64-unknown-linux-$LIBC + ZIG_TARGET=aarch64-linux-musl; ZIG_CPU=; GNU_PREFIX=aarch64-linux-gnu + GOARCH_VAL=arm64; GOARM_VAL=; BINFMT=qemu-aarch64 + export QEMU_CPU=cortex-a53 ;; + *) + usage ;; +esac + +BUILD_ROOT=${NIPC_TARGET_BUILD_ROOT:-$ROOT_DIR/build-targets} +OUT=$BUILD_ROOT/$TARGET +mkdir -p "$OUT/bin" || exit 1 +OUT=$(cd "$OUT" && pwd) + +FAILED=() +fail() { FAILED+=("$1"); printf '\n*** FAILED: %s (%s)\n' "$1" "$TARGET" >&2; } +section() { printf '\n===== %s: %s =====\n' "$TARGET" "$1"; } + +find_gnu_tool() { + local tool=$1 candidate + for candidate in "$GNU_PREFIX-$tool" \ + $(compgen -c "$GNU_PREFIX-$tool-" | grep -E -- "-$tool-[0-9]+$" | sort -rV); do + if command -v "$candidate" >/dev/null 2>&1; then + command -v "$candidate" + return 0 + fi + done + echo "missing $GNU_PREFIX-$tool" >&2 + return 1 +} + +if [[ -n "$BINFMT" && ! -e "/proc/sys/fs/binfmt_misc/$BINFMT" ]]; then + echo "missing binfmt handler $BINFMT (install qemu-user-binfmt)" >&2 + exit 1 +fi + +# Target C compiler: glibc cross gcc, or zig cc for musl. +if [[ -n "${NIPC_TARGET_CC:-}" ]]; then + TARGET_CC=$NIPC_TARGET_CC +elif [[ "$LIBC" == gnu ]]; then + TARGET_CC=$(find_gnu_tool gcc) || exit 1 +else + command -v zig >/dev/null 2>&1 || { echo "zig is required for musl C builds" >&2; exit 1; } + TARGET_CC=$OUT/zig-cc + { + echo '#!/bin/sh' + echo "exec zig cc -target $ZIG_TARGET${ZIG_CPU:+ -mcpu=$ZIG_CPU} \"\$@\"" + } > "$TARGET_CC" + chmod +x "$TARGET_CC" +fi + +# Rust links musl targets with Rust's self-contained musl; any gcc for the +# architecture works as the linker driver. +RUST_LINKER= +if [[ -n "$GNU_PREFIX" ]]; then + RUST_LINKER=$(find_gnu_tool gcc) || exit 1 +fi +if [[ "${NIPC_TARGET_RUSTUP_ADD:-0}" == 1 ]]; then + rustup target add "$RUST_TARGET" || exit 1 +fi +RUST_TARGET_ENV=$(echo "$RUST_TARGET" | tr 'a-z-' 'A-Z_') +export CARGO_TARGET_DIR=$OUT/cargo +if [[ -n "$RUST_LINKER" ]]; then + export "CARGO_TARGET_${RUST_TARGET_ENV}_LINKER=$RUST_LINKER" +fi +RUST_STATIC_FLAGS="-C target-feature=+crt-static" +# Rust 1.99 links static armv7 glibc binaries as static-pie, which needs +# rcrt1.o; Debian and Ubuntu armhf cross libcs do not ship it. +case "$TARGET" in + armv7-gnu|armv7-gnu-time64) RUST_STATIC_FLAGS="$RUST_STATIC_FLAGS -C relocation-model=static" ;; +esac +export "CARGO_TARGET_${RUST_TARGET_ENV}_RUSTFLAGS=$RUST_STATIC_FLAGS" + +run_c() { + section "C build" + local b=$OUT/c + local emulator=() + if [[ -n "$BINFMT" ]]; then + emulator=("-DCMAKE_CROSSCOMPILING_EMULATOR=$BINFMT") + fi + if ! cmake -S "$ROOT_DIR" -B "$b" -G Ninja \ + -DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR="$PROC" \ + -DCMAKE_C_COMPILER="$TARGET_CC" \ + "-DCMAKE_C_FLAGS=$C_LAYOUT_FLAGS" \ + -DCMAKE_EXE_LINKER_FLAGS=-static \ + -DCARGO_EXECUTABLE=CARGO_EXECUTABLE-NOTFOUND \ + -DGO_EXECUTABLE=GO_EXECUTABLE-NOTFOUND \ + "${emulator[@]}" >/dev/null || + ! cmake --build "$b"; then + fail "C build" + return + fi + section "C tests" + # Correctness only: fuzz, stress and scale suites are not registered by + # default and run from tests/run-extended-*.sh (AGENTS.md Test Cadence). + ctest --test-dir "$b" --output-on-failure --timeout 300 || fail "C tests" +} + +run_rust() { + section "Rust build" + local manifest=$ROOT_DIR/src/crates/netipc/Cargo.toml + if ! cargo build --manifest-path "$manifest" --target "$RUST_TARGET" \ + --bin interop_uds --bin interop_shm --bin interop_service --bin interop_cache || + ! cargo test --manifest-path "$manifest" --target "$RUST_TARGET" --lib --no-run; then + fail "Rust build" + return + fi + section "Rust tests" + cargo test --manifest-path "$manifest" --target "$RUST_TARGET" --lib || fail "Rust tests" +} + +run_go() { + # Pure Go does not link libc: run it once per architecture, on the gnu + # target, except ARMv6, which only exists as a musl target. + if [[ "$LIBC" == musl && "$TARGET" != armv6-musl ]]; then + section "Go (skipped: pure Go does not link libc; covered by the gnu target)" + return + fi + section "Go tests" + ( + cd "$ROOT_DIR/src/go" && + CGO_ENABLED=0 GOARCH=$GOARCH_VAL GOARM=$GOARM_VAL go test ./... + ) || fail "Go tests" +} + +run_interop() { + section "Interop build" + local rs=$CARGO_TARGET_DIR/$RUST_TARGET/debug name + if [[ ! -x "$OUT/c/bin/interop_uds_c" ]]; then + cmake --build "$OUT/c" --target interop_uds_c interop_shm_c \ + interop_service_c interop_cache_c 2>/dev/null || + { fail "Interop build (run the c part first)"; return; } + fi + if [[ ! -x "$rs/interop_uds" ]]; then + cargo build --manifest-path "$ROOT_DIR/src/crates/netipc/Cargo.toml" \ + --target "$RUST_TARGET" --bin interop_uds --bin interop_shm \ + --bin interop_service --bin interop_cache || + { fail "Interop build (Rust)"; return; } + fi + for name in interop_uds interop_shm interop_service interop_cache; do + ( + cd "$ROOT_DIR/tests/fixtures/go" && + CGO_ENABLED=0 GOARCH=$GOARCH_VAL GOARM=$GOARM_VAL \ + go build -o "$OUT/bin/${name}_go" "./cmd/$name" + ) || { fail "Interop build (Go $name)"; return; } + done + + local c=$OUT/c/bin g=$OUT/bin script + export INTEROP_UDS_C=$c/interop_uds_c INTEROP_UDS_RS=$rs/interop_uds INTEROP_UDS_GO=$g/interop_uds_go + export INTEROP_SHM_C=$c/interop_shm_c INTEROP_SHM_RS=$rs/interop_shm INTEROP_SHM_GO=$g/interop_shm_go + export INTEROP_SVC_C=$c/interop_service_c INTEROP_SVC_RS=$rs/interop_service INTEROP_SVC_GO=$g/interop_service_go + export INTEROP_CACHE_C=$c/interop_cache_c INTEROP_CACHE_RS=$rs/interop_cache INTEROP_CACHE_GO=$g/interop_cache_go + for script in test_uds_interop test_shm_interop test_service_interop \ + test_service_shm_interop test_cache_interop test_cache_shm_interop; do + section "Interop $script" + bash "$ROOT_DIR/tests/$script.sh" || fail "Interop $script" + done +} + +for part in "${PARTS[@]}"; do + case "$part" in + c) run_c ;; + rust) run_rust ;; + go) run_go ;; + interop) run_interop ;; + *) usage ;; + esac +done + +printf '\n===== %s summary =====\n' "$TARGET" +if (( ${#FAILED[@]} > 0 )); then + printf 'FAILED: %s\n' "${FAILED[@]}" + exit 1 +fi +echo "all parts passed" diff --git a/tests/run-posix-bench.sh b/tests/run-posix-bench.sh index ff64559e..afa3bfe6 100755 --- a/tests/run-posix-bench.sh +++ b/tests/run-posix-bench.sh @@ -573,7 +573,7 @@ run_pair_once_to_row() { local retry_csv="${retry_run_dir}/row.csv" local status=0 - mkdir -p "$retry_run_dir" + mkdir -p -m 0700 "$retry_run_dir" echo "scenario,client,server,target_rps,throughput,p50_us,p95_us,p99_us,client_cpu_pct,server_cpu_pct,total_cpu_pct" > "$retry_csv" OUTPUT_CSV="$retry_csv" @@ -902,7 +902,7 @@ main() { exit 1 fi - mkdir -p "$RUN_DIR" + mkdir -p -m 0700 "$RUN_DIR" # CSV header echo "scenario,client,server,target_rps,throughput,p50_us,p95_us,p99_us,client_cpu_pct,server_cpu_pct,total_cpu_pct" > "$OUTPUT_CSV" diff --git a/tests/run-sanitizer-asan.sh b/tests/run-sanitizer-asan.sh index 6ceaebd5..c618947d 100755 --- a/tests/run-sanitizer-asan.sh +++ b/tests/run-sanitizer-asan.sh @@ -24,8 +24,9 @@ run() { printf >&2 "${YELLOW}" printf >&2 "%q " "$@" printf >&2 "${NC}\n" - if ! "$@"; then - local exit_code=$? + local exit_code=0 + "$@" || exit_code=$? + if [ "$exit_code" -ne 0 ]; then echo -e >&2 "${RED}[ERROR]${NC} Command failed with exit code ${exit_code}: $*" return $exit_code fi diff --git a/tests/run-sanitizer-tsan.sh b/tests/run-sanitizer-tsan.sh index ffed7fc1..fed98936 100755 --- a/tests/run-sanitizer-tsan.sh +++ b/tests/run-sanitizer-tsan.sh @@ -23,8 +23,9 @@ run() { printf >&2 "${YELLOW}" printf >&2 "%q " "$@" printf >&2 "${NC}\n" - if ! "$@"; then - local exit_code=$? + local exit_code=0 + "$@" || exit_code=$? + if [ "$exit_code" -ne 0 ]; then echo -e >&2 "${RED}[ERROR]${NC} Command failed with exit code ${exit_code}: $*" return $exit_code fi diff --git a/tests/run-valgrind.sh b/tests/run-valgrind.sh index 70eed148..6379579e 100755 --- a/tests/run-valgrind.sh +++ b/tests/run-valgrind.sh @@ -23,8 +23,9 @@ run() { printf >&2 "${YELLOW}" printf >&2 "%q " "$@" printf >&2 "${NC}\n" - if ! "$@"; then - local exit_code=$? + local exit_code=0 + "$@" || exit_code=$? + if [ "$exit_code" -ne 0 ]; then echo -e >&2 "${RED}[ERROR]${NC} Command failed with exit code ${exit_code}: $*" return $exit_code fi diff --git a/tests/run-windows-bench.sh b/tests/run-windows-bench.sh index 8c9084d3..8daad6ad 100755 --- a/tests/run-windows-bench.sh +++ b/tests/run-windows-bench.sh @@ -106,6 +106,35 @@ BENCH_GO="${BUILD_DIR}/bin/bench_windows_go.exe" # Helpers # --------------------------------------------------------------------------- +# taskkill lives in System32, which a minimal PATH may lack. +taskkill_bin() { + if command -v taskkill.exe >/dev/null 2>&1; then + command -v taskkill.exe + else + cygpath -u "${SYSTEMROOT:-C:\\Windows}\\System32\\taskkill.exe" + fi +} + +# MSYS-runtime servers blocked in a Win32 wait never act on SIGTERM, so a +# plain kill can leave them running and a following wait blocks forever. +force_stop_pid() { + local pid="$1" + local winpid="" + local waited=0 + + kill "$pid" 2>/dev/null || true + while kill -0 "$pid" 2>/dev/null && [ "$waited" -lt 20 ]; do + sleep 0.1 + waited=$((waited + 1)) + done + if kill -0 "$pid" 2>/dev/null; then + winpid=$(ps -W | awk -v p="$pid" '$1 == p { print $4; exit }' 2>/dev/null || true) + if [ -n "$winpid" ]; then + "$(taskkill_bin)" //PID "$winpid" //T //F >/dev/null 2>&1 || true + fi + fi +} + cleanup() { if [ "${NIPC_KEEP_RUN_DIR:-0}" = "1" ]; then warn "Preserving RUN_DIR: $ROOT_RUN_DIR" @@ -113,7 +142,7 @@ cleanup() { rm -rf "$ROOT_RUN_DIR" fi for pid in "${SERVER_PIDS[@]:-}"; do - kill "$pid" 2>/dev/null || true + force_stop_pid "$pid" wait "$pid" 2>/dev/null || true done } @@ -319,7 +348,7 @@ start_server() { err "Server $lang ($subcmd) did not print READY within 10s" cat "$server_out" >&2 - kill "$pid" 2>/dev/null || true + force_stop_pid "$pid" return 1 } @@ -374,7 +403,7 @@ stop_server() { if kill -0 "$pid" 2>/dev/null; then forced_stop=1 warn " Server ${lang} (${svc}) did not exit cleanly within ${SERVER_STOP_GRACE_SEC}s; forcing kill" - kill "$pid" 2>/dev/null || true + force_stop_pid "$pid" fi if ! wait "$pid" 2>/dev/null; then diff --git a/tests/run-windows-msys-validation.sh b/tests/run-windows-msys-validation.sh index 5f356256..68e67ad6 100644 --- a/tests/run-windows-msys-validation.sh +++ b/tests/run-windows-msys-validation.sh @@ -26,6 +26,7 @@ FUNCTIONAL_TESTS=( test_win_shm test_win_service test_win_service_payload_limits + test_win_robustness test_win_service_extra test_named_pipe_interop test_win_shm_interop @@ -41,6 +42,7 @@ BUILD_TARGETS=( test_win_shm test_win_service test_win_service_payload_limits + test_win_robustness test_win_service_extra test_win_stress interop_named_pipe_c diff --git a/tests/test_cache_interop.sh b/tests/test_cache_interop.sh index 9ff637d6..9d9a6253 100755 --- a/tests/test_cache_interop.sh +++ b/tests/test_cache_interop.sh @@ -19,6 +19,7 @@ set -euo pipefail source "$(dirname "${BASH_SOURCE[0]}")/run-low-priority.sh" +source "$(dirname "${BASH_SOURCE[0]}")/interop-lib.sh" netipc_low_priority_self "$@" RED='\033[0;31m' @@ -52,7 +53,7 @@ if [[ -x "$INTEROP_CACHE_GO" ]]; then HAS_GO=1 fi -RUN_DIR="$(mktemp -d "${TMPDIR:-/tmp}/nipc_cache_interop.XXXXXX")" +RUN_DIR="$(interop_mktemp_run_dir nipc_cache_interop)" cleanup() { local pids @@ -104,30 +105,12 @@ run_test() { rm -f "${RUN_DIR}/${service}-"*.ipcshm # Start server in background, wait for READY - local server_pid local server_log="${RUN_DIR}/${service}.server.log" - env NIPC_PROFILE="${NIPC_PROFILE:-}" "$server_bin" server "$RUN_DIR" "$service" > "$server_log" 2>&1 & - server_pid=$! - - local waited=0 - while [[ $waited -lt $((TIMEOUT * 10)) ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - echo -e "${RED}FAIL${NC} (server exited early)" - cat "$server_log" >&2 2>/dev/null || true - FAIL=$((FAIL + 1)) - return - fi - if grep -q "^READY$" "$server_log" 2>/dev/null; then - break - fi - sleep 0.1 - waited=$((waited + 1)) - done - - if [[ $waited -ge $((TIMEOUT * 10)) ]]; then - echo -e "${RED}FAIL${NC} (server not ready after ${TIMEOUT}s)" - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true + if ! interop_start_server "$TIMEOUT" "$server_log" \ + env NIPC_PROFILE="${NIPC_PROFILE:-}" "$server_bin" server "$RUN_DIR" "$service"; then + echo -e "${RED}FAIL${NC} (server not ready)" + cat "$server_log" >&2 2>/dev/null || true + interop_stop_server FAIL=$((FAIL + 1)) return fi @@ -147,9 +130,7 @@ run_test() { FAIL=$((FAIL + 1)) fi - # Kill server and wait - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true + interop_stop_server } main() { diff --git a/tests/test_cache_win_interop.sh b/tests/test_cache_win_interop.sh index 0d539d5f..56fdc352 100644 --- a/tests/test_cache_win_interop.sh +++ b/tests/test_cache_win_interop.sh @@ -15,6 +15,7 @@ set -euo pipefail source "$(dirname "${BASH_SOURCE[0]}")/run-low-priority.sh" +source "$(dirname "${BASH_SOURCE[0]}")/interop-lib.sh" netipc_low_priority_self "$@" RED='\033[0;31m' @@ -26,7 +27,6 @@ PASS=0 FAIL=0 SKIP=0 TIMEOUT=10 -CLIENT_FAIL_REASON="" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -48,59 +48,11 @@ else fi cleanup() { - local pids - pids=$(jobs -p 2>/dev/null) || true - if [[ -n "$pids" ]]; then - for pid in $pids; do - stop_server "$pid" >/dev/null 2>&1 || true - done - fi + interop_stop_server rm -rf "$RUN_DIR_HOST" } trap cleanup EXIT -stop_server() { - local server_pid="$1" - local waited=0 - local win_pid - - if ! kill -0 "$server_pid" 2>/dev/null; then - wait "$server_pid" 2>/dev/null || true - return 0 - fi - - kill "$server_pid" 2>/dev/null || true - - while [[ $waited -lt $((TIMEOUT * 10)) ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - wait "$server_pid" 2>/dev/null || true - return 0 - fi - sleep 0.1 - waited=$((waited + 1)) - done - - win_pid=$(ps -W 2>/dev/null | awk -v pid="$server_pid" '$1 == pid {print $4; exit}') - if [[ -n "$win_pid" ]] && command -v taskkill.exe >/dev/null 2>&1; then - taskkill.exe //PID "$win_pid" //T //F >/dev/null 2>&1 || true - elif [[ -n "$win_pid" ]] && command -v taskkill >/dev/null 2>&1; then - taskkill //PID "$win_pid" //T //F >/dev/null 2>&1 || true - fi - - waited=0 - while [[ $waited -lt 50 ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - wait "$server_pid" 2>/dev/null || true - return 0 - fi - sleep 0.1 - waited=$((waited + 1)) - done - - wait "$server_pid" 2>/dev/null || true - return 1 -} - check_binaries() { local missing=0 if [[ ! -x "$INTEROP_CACHE_C" ]]; then @@ -120,44 +72,6 @@ check_binaries() { fi } -run_client_with_ready_retry() { - local client_bin="$1" - local service="$2" - local max_attempts=$((TIMEOUT * 10)) - local attempt - local client_out - local rc - - CLIENT_FAIL_REASON="" - - for ((attempt = 1; attempt <= max_attempts; attempt++)); do - set +e - client_out=$(env NIPC_PROFILE="${NIPC_PROFILE:-}" "$client_bin" client "$RUN_DIR" "$service" 2>&1) - rc=$? - set -e - - if [[ $rc -eq 0 ]] && echo "$client_out" | grep -q "^PASS$"; then - return 0 - fi - - if echo "$client_out" | grep -q "^client: not ready$" && - [[ $attempt -lt $max_attempts ]]; then - sleep 0.1 - continue - fi - - if [[ $rc -eq 0 ]]; then - CLIENT_FAIL_REASON="client output: $client_out" - else - CLIENT_FAIL_REASON="client exit code $rc, output: $client_out" - fi - return 1 - done - - CLIENT_FAIL_REASON="client did not become ready after ${TIMEOUT}s" - return 1 -} - run_test() { local name="$1" local server_bin="$2" @@ -172,53 +86,30 @@ run_test() { echo -n " $name ... " - local server_pid local server_log="${RUN_DIR_HOST}/${service}.server.log" - env NIPC_PROFILE="${NIPC_PROFILE:-}" "$server_bin" server "$RUN_DIR" "$service" > "$server_log" 2>&1 & - server_pid=$! - - local waited=0 - while [[ $waited -lt $((TIMEOUT * 10)) ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - echo -e "${RED}FAIL${NC} (server exited early)" - cat "$server_log" >&2 2>/dev/null || true - FAIL=$((FAIL + 1)) - return - fi - if grep -q "^READY$" "$server_log" 2>/dev/null; then - break - fi - sleep 0.1 - waited=$((waited + 1)) - done - - if [[ $waited -ge $((TIMEOUT * 10)) ]]; then - echo -e "${RED}FAIL${NC} (server not ready after ${TIMEOUT}s)" - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true + if ! interop_start_server "$TIMEOUT" "$server_log" \ + env NIPC_PROFILE="${NIPC_PROFILE:-}" \ + "$server_bin" server "$RUN_DIR" "$service"; then + echo -e "${RED}FAIL${NC} (server not ready)" + cat "$server_log" >&2 2>/dev/null || true + interop_stop_server FAIL=$((FAIL + 1)) return fi - local test_ok=1 - local fail_reason="" + # The server printed READY after binding, so the client connects once. + local client_out rc + set +e + client_out=$(env NIPC_PROFILE="${NIPC_PROFILE:-}" "$client_bin" client "$RUN_DIR" "$service" 2>&1) + rc=$? + set -e - if ! run_client_with_ready_retry "$client_bin" "$service"; then - test_ok=0 - fail_reason="$CLIENT_FAIL_REASON" - fi - - if ! stop_server "$server_pid"; then - test_ok=0 - if [[ -n "$fail_reason" ]]; then - fail_reason="${fail_reason}; server did not exit cleanly" - else - fail_reason="server did not exit cleanly" - fi - fi + interop_stop_server - if [[ $test_ok -eq 0 ]]; then - echo -e "${RED}FAIL${NC} (${fail_reason})" + if [[ $rc -ne 0 ]] || ! echo "$client_out" | grep -q "^PASS$"; then + echo -e "${RED}FAIL${NC} (client exit code $rc, output: $client_out)" + echo "server output:" >&2 + cat "$server_log" >&2 2>/dev/null || true FAIL=$((FAIL + 1)) return fi diff --git a/tests/test_lookup_scale_interop.sh b/tests/test_lookup_scale_interop.sh index 00cdd8ef..01fd357f 100755 --- a/tests/test_lookup_scale_interop.sh +++ b/tests/test_lookup_scale_interop.sh @@ -9,6 +9,7 @@ set -euo pipefail source "$(dirname "${BASH_SOURCE[0]}")/run-low-priority.sh" +source "$(dirname "${BASH_SOURCE[0]}")/interop-lib.sh" netipc_low_priority_self "$@" RED='\033[0;31m' @@ -42,10 +43,11 @@ if [[ -x "$INTEROP_SVC_GO" ]]; then HAS_GO=1 fi -RUN_DIR="$(mktemp -d "${TMPDIR:-/tmp}/nipc_lookup_scale_interop.XXXXXX")" +RUN_DIR="$(interop_mktemp_run_dir nipc_lookup_scale_interop)" cleanup() { - for pid in "${SERVER_PIDS[@]}"; do + # bash 3.2 treats an empty array as unset under set -u. + for pid in ${SERVER_PIDS[@]+"${SERVER_PIDS[@]}"}; do if kill -0 "$pid" 2>/dev/null; then kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true @@ -74,41 +76,6 @@ check_binaries() { fi } -wait_for_server() { - local server_pid="$1" - local server_log="$2" - local service="$3" - local waited=0 - - while [[ $waited -lt $((TIMEOUT * 10)) ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - return 1 - fi - if grep -q "^READY$" "$server_log" 2>/dev/null; then - break - fi - sleep 0.1 - waited=$((waited + 1)) - done - if [[ $waited -ge $((TIMEOUT * 10)) ]]; then - return 2 - fi - - waited=0 - while [[ $waited -lt $((TIMEOUT * 10)) ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - return 3 - fi - if [[ -S "${RUN_DIR}/${service}.sock" ]]; then - return 0 - fi - sleep 0.1 - waited=$((waited + 1)) - done - - return 4 -} - run_lookup_test() { local method="$1" local name="$2" @@ -130,19 +97,17 @@ run_lookup_test() { rm -f "${RUN_DIR}/${service}-"*.ipcshm local server_log="${RUN_DIR}/${service}.server.log" - env NIPC_PROFILE="${NIPC_PROFILE:-}" \ + if ! interop_start_server "$TIMEOUT" "$server_log" \ + env NIPC_PROFILE="${NIPC_PROFILE:-}" \ NIPC_LOOKUP_SCALE_ITEMS="$LOOKUP_ITEMS" \ - "$server_bin" "$server_mode" "$RUN_DIR" "$service" >"$server_log" 2>&1 & - local server_pid=$! - SERVER_PIDS+=("$server_pid") - - if ! wait_for_server "$server_pid" "$server_log" "$service"; then - local rc=$? - echo -e "${RED}FAIL${NC} (server readiness rc=${rc})" + "$server_bin" "$server_mode" "$RUN_DIR" "$service"; then + echo -e "${RED}FAIL${NC} (server not ready)" cat "$server_log" >&2 2>/dev/null || true + interop_stop_server FAIL=$((FAIL + 1)) return fi + SERVER_PIDS+=("$INTEROP_SERVER_PID") local client_out if client_out=$(env NIPC_PROFILE="${NIPC_PROFILE:-}" \ @@ -160,10 +125,7 @@ run_lookup_test() { FAIL=$((FAIL + 1)) fi - if kill -0 "$server_pid" 2>/dev/null; then - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true - fi + interop_stop_server } run_matrix_for_method() { diff --git a/tests/test_lookup_scale_win_interop.sh b/tests/test_lookup_scale_win_interop.sh index cc1b2655..14d829ec 100755 --- a/tests/test_lookup_scale_win_interop.sh +++ b/tests/test_lookup_scale_win_interop.sh @@ -9,6 +9,7 @@ set -euo pipefail source "$(dirname "${BASH_SOURCE[0]}")/run-low-priority.sh" +source "$(dirname "${BASH_SOURCE[0]}")/interop-lib.sh" netipc_low_priority_self "$@" RED='\033[0;31m' @@ -21,8 +22,6 @@ FAIL=0 SKIP=0 TIMEOUT=60 LOOKUP_ITEMS="${NIPC_LOOKUP_SCALE_ITEMS:-8192}" -CLIENT_FAIL_REASON="" -SERVER_PIDS=() SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -44,55 +43,11 @@ else fi cleanup() { - for pid in "${SERVER_PIDS[@]}"; do - stop_server "$pid" >/dev/null 2>&1 || true - done + interop_stop_server rm -rf "$RUN_DIR_HOST" } trap cleanup EXIT -stop_server() { - local server_pid="$1" - local waited=0 - local win_pid - - if ! kill -0 "$server_pid" 2>/dev/null; then - wait "$server_pid" 2>/dev/null || true - return 0 - fi - - kill "$server_pid" 2>/dev/null || true - - while [[ $waited -lt $((TIMEOUT * 10)) ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - wait "$server_pid" 2>/dev/null || true - return 0 - fi - sleep 0.1 - waited=$((waited + 1)) - done - - win_pid=$(ps -W 2>/dev/null | awk -v pid="$server_pid" '$1 == pid {print $4; exit}') - if [[ -n "$win_pid" ]] && command -v taskkill.exe >/dev/null 2>&1; then - taskkill.exe //PID "$win_pid" //T //F >/dev/null 2>&1 || true - elif [[ -n "$win_pid" ]] && command -v taskkill >/dev/null 2>&1; then - taskkill //PID "$win_pid" //T //F >/dev/null 2>&1 || true - fi - - waited=0 - while [[ $waited -lt 50 ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - wait "$server_pid" 2>/dev/null || true - return 0 - fi - sleep 0.1 - waited=$((waited + 1)) - done - - wait "$server_pid" 2>/dev/null || true - return 1 -} - check_binaries() { local missing=0 if [[ ! -x "$INTEROP_SVC_C" ]]; then @@ -112,66 +67,6 @@ check_binaries() { fi } -wait_for_ready() { - local server_pid="$1" - local server_log="$2" - local waited=0 - - while [[ $waited -lt $((TIMEOUT * 10)) ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - return 1 - fi - if grep -q "^READY$" "$server_log" 2>/dev/null; then - return 0 - fi - sleep 0.1 - waited=$((waited + 1)) - done - - return 2 -} - -run_client_with_ready_retry() { - local client_bin="$1" - local client_mode="$2" - local service="$3" - local max_attempts=$((TIMEOUT * 10)) - local attempt - local client_out - local rc - - CLIENT_FAIL_REASON="" - - for ((attempt = 1; attempt <= max_attempts; attempt++)); do - set +e - client_out=$(env NIPC_PROFILE="${NIPC_PROFILE:-}" \ - NIPC_LOOKUP_SCALE_ITEMS="$LOOKUP_ITEMS" \ - "$client_bin" "$client_mode" "$RUN_DIR" "$service" 2>&1) - rc=$? - set -e - - if [[ $rc -eq 0 ]] && echo "$client_out" | grep -q "^PASS$"; then - return 0 - fi - - if echo "$client_out" | grep -q "not ready" && - [[ $attempt -lt $max_attempts ]]; then - sleep 0.1 - continue - fi - - if [[ $rc -eq 0 ]]; then - CLIENT_FAIL_REASON="client output: $client_out" - else - CLIENT_FAIL_REASON="client exit code $rc, output: $client_out" - fi - return 1 - done - - CLIENT_FAIL_REASON="client did not become ready after ${TIMEOUT}s" - return 1 -} - run_lookup_test() { local method="$1" local name="$2" @@ -190,43 +85,30 @@ run_lookup_test() { echo -n " $name ... " local server_log="${RUN_DIR_HOST}/${service}.server.log" - env NIPC_PROFILE="${NIPC_PROFILE:-}" \ + if ! interop_start_server "$TIMEOUT" "$server_log" \ + env NIPC_PROFILE="${NIPC_PROFILE:-}" \ NIPC_LOOKUP_SCALE_ITEMS="$LOOKUP_ITEMS" \ - "$server_bin" "$server_mode" "$RUN_DIR" "$service" >"$server_log" 2>&1 & - local server_pid=$! - SERVER_PIDS+=("$server_pid") - - local ready_rc=0 - set +e - wait_for_ready "$server_pid" "$server_log" - ready_rc=$? - set -e - if [[ $ready_rc -ne 0 ]]; then - echo -e "${RED}FAIL${NC} (server readiness rc=${ready_rc})" + "$server_bin" "$server_mode" "$RUN_DIR" "$service"; then + echo -e "${RED}FAIL${NC} (server not ready)" cat "$server_log" >&2 2>/dev/null || true + interop_stop_server FAIL=$((FAIL + 1)) return fi - local test_ok=1 - local fail_reason="" - - if ! run_client_with_ready_retry "$client_bin" "$client_mode" "$service"; then - test_ok=0 - fail_reason="$CLIENT_FAIL_REASON" - fi + # The server printed READY after binding, so the client connects once. + local client_out rc + set +e + client_out=$(env NIPC_PROFILE="${NIPC_PROFILE:-}" \ + NIPC_LOOKUP_SCALE_ITEMS="$LOOKUP_ITEMS" \ + "$client_bin" "$client_mode" "$RUN_DIR" "$service" 2>&1) + rc=$? + set -e - if ! stop_server "$server_pid"; then - test_ok=0 - if [[ -n "$fail_reason" ]]; then - fail_reason="${fail_reason}; server did not exit cleanly" - else - fail_reason="server did not exit cleanly" - fi - fi + interop_stop_server - if [[ $test_ok -eq 0 ]]; then - echo -e "${RED}FAIL${NC} (${fail_reason})" + if [[ $rc -ne 0 ]] || ! echo "$client_out" | grep -q "^PASS$"; then + echo -e "${RED}FAIL${NC} (client exit code $rc, output: $client_out)" FAIL=$((FAIL + 1)) return fi diff --git a/tests/test_named_pipe_interop.sh b/tests/test_named_pipe_interop.sh index 0acf790d..233b0d51 100755 --- a/tests/test_named_pipe_interop.sh +++ b/tests/test_named_pipe_interop.sh @@ -2,7 +2,9 @@ # # test_named_pipe_interop.sh - Cross-language Named Pipe transport interop tests. # -# Tests all 9 directed pairs: C, Rust, and Go servers and clients. +# Tests all 9 directed pairs: C, Rust, and Go servers and clients, once with +# one request and once with a client that pipelines more than the pipe +# buffers hold before reading any response. # All binaries use identical wire protocol, FNV-1a pipe name derivation, # and auth tokens. # @@ -16,6 +18,7 @@ set -euo pipefail source "$(dirname "${BASH_SOURCE[0]}")/run-low-priority.sh" +source "$(dirname "${BASH_SOURCE[0]}")/interop-lib.sh" netipc_low_priority_self "$@" RED='\033[0;31m' @@ -57,12 +60,7 @@ else fi cleanup() { - local pids - pids=$(jobs -p 2>/dev/null) || true - if [[ -n "$pids" ]]; then - kill $pids 2>/dev/null || true - wait $pids 2>/dev/null || true - fi + interop_stop_server rm -rf "$RUN_DIR_HOST" } trap cleanup EXIT @@ -91,6 +89,7 @@ run_test() { local server_bin="$2" local client_bin="$3" local service="$4" + local mode_prefix="${5:-}" # Skip if either binary is missing if [[ ! -x "$server_bin" || ! -x "$client_bin" ]]; then @@ -101,53 +100,36 @@ run_test() { echo -n " $name ... " - # Start server in background, wait for READY - local server_pid local server_log="${RUN_DIR_HOST}/${service}.server.log" - "$server_bin" server "$RUN_DIR" "$service" > "$server_log" 2>&1 & - server_pid=$! - - # Wait for READY line (up to TIMEOUT seconds) - local waited=0 - while [[ $waited -lt $((TIMEOUT * 10)) ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - echo -e "${RED}FAIL${NC} (server exited early)" - cat "$server_log" >&2 2>/dev/null || true - FAIL=$((FAIL + 1)) - return - fi - if grep -q "^READY$" "$server_log" 2>/dev/null; then - break - fi - sleep 0.1 - waited=$((waited + 1)) - done - - if [[ $waited -ge $((TIMEOUT * 10)) ]]; then - echo -e "${RED}FAIL${NC} (server not ready after ${TIMEOUT}s)" - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true + if ! interop_start_server "$TIMEOUT" "$server_log" \ + "$server_bin" "${mode_prefix}server" "$RUN_DIR" "$service"; then + echo -e "${RED}FAIL${NC} (server not ready)" + cat "$server_log" >&2 2>/dev/null || true + interop_stop_server FAIL=$((FAIL + 1)) return fi - # Run client - local client_out - if client_out=$("$client_bin" client "$RUN_DIR" "$service" 2>&1); then - if echo "$client_out" | grep -q "^PASS$"; then - echo -e "${GREEN}PASS${NC}" - PASS=$((PASS + 1)) - else - echo -e "${RED}FAIL${NC} (client output: $client_out)" - FAIL=$((FAIL + 1)) - fi - else - echo -e "${RED}FAIL${NC} (client exit code $?, output: $client_out)" + # The server printed READY after creating its endpoint, so the client + # connects once. + local client_out rc + set +e + client_out=$("$client_bin" "${mode_prefix}client" "$RUN_DIR" "$service" 2>&1) + rc=$? + set -e + + if [[ $rc -ne 0 ]] || ! echo "$client_out" | grep -q "^PASS$"; then + echo -e "${RED}FAIL${NC} (client exit code $rc, output: $client_out)" FAIL=$((FAIL + 1)) + # The server exits after serving one client; after a client failure + # it may still be waiting for one. + interop_stop_server + return fi - # Wait for server to exit - wait "$server_pid" 2>/dev/null || true + echo -e "${GREEN}PASS${NC}" + PASS=$((PASS + 1)) + interop_wait_server } main() { @@ -161,36 +143,32 @@ main() { echo "Go binary: $INTEROP_NP_GO" echo "" - # --- Same-language baseline pairs (3 tests) --- - - run_test "C server, C client" \ - "$INTEROP_NP_C" "$INTEROP_NP_C" "interop_np_c_c" - - run_test "Rust server, Rust client" \ - "$INTEROP_NP_RS" "$INTEROP_NP_RS" "interop_np_rs_rs" - - run_test "Go server, Go client" \ - "$INTEROP_NP_GO" "$INTEROP_NP_GO" "interop_np_go_go" - - # --- Cross-language pairs (6 tests) --- - - run_test "C server, Rust client" \ - "$INTEROP_NP_C" "$INTEROP_NP_RS" "interop_np_c_rs" - - run_test "Rust server, C client" \ - "$INTEROP_NP_RS" "$INTEROP_NP_C" "interop_np_rs_c" - - run_test "C server, Go client" \ - "$INTEROP_NP_C" "$INTEROP_NP_GO" "interop_np_c_go" - - run_test "Go server, C client" \ - "$INTEROP_NP_GO" "$INTEROP_NP_C" "interop_np_go_c" - - run_test "Rust server, Go client" \ - "$INTEROP_NP_RS" "$INTEROP_NP_GO" "interop_np_rs_go" + local pass + for pass in single pipeline; do + local prefix="" label="" tag="" + if [[ $pass == pipeline ]]; then + prefix="pipeline-" label=" (pipelining)" tag="_pl" + fi - run_test "Go server, Rust client" \ - "$INTEROP_NP_GO" "$INTEROP_NP_RS" "interop_np_go_rs" + run_test "C server, C client${label}" \ + "$INTEROP_NP_C" "$INTEROP_NP_C" "interop_np_c_c${tag}" "$prefix" + run_test "Rust server, Rust client${label}" \ + "$INTEROP_NP_RS" "$INTEROP_NP_RS" "interop_np_rs_rs${tag}" "$prefix" + run_test "Go server, Go client${label}" \ + "$INTEROP_NP_GO" "$INTEROP_NP_GO" "interop_np_go_go${tag}" "$prefix" + run_test "C server, Rust client${label}" \ + "$INTEROP_NP_C" "$INTEROP_NP_RS" "interop_np_c_rs${tag}" "$prefix" + run_test "Rust server, C client${label}" \ + "$INTEROP_NP_RS" "$INTEROP_NP_C" "interop_np_rs_c${tag}" "$prefix" + run_test "C server, Go client${label}" \ + "$INTEROP_NP_C" "$INTEROP_NP_GO" "interop_np_c_go${tag}" "$prefix" + run_test "Go server, C client${label}" \ + "$INTEROP_NP_GO" "$INTEROP_NP_C" "interop_np_go_c${tag}" "$prefix" + run_test "Rust server, Go client${label}" \ + "$INTEROP_NP_RS" "$INTEROP_NP_GO" "interop_np_rs_go${tag}" "$prefix" + run_test "Go server, Rust client${label}" \ + "$INTEROP_NP_GO" "$INTEROP_NP_RS" "interop_np_go_rs${tag}" "$prefix" + done echo "" echo -e "=== Results: ${GREEN}${PASS} passed${NC}, ${RED}${FAIL} failed${NC}, ${YELLOW}${SKIP} skipped${NC} ===" diff --git a/tests/test_service_interop.sh b/tests/test_service_interop.sh index 1d00e010..f132549b 100755 --- a/tests/test_service_interop.sh +++ b/tests/test_service_interop.sh @@ -5,6 +5,7 @@ # Tests all 9 directed pairs: C, Rust, and Go servers and clients. # Each test starts a managed server with a cgroups handler (3 items), # the client connects, calls snapshot, verifies items. +# All pairs run twice: once normally and once with a 300 ms server handler. # # Expects: # $INTEROP_SVC_C path to the C interop_service binary @@ -16,6 +17,7 @@ set -euo pipefail source "$(dirname "${BASH_SOURCE[0]}")/run-low-priority.sh" +source "$(dirname "${BASH_SOURCE[0]}")/interop-lib.sh" netipc_low_priority_self "$@" RED='\033[0;31m' @@ -27,6 +29,7 @@ PASS=0 FAIL=0 SKIP=0 TIMEOUT=10 +HANDLER_DELAY_MS="" # Resolve binary paths SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -49,7 +52,7 @@ if [[ -x "$INTEROP_SVC_GO" ]]; then HAS_GO=1 fi -RUN_DIR="$(mktemp -d "${TMPDIR:-/tmp}/nipc_svc_interop.XXXXXX")" +RUN_DIR="$(interop_mktemp_run_dir nipc_svc_interop)" cleanup() { local pids @@ -101,56 +104,13 @@ run_test() { rm -f "${RUN_DIR}/${service}-"*.ipcshm # Start server in background, wait for READY - local server_pid local server_log="${RUN_DIR}/${service}.server.log" - env NIPC_PROFILE="${NIPC_PROFILE:-}" "$server_bin" server "$RUN_DIR" "$service" > "$server_log" 2>&1 & - server_pid=$! - - # Wait for READY line (up to TIMEOUT seconds) - local waited=0 - while [[ $waited -lt $((TIMEOUT * 10)) ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - echo -e "${RED}FAIL${NC} (server exited early)" - cat "$server_log" >&2 2>/dev/null || true - FAIL=$((FAIL + 1)) - return - fi - if grep -q "^READY$" "$server_log" 2>/dev/null; then - break - fi - sleep 0.1 - waited=$((waited + 1)) - done - - if [[ $waited -ge $((TIMEOUT * 10)) ]]; then - echo -e "${RED}FAIL${NC} (server not ready after ${TIMEOUT}s)" - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true - FAIL=$((FAIL + 1)) - return - fi - - # READY alone is not enough for the Go/Rust fixtures: they emit READY - # just before entering server.Run(), so the socket may not exist yet. - waited=0 - while [[ $waited -lt $((TIMEOUT * 10)) ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - echo -e "${RED}FAIL${NC} (server exited before socket bind)" - cat "$server_log" >&2 2>/dev/null || true - FAIL=$((FAIL + 1)) - return - fi - if [[ -S "${RUN_DIR}/${service}.sock" ]]; then - break - fi - sleep 0.1 - waited=$((waited + 1)) - done - - if [[ $waited -ge $((TIMEOUT * 10)) ]]; then - echo -e "${RED}FAIL${NC} (socket not ready after ${TIMEOUT}s)" - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true + if ! interop_start_server "$TIMEOUT" "$server_log" \ + env NIPC_PROFILE="${NIPC_PROFILE:-}" NIPC_HANDLER_DELAY_MS="$HANDLER_DELAY_MS" \ + "$server_bin" server "$RUN_DIR" "$service"; then + echo -e "${RED}FAIL${NC} (server not ready)" + cat "$server_log" >&2 2>/dev/null || true + interop_stop_server FAIL=$((FAIL + 1)) return fi @@ -170,53 +130,60 @@ run_test() { FAIL=$((FAIL + 1)) fi - # Kill server and wait - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true + interop_stop_server } -main() { - echo "=== L2 Service Cross-Language Interop Tests ===" - echo "" +# Run all 9 directed pairs; HANDLER_DELAY_MS slows every server handler. +run_all_pairs() { + local suffix="$1" + local label="$2" - check_binaries - mkdir -p "$RUN_DIR" + run_test "C server, C client${label}" \ + "$INTEROP_SVC_C" "$INTEROP_SVC_C" "svc_c_c${suffix}" - echo "C binary: $INTEROP_SVC_C" - echo "Rust binary: $INTEROP_SVC_RS" - echo "Go binary: $INTEROP_SVC_GO" - echo "" + run_test "Rust server, Rust client${label}" \ + "$INTEROP_SVC_RS" "$INTEROP_SVC_RS" "svc_rs_rs${suffix}" - # --- Same-language pairs (3 tests) --- + run_test "Go server, Go client${label}" \ + "$INTEROP_SVC_GO" "$INTEROP_SVC_GO" "svc_go_go${suffix}" - run_test "C server, C client" \ - "$INTEROP_SVC_C" "$INTEROP_SVC_C" "svc_c_c" + run_test "C server, Rust client${label}" \ + "$INTEROP_SVC_C" "$INTEROP_SVC_RS" "svc_c_rs${suffix}" - run_test "Rust server, Rust client" \ - "$INTEROP_SVC_RS" "$INTEROP_SVC_RS" "svc_rs_rs" + run_test "Rust server, C client${label}" \ + "$INTEROP_SVC_RS" "$INTEROP_SVC_C" "svc_rs_c${suffix}" - run_test "Go server, Go client" \ - "$INTEROP_SVC_GO" "$INTEROP_SVC_GO" "svc_go_go" + run_test "C server, Go client${label}" \ + "$INTEROP_SVC_C" "$INTEROP_SVC_GO" "svc_c_go${suffix}" - # --- Cross-language pairs (6 tests) --- + run_test "Go server, C client${label}" \ + "$INTEROP_SVC_GO" "$INTEROP_SVC_C" "svc_go_c${suffix}" - run_test "C server, Rust client" \ - "$INTEROP_SVC_C" "$INTEROP_SVC_RS" "svc_c_rs" + run_test "Rust server, Go client${label}" \ + "$INTEROP_SVC_RS" "$INTEROP_SVC_GO" "svc_rs_go${suffix}" - run_test "Rust server, C client" \ - "$INTEROP_SVC_RS" "$INTEROP_SVC_C" "svc_rs_c" + run_test "Go server, Rust client${label}" \ + "$INTEROP_SVC_GO" "$INTEROP_SVC_RS" "svc_go_rs${suffix}" +} - run_test "C server, Go client" \ - "$INTEROP_SVC_C" "$INTEROP_SVC_GO" "svc_c_go" +main() { + echo "=== L2 Service Cross-Language Interop Tests ===" + echo "" - run_test "Go server, C client" \ - "$INTEROP_SVC_GO" "$INTEROP_SVC_C" "svc_go_c" + check_binaries + mkdir -p "$RUN_DIR" + + echo "C binary: $INTEROP_SVC_C" + echo "Rust binary: $INTEROP_SVC_RS" + echo "Go binary: $INTEROP_SVC_GO" + echo "" - run_test "Rust server, Go client" \ - "$INTEROP_SVC_RS" "$INTEROP_SVC_GO" "svc_rs_go" + run_all_pairs "" "" - run_test "Go server, Rust client" \ - "$INTEROP_SVC_GO" "$INTEROP_SVC_RS" "svc_go_rs" + # Handlers slower than the clients' 100 ms abort-poll slice must still + # complete within the call timeout (SOW-0041). + HANDLER_DELAY_MS=300 + run_all_pairs "_slow" " (300 ms handler)" echo "" echo -e "=== Results: ${GREEN}${PASS} passed${NC}, ${RED}${FAIL} failed${NC}, ${YELLOW}${SKIP} skipped${NC} ===" diff --git a/tests/test_service_win_interop.sh b/tests/test_service_win_interop.sh index b1551a20..802aa89b 100644 --- a/tests/test_service_win_interop.sh +++ b/tests/test_service_win_interop.sh @@ -5,6 +5,7 @@ # Tests all 9 directed pairs: C, Rust, and Go servers and clients. # Each test starts a managed server with a cgroups handler (3 items), # the client connects, calls snapshot, verifies items. +# All pairs run twice: once normally and once with a 300 ms server handler. # # Expects: # $INTEROP_SVC_C path to the C interop_service_win binary @@ -16,6 +17,7 @@ set -euo pipefail source "$(dirname "${BASH_SOURCE[0]}")/run-low-priority.sh" +source "$(dirname "${BASH_SOURCE[0]}")/interop-lib.sh" netipc_low_priority_self "$@" RED='\033[0;31m' @@ -27,7 +29,7 @@ PASS=0 FAIL=0 SKIP=0 TIMEOUT=10 -CLIENT_FAIL_REASON="" +HANDLER_DELAY_MS="" # Resolve binary paths SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -50,59 +52,11 @@ else fi cleanup() { - local pids - pids=$(jobs -p 2>/dev/null) || true - if [[ -n "$pids" ]]; then - for pid in $pids; do - stop_server "$pid" >/dev/null 2>&1 || true - done - fi + interop_stop_server rm -rf "$RUN_DIR_HOST" } trap cleanup EXIT -stop_server() { - local server_pid="$1" - local waited=0 - local win_pid - - if ! kill -0 "$server_pid" 2>/dev/null; then - wait "$server_pid" 2>/dev/null || true - return 0 - fi - - kill "$server_pid" 2>/dev/null || true - - while [[ $waited -lt $((TIMEOUT * 10)) ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - wait "$server_pid" 2>/dev/null || true - return 0 - fi - sleep 0.1 - waited=$((waited + 1)) - done - - win_pid=$(ps -W 2>/dev/null | awk -v pid="$server_pid" '$1 == pid {print $4; exit}') - if [[ -n "$win_pid" ]] && command -v taskkill.exe >/dev/null 2>&1; then - taskkill.exe //PID "$win_pid" //T //F >/dev/null 2>&1 || true - elif [[ -n "$win_pid" ]] && command -v taskkill >/dev/null 2>&1; then - taskkill //PID "$win_pid" //T //F >/dev/null 2>&1 || true - fi - - waited=0 - while [[ $waited -lt 50 ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - wait "$server_pid" 2>/dev/null || true - return 0 - fi - sleep 0.1 - waited=$((waited + 1)) - done - - wait "$server_pid" 2>/dev/null || true - return 1 -} - check_binaries() { local missing=0 if [[ ! -x "$INTEROP_SVC_C" ]]; then @@ -122,44 +76,6 @@ check_binaries() { fi } -run_client_with_ready_retry() { - local client_bin="$1" - local service="$2" - local max_attempts=$((TIMEOUT * 10)) - local attempt - local client_out - local rc - - CLIENT_FAIL_REASON="" - - for ((attempt = 1; attempt <= max_attempts; attempt++)); do - set +e - client_out=$(env NIPC_PROFILE="${NIPC_PROFILE:-}" "$client_bin" client "$RUN_DIR" "$service" 2>&1) - rc=$? - set -e - - if [[ $rc -eq 0 ]] && echo "$client_out" | grep -q "^PASS$"; then - return 0 - fi - - if echo "$client_out" | grep -q "^client: not ready$" && - [[ $attempt -lt $max_attempts ]]; then - sleep 0.1 - continue - fi - - if [[ $rc -eq 0 ]]; then - CLIENT_FAIL_REASON="client output: $client_out" - else - CLIENT_FAIL_REASON="client exit code $rc, output: $client_out" - fi - return 1 - done - - CLIENT_FAIL_REASON="client did not become ready after ${TIMEOUT}s" - return 1 -} - run_test() { local name="$1" local server_bin="$2" @@ -174,53 +90,28 @@ run_test() { echo -n " $name ... " - local server_pid local server_log="${RUN_DIR_HOST}/${service}.server.log" - env NIPC_PROFILE="${NIPC_PROFILE:-}" "$server_bin" server "$RUN_DIR" "$service" > "$server_log" 2>&1 & - server_pid=$! - - local waited=0 - while [[ $waited -lt $((TIMEOUT * 10)) ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - echo -e "${RED}FAIL${NC} (server exited early)" - cat "$server_log" >&2 2>/dev/null || true - FAIL=$((FAIL + 1)) - return - fi - if grep -q "^READY$" "$server_log" 2>/dev/null; then - break - fi - sleep 0.1 - waited=$((waited + 1)) - done - - if [[ $waited -ge $((TIMEOUT * 10)) ]]; then - echo -e "${RED}FAIL${NC} (server not ready after ${TIMEOUT}s)" - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true + if ! interop_start_server "$TIMEOUT" "$server_log" \ + env NIPC_PROFILE="${NIPC_PROFILE:-}" NIPC_HANDLER_DELAY_MS="$HANDLER_DELAY_MS" \ + "$server_bin" server "$RUN_DIR" "$service"; then + echo -e "${RED}FAIL${NC} (server not ready)" + cat "$server_log" >&2 2>/dev/null || true + interop_stop_server FAIL=$((FAIL + 1)) return fi - local test_ok=1 - local fail_reason="" - - if ! run_client_with_ready_retry "$client_bin" "$service"; then - test_ok=0 - fail_reason="$CLIENT_FAIL_REASON" - fi + # The server printed READY after binding, so the client connects once. + local client_out rc + set +e + client_out=$(env NIPC_PROFILE="${NIPC_PROFILE:-}" "$client_bin" client "$RUN_DIR" "$service" 2>&1) + rc=$? + set -e - if ! stop_server "$server_pid"; then - test_ok=0 - if [[ -n "$fail_reason" ]]; then - fail_reason="${fail_reason}; server did not exit cleanly" - else - fail_reason="server did not exit cleanly" - fi - fi + interop_stop_server - if [[ $test_ok -eq 0 ]]; then - echo -e "${RED}FAIL${NC} (${fail_reason})" + if [[ $rc -ne 0 ]] || ! echo "$client_out" | grep -q "^PASS$"; then + echo -e "${RED}FAIL${NC} (client exit code $rc, output: $client_out)" FAIL=$((FAIL + 1)) return fi @@ -229,6 +120,39 @@ run_test() { PASS=$((PASS + 1)) } +# Run all 9 directed pairs; HANDLER_DELAY_MS slows every server handler. +run_all_pairs() { + local suffix="$1" + local label="$2" + + run_test "C server, C client${label}" \ + "$INTEROP_SVC_C" "$INTEROP_SVC_C" "svc_c_c${suffix}" + + run_test "Rust server, Rust client${label}" \ + "$INTEROP_SVC_RS" "$INTEROP_SVC_RS" "svc_rs_rs${suffix}" + + run_test "Go server, Go client${label}" \ + "$INTEROP_SVC_GO" "$INTEROP_SVC_GO" "svc_go_go${suffix}" + + run_test "C server, Rust client${label}" \ + "$INTEROP_SVC_C" "$INTEROP_SVC_RS" "svc_c_rs${suffix}" + + run_test "Rust server, C client${label}" \ + "$INTEROP_SVC_RS" "$INTEROP_SVC_C" "svc_rs_c${suffix}" + + run_test "C server, Go client${label}" \ + "$INTEROP_SVC_C" "$INTEROP_SVC_GO" "svc_c_go${suffix}" + + run_test "Go server, C client${label}" \ + "$INTEROP_SVC_GO" "$INTEROP_SVC_C" "svc_go_c${suffix}" + + run_test "Rust server, Go client${label}" \ + "$INTEROP_SVC_RS" "$INTEROP_SVC_GO" "svc_rs_go${suffix}" + + run_test "Go server, Rust client${label}" \ + "$INTEROP_SVC_GO" "$INTEROP_SVC_RS" "svc_go_rs${suffix}" +} + main() { echo "=== L2 Service Cross-Language Interop Tests (Windows) ===" echo "" @@ -240,32 +164,12 @@ main() { echo "Go binary: $INTEROP_SVC_GO" echo "" - run_test "C server, C client" \ - "$INTEROP_SVC_C" "$INTEROP_SVC_C" "svc_c_c" - - run_test "Rust server, Rust client" \ - "$INTEROP_SVC_RS" "$INTEROP_SVC_RS" "svc_rs_rs" - - run_test "Go server, Go client" \ - "$INTEROP_SVC_GO" "$INTEROP_SVC_GO" "svc_go_go" - - run_test "C server, Rust client" \ - "$INTEROP_SVC_C" "$INTEROP_SVC_RS" "svc_c_rs" - - run_test "Rust server, C client" \ - "$INTEROP_SVC_RS" "$INTEROP_SVC_C" "svc_rs_c" - - run_test "C server, Go client" \ - "$INTEROP_SVC_C" "$INTEROP_SVC_GO" "svc_c_go" - - run_test "Go server, C client" \ - "$INTEROP_SVC_GO" "$INTEROP_SVC_C" "svc_go_c" - - run_test "Rust server, Go client" \ - "$INTEROP_SVC_RS" "$INTEROP_SVC_GO" "svc_rs_go" + run_all_pairs "" "" - run_test "Go server, Rust client" \ - "$INTEROP_SVC_GO" "$INTEROP_SVC_RS" "svc_go_rs" + # Handlers slower than the clients' 100 ms abort-poll slice must still + # complete within the call timeout (SOW-0041). + HANDLER_DELAY_MS=300 + run_all_pairs "_slow" " (300 ms handler)" echo "" echo -e "=== Results: ${GREEN}${PASS} passed${NC}, ${RED}${FAIL} failed${NC}, ${YELLOW}${SKIP} skipped${NC} ===" diff --git a/tests/test_shm_interop.sh b/tests/test_shm_interop.sh index 4ee51b29..ecda6dc6 100755 --- a/tests/test_shm_interop.sh +++ b/tests/test_shm_interop.sh @@ -15,6 +15,7 @@ set -euo pipefail source "$(dirname "${BASH_SOURCE[0]}")/run-low-priority.sh" +source "$(dirname "${BASH_SOURCE[0]}")/interop-lib.sh" netipc_low_priority_self "$@" RED='\033[0;31m' @@ -99,31 +100,12 @@ run_test() { rm -f "${RUN_DIR}/${service}-"*.ipcshm # Start server in background, wait for READY - local server_pid local server_log="${RUN_DIR}/${service}.server.log" - "$server_bin" server "$RUN_DIR" "$service" > "$server_log" 2>&1 & - server_pid=$! - - # Wait for READY line - local waited=0 - while [[ $waited -lt $((TIMEOUT * 10)) ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - echo -e "${RED}FAIL${NC} (server exited early)" - cat "$server_log" >&2 || true - FAIL=$((FAIL + 1)) - return - fi - if grep -q "^READY$" "$server_log" 2>/dev/null; then - break - fi - sleep 0.1 - waited=$((waited + 1)) - done - - if [[ $waited -ge $((TIMEOUT * 10)) ]]; then - echo -e "${RED}FAIL${NC} (server not ready after ${TIMEOUT}s)" - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true + if ! interop_start_server "$TIMEOUT" "$server_log" \ + "$server_bin" server "$RUN_DIR" "$service"; then + echo -e "${RED}FAIL${NC} (server not ready)" + cat "$server_log" >&2 2>/dev/null || true + interop_stop_server FAIL=$((FAIL + 1)) return fi @@ -144,7 +126,7 @@ run_test() { fi # Wait for server to exit - wait "$server_pid" 2>/dev/null || true + interop_wait_server } main() { diff --git a/tests/test_uds_interop.sh b/tests/test_uds_interop.sh index e8a64e8a..fcc938b1 100755 --- a/tests/test_uds_interop.sh +++ b/tests/test_uds_interop.sh @@ -15,6 +15,7 @@ set -euo pipefail source "$(dirname "${BASH_SOURCE[0]}")/run-low-priority.sh" +source "$(dirname "${BASH_SOURCE[0]}")/interop-lib.sh" netipc_low_priority_self "$@" RED='\033[0;31m' @@ -48,7 +49,7 @@ if [[ -x "$INTEROP_UDS_GO" ]]; then HAS_GO=1 fi -RUN_DIR="$(mktemp -d "${TMPDIR:-/tmp}/nipc_interop.XXXXXX")" +RUN_DIR="$(interop_mktemp_run_dir nipc_interop)" cleanup() { # Kill any background processes we started @@ -100,31 +101,12 @@ run_test() { rm -f "${RUN_DIR}/${service}.sock" # Start server in background, wait for READY - local server_pid local server_log="${RUN_DIR}/${service}.server.log" - "$server_bin" server "$RUN_DIR" "$service" > "$server_log" 2>&1 & - server_pid=$! - - # Wait for READY line (up to TIMEOUT seconds) - local waited=0 - while [[ $waited -lt $((TIMEOUT * 10)) ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - echo -e "${RED}FAIL${NC} (server exited early)" - cat "$server_log" >&2 2>/dev/null || true - FAIL=$((FAIL + 1)) - return - fi - if grep -q "^READY$" "$server_log" 2>/dev/null; then - break - fi - sleep 0.1 - waited=$((waited + 1)) - done - - if [[ $waited -ge $((TIMEOUT * 10)) ]]; then - echo -e "${RED}FAIL${NC} (server not ready after ${TIMEOUT}s)" - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true + if ! interop_start_server "$TIMEOUT" "$server_log" \ + "$server_bin" server "$RUN_DIR" "$service"; then + echo -e "${RED}FAIL${NC} (server not ready)" + cat "$server_log" >&2 2>/dev/null || true + interop_stop_server FAIL=$((FAIL + 1)) return fi @@ -145,7 +127,7 @@ run_test() { fi # Wait for server to exit - wait "$server_pid" 2>/dev/null || true + interop_wait_server } run_pipeline_test() { @@ -167,31 +149,12 @@ run_pipeline_test() { rm -f "${RUN_DIR}/${service}.sock" # Start pipeline server - local server_pid local server_log="${RUN_DIR}/${service}.server.log" - "$server_bin" pipeline-server "$RUN_DIR" "$service" "$count" > "$server_log" 2>&1 & - server_pid=$! - - # Wait for READY - local waited=0 - while [[ $waited -lt $((TIMEOUT * 10)) ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - echo -e "${RED}FAIL${NC} (server exited early)" - cat "$server_log" >&2 2>/dev/null || true - FAIL=$((FAIL + 1)) - return - fi - if grep -q "^READY$" "$server_log" 2>/dev/null; then - break - fi - sleep 0.1 - waited=$((waited + 1)) - done - - if [[ $waited -ge $((TIMEOUT * 10)) ]]; then - echo -e "${RED}FAIL${NC} (server not ready after ${TIMEOUT}s)" - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true + if ! interop_start_server "$TIMEOUT" "$server_log" \ + "$server_bin" pipeline-server "$RUN_DIR" "$service" "$count"; then + echo -e "${RED}FAIL${NC} (server not ready)" + cat "$server_log" >&2 2>/dev/null || true + interop_stop_server FAIL=$((FAIL + 1)) return fi @@ -211,7 +174,7 @@ run_pipeline_test() { FAIL=$((FAIL + 1)) fi - wait "$server_pid" 2>/dev/null || true + interop_wait_server } main() { diff --git a/tests/test_win_shm_interop.sh b/tests/test_win_shm_interop.sh index 201d5789..2b80e9b8 100755 --- a/tests/test_win_shm_interop.sh +++ b/tests/test_win_shm_interop.sh @@ -15,6 +15,7 @@ set -euo pipefail source "$(dirname "${BASH_SOURCE[0]}")/run-low-priority.sh" +source "$(dirname "${BASH_SOURCE[0]}")/interop-lib.sh" netipc_low_priority_self "$@" RED='\033[0;31m' @@ -56,12 +57,7 @@ else fi cleanup() { - local pids - pids=$(jobs -p 2>/dev/null) || true - if [[ -n "$pids" ]]; then - kill $pids 2>/dev/null || true - wait $pids 2>/dev/null || true - fi + interop_stop_server rm -rf "$RUN_DIR_HOST" } trap cleanup EXIT @@ -100,53 +96,36 @@ run_test() { echo -n " $name ... " - # Start server in background, wait for READY - local server_pid local server_log="${RUN_DIR_HOST}/${service}.server.log" - "$server_bin" server "$RUN_DIR" "$service" > "$server_log" 2>&1 & - server_pid=$! - - # Wait for READY line - local waited=0 - while [[ $waited -lt $((TIMEOUT * 10)) ]]; do - if ! kill -0 "$server_pid" 2>/dev/null; then - echo -e "${RED}FAIL${NC} (server exited early)" - cat "$server_log" >&2 || true - FAIL=$((FAIL + 1)) - return - fi - if grep -q "^READY$" "$server_log" 2>/dev/null; then - break - fi - sleep 0.1 - waited=$((waited + 1)) - done - - if [[ $waited -ge $((TIMEOUT * 10)) ]]; then - echo -e "${RED}FAIL${NC} (server not ready after ${TIMEOUT}s)" - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true + if ! interop_start_server "$TIMEOUT" "$server_log" \ + "$server_bin" server "$RUN_DIR" "$service"; then + echo -e "${RED}FAIL${NC} (server not ready)" + cat "$server_log" >&2 2>/dev/null || true + interop_stop_server FAIL=$((FAIL + 1)) return fi - # Run client - local client_out - if client_out=$("$client_bin" client "$RUN_DIR" "$service" 2>&1); then - if echo "$client_out" | grep -q "^PASS$"; then - echo -e "${GREEN}PASS${NC}" - PASS=$((PASS + 1)) - else - echo -e "${RED}FAIL${NC} (client output: $client_out)" - FAIL=$((FAIL + 1)) - fi - else - echo -e "${RED}FAIL${NC} (client exit code $?, output: $client_out)" + # The server printed READY after creating its endpoint, so the client + # connects once. + local client_out rc + set +e + client_out=$("$client_bin" client "$RUN_DIR" "$service" 2>&1) + rc=$? + set -e + + if [[ $rc -ne 0 ]] || ! echo "$client_out" | grep -q "^PASS$"; then + echo -e "${RED}FAIL${NC} (client exit code $rc, output: $client_out)" FAIL=$((FAIL + 1)) + # The server exits after serving one client; after a client failure + # it may still be waiting for one. + interop_stop_server + return fi - # Wait for server to exit - wait "$server_pid" 2>/dev/null || true + echo -e "${GREEN}PASS${NC}" + PASS=$((PASS + 1)) + interop_wait_server } main() {