Uh oh!
There was an error while loading. Please reload this page.
npm test no longer drives the host's real service manager (#602) - #606
Conversation
Two attach fixtures dropped a service marker under the REAL service label (com.hyperparam.hypaware / hypaware.service) into a temp HOME so serviceDaemonStatus would report installed, then let restartServiceDaemon run, on the recorded assumption that no launchctl/systemctl binary would be reachable. That holds in the CI container and is false on a developer machine: launchd and systemd address a service by label in a per-uid namespace, and resolveTarget derives userDomain from process.getuid(), never from HOME. On macOS every npm test therefore ran `launchctl kickstart -k gui/<uid>/com.hyperparam.hypaware` against the developer's own daemon, severing in-flight proxied streams. runServiceCommand, the single seam every service-manager spawn passes through, now refuses under the Node test runner (NODE_TEST_CONTEXT set) unless HYP_ALLOW_REAL_SERVICE_MANAGER=1. The guard is a rejection, not a synchronous throw, so installLaunchAgent's best-effort .catch() still applies. Smokes and the packaged CLI do not set NODE_TEST_CONTEXT, so the acceptance tier that installs a real daemon is untouched. The duplicated installFakeDaemonService fixture moves to a shared test helper carrying the rule, and LLP 0181 records it. Co-Authored-By: Claude <noreply@anthropic.com>
NODE_TEST_CONTEXT is set by `node --test` only in the children it forks, so the guard as landed answered one of three shapes. Measured at the previous head with recording launchctl/systemctl stubs on PATH, both of the others still ran `systemctl --user restart hypaware.service` against the host: node test/core/attach-enable-resume.test.js node --test --experimental-test-isolation=none <the two attach fixtures> The first is the ordinary habit of iterating on one file; the second is reachable through the repo's own entrypoint, since scripts/run-tests.js forwards extra args verbatim, so `npm test -- --experimental-test-isolation=none` disabled the guard for the whole suite. underTestRunner now also accepts `--test` in process.execArgv and a `.test.js` entry on the command line. It over-answers on purpose: a false yes costs a refusal that HYP_ALLOW_REAL_SERVICE_MANAGER=1 lifts, a false no costs the host's daemon, and no command that reaches a service op (hyp daemon ..., hyp attach, hyp init, hyp join) takes a file path. Verified unrefused: both daemon smokes, and `node bin/hypaware.js daemon restart` against stubs, which still spawns. The regression test grows a case per shape, all three driving the real fixtures with stubs on PATH. The opt-in test moves into a child process: it set the variable on process.env, which under --experimental-test-isolation=none would disable the guard for every other test in that process. LLP 0181 records the three shapes, narrows the opt-in rule to what it means (no test may use the opt-in to reach a real service manager), and carries Generated-by: neutral rather than a human byline. LLP 0017 gains the forward ref. Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe
commented
Aug 4, 2026
Review round 1 - |
…sting them (#602) The mechanism test drove a hand-maintained REAL_LABEL_FIXTURES list, which is the same fixture-by-fixture discipline that failed in #602: the next author to reach for installFakeDaemonService would not think to add themselves, and the test would go on proving the rule for two files while a third escaped. Discovery (test/**/*.test.js importing the helper) makes the three no-spawn runs cover every such fixture by construction. A discovery assertion runs first so a renamed or moved helper fails loudly rather than leaving the three runs executing zero files and passing. Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe
commented
Aug 4, 2026
Review round 2 - |
| run | result | service-manager invocations |
|---|---|---|
PATH=<stubs> npm test | 3382 tests, 3381 pass, 0 fail, 1 skipped | 0 |
PATH=<stubs> npm test -- --experimental-test-isolation=none | 3382 tests, 3379 pass, 2 fail (unrelated, see below) | 0 |
Production shape is genuinely unaffected (the risk of a widened refusal): with the same stubs on PATH, a plain node probe.js calling serviceDaemonStatus against a home holding a real unit marker still spawns systemctl --user show hypaware.service --property=LoadState,ActiveState,MainPID. So the third predicate arm does not false-positive outside a test.
Regression test genuinely pins the bug: with src/core/daemon/service_ops.js reverted to 9dbba3e, test/core/service-manager-test-sandbox.test.js fails 4 of 6; at head it passes 6 of 6.
Seam is still singular: launchctl / systemctl appear in src/ only under src/core/daemon/{service_ops,macos,linux,install}.js, and service_ops.js:129 is the only spawn in that tree. Every caller of restartServiceDaemon / startServiceDaemon (src/core/cli/walkthrough.js:1016, src/core/commands/central.js:406, src/core/commands/daemon.js:140,278, src/core/config/client_enable.js:207) takes no filesystem path, confirming the doc's claim that no real invocation carries a .test.js. Smoke tier untouched: no *.test.js under hypaware-core/, no smoke passes --test; daemon_install_render, daemon_foreground_start_stop, core_boot_noop all ok.
1. preference - the mechanism test hand-listed the fixtures it protects
test/core/service-manager-test-sandbox.test.js:43 (pre-fix):
/** * The fixtures that put a real service label on disk and then drive daemon * code for real. Add to this list, do not remove from it. */constREAL_LABEL_FIXTURES=['test/core/attach-enable-resume.test.js','test/core/attach-endpoint-fallback.test.js',]LLP 0181#the-guard puts the refusal at the seam precisely because "fixture-by-fixture discipline is the thing that already failed". This list is that same discipline, one level up: the next author to reach for installFakeDaemonService would not think to add themselves, and the test would go on proving the rule for two files while a third escaped unobserved. Nothing enforced the comment.
Not a blocker - the seam guard protects an unlisted fixture regardless; only the evidence would go stale.
Fixed in 30998f1: realLabelFixtures() walks test/**/*.test.js for importers of helpers/daemon_service_fixture.js, so the three no-spawn runs cover every such fixture by construction. A discovery assertion runs first, because the failure mode of discovery is silent vacuity - a renamed or moved helper would otherwise leave all three runs executing zero files and passing green. (The walk skips the test file itself, which names the helper in a constant and would otherwise recurse into running itself.)
Verified landed: git show HEAD:test/core/service-manager-test-sandbox.test.js contains realLabelFixtures at line 54 and const REAL_LABEL_FIXTURES = realLabelFixtures() at line 77; the list literal is gone. Test goes 6/6 at head and 2/6 with the guard reverted, so the added test did not weaken the pre-fix failure signal.
Verified clean
- Conventions: no U+2014, no trailing semicolons, no new
@typedef, no inlineimport('...')types, no unused imports left by the fixture extraction (both attach files still use every import).npm run typecheckexits 0. @refhonesty:LLP 0181#the-guardand#the-ruleboth exist as explicit anchors;LLP 0181's own links resolve (0017#install-global-package-then-service-managermatches## Install: global package, then service manager;0174#promptexists). Number 0181 was free;Generated-by: neutralmatches the 35-doc precedent; theExtended-by:line on 0017 is a mechanical forward-ref, permitted on an Accepted doc.- Status contract preserved: the refusal is absorbed by
serviceRuntimeStatus's catch (src/core/daemon/install.js:308), soLLP 0017#status-queries-never-raisestill holds; only state-changing ops surface it.
Out of scope, now tracked
The --experimental-test-isolation=none run's 2 failures (hyp policy on a corrupt store..., a query whose heap growth exceeds the execution budget...) are pre-existing and unrelated: re-running that mode with all three PR-touched test files excluded reproduces exactly the same 2. Shared-process interference in an unsupported mode; not this PR's.
The shutdown-wedge amplifier this PR confirms and deliberately defers had no tracking issue, and #602 closes with this PR, so the finding would have been lost. Filed as #610 (ai-gateway proxy stop() never drains SSE connections, so daemon shutdown wedges until SIGKILL).
Post-fix numbers
npm test: 3383 tests, 3382 pass, 0 fail, 1 skipped. npm run typecheck: exit 0. Head advanced 8c1fe48 to 30998f1.
philcunliffe
commented
Aug 4, 2026
Triage (rung: judgement) - no residual findingsParked at triage after 2 review rounds with the head unreviewed. Verified round 1 and round 2's findings independently against the committed tree at What was checkedRound 1 (
Round 2 (
Independent evidence gathered in a fresh worktree at head |
Uh oh!
There was an error while loading. Please reload this page.
Root cause
test/core/attach-enable-resume.test.jsandtest/core/attach-endpoint-fallback.test.jsboth wrote a service marker under the real service label (com.hyperparam.hypaware/hypaware.service) into a tempHOME, soserviceDaemonStatuswould reportinstalled: true, and then let the daemon code run for real. The fixture comment recorded the assumption:That is true of the CI container and false of every developer machine.
A temp
HOMEdoes not sandbox the service manager. It relocates the plist/unit file, which is the only thingserviceDaemonStatus's installed-check reads. launchd and systemd address a service by label inside a per-uid namespace, andresolveTargetinsrc/core/daemon/macos.jsderivesuserDomainfromprocess.getuid(), never fromHOME. So the command that actually ran on a macOS host waslaunchctl kickstart -k gui/<uid>/com.hyperparam.hypaware: the developer's own daemon, once pernpm test.Fix
runServiceCommandinsrc/core/daemon/service_ops.jsis the single seam every service-manager spawn passes through. It now returns a rejectedServiceManagerSandboxErrorinstead of spawning whenNODE_TEST_CONTEXTis set (the Node test runner sets it in every test child, and it is inherited by anything a test spawns), unlessHYP_ALLOW_REAL_SERVICE_MANAGER=1.The guard is deliberately at the seam rather than in each fixture, because fixture-by-fixture discipline is exactly what failed here. Three properties are deliberate:
installLaunchAgent's best-effortbootoutattach a.catch()to the returned promise, which a synchronous throw would sail past.hyp smoke ...) and the packaged CLI do not setNODE_TEST_CONTEXT, so the acceptance tier indocs/ACCEPTANCE.mdthat is supposed to install and start a real daemon is untouched and needs no opt-in.npm run smoke -- daemon_install_renderandcore_boot_noopboth stay green.Alongside it:
installFakeDaemonServicefixture moves totest/helpers/daemon_service_fixture.js, carrying the rule in a comment where the next author of this exact mistake will read it.attach-enable-resume.test.js's test body now says why the restart fails (the guard, not a missing binary).llp/0181-tests-never-drive-the-real-service-manager.decision.md, Systems: Daemon) records the rule, the guard, and the two alternatives rejected.On the reporter's "pass fake adapters in both test files"
enableClientAdapterdoes take arestartDaemoninjection point, but it is not threaded throughrunAttach, which is what these two tests exercise end to end. Widening a command entrypoint's signature to serve a test, and then remembering to thread it at every future call site, is strictly weaker than refusing at the seam. LLP 0181 §Alternatives records this and keeps the seam as the right move if attach ever needs to script daemon behaviour rather than merely avoid it.Regression test
The macOS symptom cannot be reproduced in this Linux CI container: it has neither
launchctlnorsystemctl, which is precisely why the bug was invisible here. Sotest/core/service-manager-test-sandbox.test.jspins the mechanism, not the symptom. It puts recordinglaunchctlandsystemctlstubs onPATH(this is the only thing a developer machine has that the container does not), runs the two attach fixtures in a childnode --test, and asserts that neither stub was ever invoked. Two further tests cover the guard directly and its opt-in.Note the recorded commands are
systemctlones: on a Linux host with systemd this is the same escape, hitting the user's realhypaware.service. It is not a macOS-only bug, just a macOS-only report.Before the fix
(The 30s duration is itself the bug: the stubbed
systemctl restart"succeeded", so the enable flow went on to wait for a gateway bind that no daemon was ever going to publish.)After the fix
Full suite
npm run typecheckexits 0.Out of scope: the shutdown-wedge amplifier
The reporter's second finding is confirmed and deliberately not fixed here.
hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:91is:server.close()stops accepting new connections and then waits for every existing one to end; it never callscloseIdleConnections()/closeAllConnections(). An open SSE stream never ends on its own, so a graceful shutdown wedges until launchd escalates toSIGKILL(the observed exit status-9), which is what turned each kick into a mid-stream truncation rather than a clean restart. That is a real daemon-shutdown defect independent of the test suite and deserves its own issue and its own fix.Fixes#602