fix(aspire): host-port pinning becomes opt-in so 'aspire start --isolated' works - #978
Conversation
…952) Proves the Plan-Gate ran before implementation: research re-derives the Aspire endpoint semantics and shows the issue's suggested targetPort mechanism moves the collision rather than removing it, because for addExecutable resources both port and targetPort are host-machine ports. Refs #952 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Proves a resource that configures no host port now generates
`.withHttpEndpoint({ env: 'PORT' })`, so Aspire allocates both the host
and target port and `aspire start --isolated` can place two workspaces on
one machine. Reverting the renderer to the unconditional `port:` shape
fails 4 of the new tests.
The deprecated `Port` alias is still read, so every appsettings.json
already on disk behaves exactly as before.
Refs #952
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Proves `netscript init` now emits an appsettings.json with no host port for
the example service or the app, and that the register generators fed from it
produce `withHttpEndpoint({ env: 'PORT' })` — so two workspaces can run
`aspire start --isolated` side by side.
Adds two regression layers: a behavioural test across the appsettings →
generator seam the defect crossed, and `deno task check:aspire-host-ports`
wired into ci:quality. Drops the [3000, 3099] restriction on --service-port,
which narrowed every workspace on a machine into the same 100 ports.
Refs #952
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
impl — fix/aspire-ephemeral-host-ports (#952)Run dir: Root cause
Slices
Gate evidence
Slice review gate (Amendment A1)Reviewed each landed slice before its sign-off commit. The renderer is pure and total with one home DriftD-1 self-recorded evaluators (single-session assignment) · D-2/D-3 issue claims corrected · |
🤖 Augment PR SummarySummary: This PR makes Aspire host-port pinning opt-in so Changes:
🤖 Was this summary useful? React with 👍 or 👎 |
| * recognised by the ternary on the same line. | ||
| */ | ||
| const ENTRY_PORT_KEY = /\b(?:Host)?Port:\s*\S/; | ||
| const CONDITIONAL_WRITE = /\?/; |
There was a problem hiding this comment.
.llm/tools/validation/check-aspire-host-ports.ts:46 — CONDITIONAL_WRITE is /\?/, which will also match ?? and optional chaining (?.) on an unconditional Port:/HostPort: write, potentially letting a pinned scaffold default slip past the guard. This seems likely to weaken the regression protection the script is meant to provide.
Severity: medium
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
| `Example service "${validated.serviceName}" (oRPC handler on port ${validated.servicePort})`, | ||
| validated.serviceHostPort | ||
| ? `Example service "${validated.serviceName}" (oRPC handler, host port pinned to ${validated.serviceHostPort})` | ||
| : `Example service "${validated.serviceName}" (oRPC handler, Aspire assigns its port)`, |
There was a problem hiding this comment.
packages/cli/src/kernel/application/scaffold/init-pipeline.ts:72 — This message prints “Aspire assigns its port” whenever serviceHostPort is unset, but that’s also the default for --no-aspire runs, where the service port is actually the literal fallback (servicePort). That looks like it could mislead users scaffolding a no-Aspire workspace about where the service will be reachable.
Severity: low
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
Resolves the six-file port-handling overlap with #963 (app health probe). The collision was semantic, not textual. #963 introduced SCAFFOLD_APP_PORT as a *pinned* host port so its app-home probe could reach the app, and resolved that port by reading `NetScript.Apps.<name>.Port` from appsettings.json. #952 removes host-port pinning from the pristine scaffold entirely, so that appsettings entry is now `{"Runtime":"deno","Type":"app"}` — no Port, no HostPort. The old resolver throws on exactly that input, which would have failed the scaffold-runtime gate on main the moment this branch merged. Neither PR's checks could see it: #978 ran green against a main that did not yet contain #963. Resolution: - port-ranges.ts — keep both declarations. USER_PORT_RANGE validates an explicitly requested port; SCAFFOLD_APP_PORT narrows to what it now actually is, the source-literal fallback baked into the app for standalone runs outside the AppHost. That is the exact counterpart of how this branch already treats PORT_RANGES.SERVICE, and it is no longer a host/proxy port. - plan-init.ts, render-ts-apphost.ts — take this branch: the scaffold stops writing a host port for the app. - generated-app-endpoint.ts — the probe now resolves both cases. A pinned port still comes from appsettings (HostPort, with legacy Port still honoured, so existing workspaces resolve identically); an unpinned one is read from the running AppHost via `aspire describe --format Json`, mirroring the resolver the service-health gate already uses in CI. - probe-app-home.ts, runtime-gates.ts — the gate hands the probe the AppHost path and grants --allow-run=aspire, since the allocated port exists nowhere on disk. Regression cover: a pristine scaffold resolving to "pins nothing, and that is not an error" is asserted directly, so the #952 x #963 interaction cannot silently return.
Rebase onto
|
| File | Resolution |
|---|---|
port-ranges.ts |
Keep both declarations. USER_PORT_RANGE validates an explicitly requested port. SCAFFOLD_APP_PORT narrows to what it now actually is — the source-literal fallback baked into the app for standalone runs outside the AppHost, the exact counterpart of how this PR already treats PORT_RANGES.SERVICE. It is no longer a host/proxy port, and its doc comment now says so. |
plan-init.ts, render-ts-apphost.ts |
Take this branch — the scaffold stops writing a host port for the app. |
generated-app-endpoint.ts |
Resolves both cases. Pinned → appsettings.json (HostPort, with legacy Port still honoured so existing workspaces resolve identically). Unpinned → the running AppHost via aspire describe --format Json, mirroring the resolver PROBE_SERVICE_HEALTH_SCRIPT already uses in CI. |
probe-app-home.ts, runtime-gates.ts |
The gate hands the probe the AppHost path and grants --allow-run=aspire, because the allocated port exists nowhere on disk. |
This also unblocks the first deferred follow-up in the PR body: un-pinning the plugin API
ports (:8091–:8094) was blocked on "the E2E gates resolving endpoints from the Aspire resource
service first". That resolver now exists and is tested.
Regression cover
a pristine scaffold pins no port, and that is not an error asserts the composed behaviour
directly, so the #952 × #963 interaction cannot come back silently. The aspire describe parse is
covered without a running AppHost (resource found, banner tolerated, resource-missing and
endpoint-missing both named).
Gate evidence — scoped, because root lint/fmt:check exclude packages/cli
| Gate | Command | Result |
|---|---|---|
| Targeted tests | deno test packages/cli/e2e/tests/.../generated-app-endpoint_test.ts + runtime-gates_test.ts |
18 passed, 0 failed |
| E2E unit suite | deno test packages/cli/e2e/tests |
59 passed, 0 failed |
| Package tests | deno test packages/cli packages/aspire |
488 passed (516 steps), 0 failed |
| Lint | run-deno-lint.ts --root packages/cli --root packages/aspire |
0 occurrences, 785 files |
| Format | run-deno-fmt.ts --root packages/cli --root packages/aspire |
0 findings |
| Type-check | run-deno-check.ts --root packages/cli --root packages/aspire |
0 occurrences, 785 files, 0 failed batches |
| Host-port guard | deno task check:aspire-host-ports |
OK — 593 files, 0 findings |
| Fitness | deno task arch:check |
FAIL=0 (warnings pre-existing) |
scaffold.runtime remains the gate that proves the composed path end to end. It runs in CI on this
PR (e2e-cli / scaffold-runtime (aspire + docker + postgres)) — that lane is what this resolution
is written against, and it is the one to read before merging.
…ver a reachable host CI's scaffold-runtime caught the previous commit: behavior.app-home failed after 60 attempts (60s) while runtime.wait.dashboard reported the app Healthy and runtime.aspire-describe passed. The app was rendering; the probe simply could not reach it. Captured `aspire describe --format Json` from a live AppHost (Aspire 13.4.6) rather than reasoning about it, and it showed two independent defects. 1. Every endpoint is reported as `http://localhost:<port>`. Deno's --allow-net matches the host *string*, so `--allow-net=127.0.0.1` denied every fetch. The retry loop swallowed the permission error and reported it as if the home page never rendered — precisely the confusion this gate exists to resolve. The gate now grants `127.0.0.1,localhost`, and every `localhost` candidate carries a 127.0.0.1 twin. 2. Taking `urls[0]` from a recursive scrape was wrong, and dangerously so. A resource node also carries a `dashboardUrl` deep-link into the Aspire dashboard and an `environment` block holding every service it references (services__users__http__0, VITE_*_URL, the OTLP exporter endpoint). Probing one of those would not merely be wrong: a sibling app's page is text/html containing `<html` too, which is the whole assertion probe-app-home makes, so the gate could have reported a FALSE PASS. Resolution now reads the resource's declared `urls[]` — its own contract — preferring http over https, and never scrapes environment. Also anchored the resource lookup to the top-level `resources[]` array. A free depth-first walk matched `resourceName` inside `relationships[]`, which would return a stub carrying no endpoint and report "declared no HTTP endpoint" for a perfectly healthy app. Matching is by `displayName` then `name`, with a DCP instance-id prefix fallback (`dashboard-sayhwbds`). The probe now tries every candidate on each attempt, mirroring PROBE_SERVICE_HEALTH_SCRIPT (already green in CI) instead of committing to one, and its failure lists what each candidate actually returned rather than a single opaque line. Verified against a live AppHost, not asserted: with every pinned port stripped from appsettings.json the probe resolved through `aspire describe` to `http://localhost:34120/, http://127.0.0.1:34120/` — the same port the pinned config independently declared. Tests use the real captured describe shape as a fixture, and assert the false-pass hazard directly: resolution must never return the dashboard link, a sibling service URL, or the OTLP endpoint.
CI caught the first resolution — and the second one is verified against a live AppHostThe previous push failed 1. Every endpoint is reported as 2. Taking {
"name": "dashboard-sayhwbds", "displayName": "dashboard",
"dashboardUrl": "https://localhost:43699/?resource=dashboard-sayhwbds",
"relationships": [{ "type": "Reference", "resourceName": "users-abcdwxyz" }],
"urls": [{ "name": "http", "url": "http://localhost:34120" }],
"environment": {
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:42595",
"services__users__http__0": "http://localhost:34100",
"VITE_USERS_URL": "http://localhost:34100"
}
}A scrape returns the dashboard deep-link, the OTLP exporter, and every sibling service. Probing a Also anchored the lookup to the top-level The probe now tries every candidate per attempt (mirroring Verified, not assertedWith every pinned port stripped from 34120 is the port the pinned config independently declared. The tests use that captured shape as a Gates — all green on
|
Summary
netscript initgenerated.withHttpEndpoint({ port: N, env: 'PORT' })for the example service andthe app. In Aspire that
portis the host (proxy) port — a machine-global reservation thataspire start --isolatedcannot randomise away — so two NetScript workspaces on one machine collidedby construction and the dashboard could advertise a URL owned by another instance.
Host-port pinning is now opt-in. A pristine scaffold emits
.withHttpEndpoint({ env: 'PORT' }),letting Aspire allocate both the host and target port; a resource pins one only by carrying
HostPortinappsettings.json.Scope
packages/cli,packages/aspireThe issue's suggested mechanism does not fix the bug — and this is the substantive correction
#952 suggests treating a configured port as the target port and leaving the host port ephemeral.
That is right for containers, where a target port is namespaced. Every NetScript service, plugin
and app is an executable (
builder.addExecutable(...)), and for those the target port is a realport on the host machine — the port the
denoprocess itself binds:{ port: 3000, env: 'PORT' }{ targetPort: 3000 }{ env: 'PORT' }targetPortwould move the collision from Aspire's proxy to the process's ownlisten(), and wouldadditionally block replicas. The only shape that isolates is emitting no port at all — which is also
Aspire's own documented shape for non-.NET resources (
addViteApp(...).withHttpEndpoint({ env: "PORT" })).This PR follows the issue's stated expected behaviour and its
HostPortnaming suggestion, andrejects the
targetPortmechanism.Two smaller corrections to the issue text:
targetPortappears nowhere inpackages/aspire/src" is true but points at the wrong file. Thegenerated apphost comes from
packages/cli/.../helpers/register/, andgenerate-register-infrastructure.tsalready emitstargetPortfor the DenoKV/Garnetresources. The idiom was present; it had never been applied to the executables — and per the above,
applying it there would have been wrong anyway.
aspire start --isolatedexports no env signal an AppHost can read (verified against the13.4.6 binary: it randomises only
ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL/ASPIRE_RESOURCE_SERVICE_ENDPOINT_URLand copies user secrets). A "pin unless isolated" design wastherefore not available.
The compatibility story
Portkeeps exactly its current meaning — the host port — and is still read whenHostPortisabsent. An
appsettings.jsonwritten by an earlier release behaves bit-identically after thischange; only newly scaffolded workspaces get the isolation-safe default.
HostPortexists becausePortreads as "the port my service listens on", which is precisely the misreading that made apinned default look harmless.
What I deliberately did not change
:8091–:8094. They carry the same defect, bute2e/src/application/gates/scaffold/runtime-gates.tslive-probes those exact ports and passes--allow-net=127.0.0.1:8091,127.0.0.1:8092into the generated project, and ~20 tutorial passagescurlthem. Un-pinning them needs the E2E gates to resolve endpoints from the Aspire resourceservice first. Follow-up issue below.
netscript service addstill pins. It allocates a port throughPortAllocatorand prints it —a separate command surface with its own UX. Follow-up issue below.
PORT_RANGES.SERVICEis not deleted. It still picks the source-literal fallback baked intoservices/<name>/src/main.tsfor standalone (--no-aspire) runs. Only theinitinputrestriction is widened.
Slices
e4fe771HostPortcontract, single endpoint renderer, regression tests —d73d3b491a5f5aValidation
deno task check(scoped wrapper,packages/cli+packages/aspire)run-deno-lint.ts --root packages/cli --root packages/aspire --root .llm/tools/validationrun-deno-fmt.ts --root packages/cli --root packages/aspire --root .llm/tools.llm/tools/{generate-cli-assets-barrel,harness/extract-verdict,quality/scan-code-quality_test}.ts— pre-existing onmain, verified by re-running the check against a stashed tree. Not fixed here to keep the diff on-topic.deno test --allow-all packages/cli packages/aspiredeno task check:aspire-host-portsdeno task arch:checkFAIL=0on every root (warnings are pre-existing)deno task quality:scanok: true, 0 findings, 7 pre-existing allowancesdeno task publish:dry-runSuccess Dry run completedeno task e2e:cli run scaffold.runtimegates/release-gates.mdthis PR changes scaffold output, so the release cut that picks it up must run it.End-to-end proof on a real scaffold
netscript init smoke952 --service --db nonefollowed bynetscript generate:Fails-before proof
Reverting
render-http-endpoint.tsto the unconditionalport:shape and re-running the new suite:4 failed (
renderHttpEndpointOptions, and the un-pinned case for each of services/plugins/apps).Restored, all 18 pass.
The regression guard
The defect shipped past checks that were each looking at one side of a seam: the appsettings test
asserted
Services.users.Port === 3000and the generator test asserted'.withHttpEndpoint({ port: 3000'. Both were green; the composed output was the bug. Two layers now:pristine-scaffold-ports_test.ts— runs the realgenerateAppsettings()for a pristine init,feeds the parsed result into the real register generators, and asserts the produced
.mtsfilescontain no
withHttpEndpoint({ port:. This is the assertion that would have caught fix(aspire): generated fixed host ports defeat 'aspire start --isolated' #952.deno task check:aspire-host-ports— new static sweep, wired intodeno task ci:qualityalongside
check:netscript-jsr-specifiers. Flags a generatedwithHttpEndpointwith a literalport, and any unconditional
Port:/HostPort:write in the two files that compose scaffoldentries. An
aspire-host-port-ok: <reason>marker allows a justified exception; an empty reasonfails.
I wrote the static rule twice. The first version matched only numeric literals — which would have
looked straight past the four lines that actually shipped (
Port: appProxyPort,Port: options.servicePort,Port: options.service.port,Port: appPort— all identifiers). Itstest now asserts all four verbatim.
Harness
.llm/runs/fix-aspire-ephemeral-host-ports--952/run-loop.md§4/§7 require PLAN-EVAL and IMPL-EVAL in sessionsseparate from the implementation session. This was a single-agent assignment with no second
session, so both verdicts are self-recorded. Read
plan-eval.mdas a completed checklist, notan independent verdict. Recorded in
supervisor.md§ overrides anddrift.mdD-1.Drift / Debt
arch-debt.mdentry created or closed.drift.mdD-1 (self-recorded evaluators), D-2/D-3 (issue claims corrected), D-4 (scope wider forapps, narrower for plugin APIs), D-5 (
scaffold.runtimenot runnable here), D-6 (packages/configdropped — its
ServiceConfig.portnever reaches the apphost).Follow-ups worth their own issues
:8091–:8094— blocked onscaffold.runtimeresolvingendpoints from the Aspire resource service instead of hardcoding
127.0.0.1:8091. Until then twoworkspaces that both install plugins still collide on those ports.
netscript service addstill pins an allocated host port — the same defect on a differentcommand surface.