Skip to content

fix(aspire): host-port pinning becomes opt-in so 'aspire start --isolated' works - #978

Merged
rickylabs merged 6 commits into
mainfrom
fix/aspire-ephemeral-host-ports
Jul 31, 2026
Merged

fix(aspire): host-port pinning becomes opt-in so 'aspire start --isolated' works#978
rickylabs merged 6 commits into
mainfrom
fix/aspire-ephemeral-host-ports

Conversation

@rickylabs

Copy link
Copy Markdown
Owner

Summary

netscript init generated .withHttpEndpoint({ port: N, env: 'PORT' }) for the example service and
the app. In Aspire that port is the host (proxy) port — a machine-global reservation that
aspire start --isolated cannot randomise away — so two NetScript workspaces on one machine collided
by 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
HostPort in appsettings.json.

Scope

The 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 real
port on the host machine — the port the deno process itself binds:

Shape host/proxy port port the process binds second workspace
shipped: { port: 3000, env: 'PORT' } 3000 fixed random proxy collision
issue's suggestion: { targetPort: 3000 } random 3000 fixed bind collision
this PR: { env: 'PORT' } random random no collision

targetPort would move the collision from Aspire's proxy to the process's own listen(), and would
additionally 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 HostPort naming suggestion, and
rejects the targetPort mechanism.

Two smaller corrections to the issue text:

  • "targetPort appears nowhere in packages/aspire/src" is true but points at the wrong file. The
    generated apphost comes from packages/cli/.../helpers/register/, and
    generate-register-infrastructure.ts already emits targetPort for the DenoKV/Garnet
    resources. 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 --isolated exports no env signal an AppHost can read (verified against the
    13.4.6 binary: it randomises only ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL /
    ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL and copies user secrets). A "pin unless isolated" design was
    therefore not available.

The compatibility story

Port keeps exactly its current meaning — the host port — and is still read when HostPort is
absent. An appsettings.json written by an earlier release behaves bit-identically after this
change; only newly scaffolded workspaces get the isolation-safe default. HostPort exists because
Port reads as "the port my service listens on", which is precisely the misreading that made a
pinned default look harmless.

What I deliberately did not change

  • Plugin API resources still pin :8091–:8094. They carry the same defect, but
    e2e/src/application/gates/scaffold/runtime-gates.ts live-probes those exact ports and passes
    --allow-net=127.0.0.1:8091,127.0.0.1:8092 into the generated project, and ~20 tutorial passages
    curl them. Un-pinning them needs the E2E gates to resolve endpoints from the Aspire resource
    service first. Follow-up issue below.
  • netscript service add still pins. It allocates a port through PortAllocator and prints it —
    a separate command surface with its own UX. Follow-up issue below.
  • PORT_RANGES.SERVICE is not deleted. It still picks the source-literal fallback baked into
    services/<name>/src/main.ts for standalone (--no-aspire) runs. Only the init input
    restriction is widened.

Slices

  • S1 Harness artifacts (research, plan, design checkpoint, plan-gate) — e4fe771
  • S2–S4 HostPort contract, single endpoint renderer, regression tests — d73d3b4
  • S5–S7 Pristine scaffold stops pinning; guard; docs — 91a5f5a

Validation

Gate Command Result
Type-check deno task check (scoped wrapper, packages/cli + packages/aspire) pass — 0 occurrences
Lint run-deno-lint.ts --root packages/cli --root packages/aspire --root .llm/tools/validation pass — 0 occurrences, 789 files
Format run-deno-fmt.ts --root packages/cli --root packages/aspire --root .llm/tools pass for every file this PR touches. 3 findings remain in .llm/tools/{generate-cli-assets-barrel,harness/extract-verdict,quality/scan-code-quality_test}.tspre-existing on main, verified by re-running the check against a stashed tree. Not fixed here to keep the diff on-topic.
Tests deno test --allow-all packages/cli packages/aspire pass — 423 + 25
New guard deno task check:aspire-host-ports pass — 590 files scanned, 0 findings
Fitness deno task arch:check passFAIL=0 on every root (warnings are pre-existing)
Code quality deno task quality:scan passok: true, 0 findings, 7 pre-existing allowances
JSR publishability deno task publish:dry-run passSuccess Dry run complete
Runtime / Aspire deno task e2e:cli run scaffold.runtime NOT RUN — needs Docker and the dotnet Aspire host, neither available in this worktree. Declared rather than silently skipped: per gates/release-gates.md this 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 none followed by netscript generate:

// appsettings.json — no Port, no HostPort
"Services": { "users":     { "Runtime": "deno", "Entrypoint": "src/main.ts" } },
"Apps":     { "dashboard": { "Runtime": "deno", "Type": "app", "ServiceReferences": ["users"] } }
// aspire/.helpers/register-services.mts:58
      .withHttpEndpoint({ env: 'PORT' });
// aspire/.helpers/register-apps.mts:73
    await dashboard.withHttpEndpoint({ env: 'PORT' });

Fails-before proof

Reverting render-http-endpoint.ts to the unconditional port: 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 === 3000 and the generator test asserted
'.withHttpEndpoint({ port: 3000'. Both were green; the composed output was the bug. Two layers now:

  1. pristine-scaffold-ports_test.ts — runs the real generateAppsettings() for a pristine init,
    feeds the parsed result into the real register generators, and asserts the produced .mts files
    contain no withHttpEndpoint({ port:. This is the assertion that would have caught fix(aspire): generated fixed host ports defeat 'aspire start --isolated' #952.
  2. deno task check:aspire-host-ports — new static sweep, wired into deno task ci:quality
    alongside check:netscript-jsr-specifiers. Flags a generated withHttpEndpoint with a literal
    port, and any unconditional Port:/HostPort: write in the two files that compose scaffold
    entries. An aspire-host-port-ok: <reason> marker allows a justified exception; an empty reason
    fails.

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). Its
test now asserts all four verbatim.

Harness

  • Run dir: .llm/runs/fix-aspire-ephemeral-host-ports--952/
  • Phase: impl — research → plan → design checkpoint → Plan-Gate → 3 slices → gate sweep.
  • Process deviation, disclosed: run-loop.md §4/§7 require PLAN-EVAL and IMPL-EVAL in sessions
    separate from the implementation session. This was a single-agent assignment with no second
    session, so both verdicts are self-recorded. Read plan-eval.md as a completed checklist, not
    an independent verdict. Recorded in supervisor.md § overrides and drift.md D-1.

Drift / Debt

  • No arch-debt.md entry created or closed.
  • drift.md D-1 (self-recorded evaluators), D-2/D-3 (issue claims corrected), D-4 (scope wider for
    apps, narrower for plugin APIs), D-5 (scaffold.runtime not runnable here), D-6 (packages/config
    dropped — its ServiceConfig.port never reaches the apphost).

Follow-ups worth their own issues

  1. Plugin API resources still pin :8091–:8094 — blocked on scaffold.runtime resolving
    endpoints from the Aspire resource service instead of hardcoding 127.0.0.1:8091. Until then two
    workspaces that both install plugins still collide on those ports.
  2. netscript service add still pins an allocated host port — the same defect on a different
    command surface.

rickylabs and others added 3 commits July 31, 2026 16:38
…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>
@rickylabs

Copy link
Copy Markdown
Owner Author

impl — fix/aspire-ephemeral-host-ports (#952)

Run dir: .llm/runs/fix-aspire-ephemeral-host-ports--952/

Root cause

generate-register-{services,plugins,apps}.ts interpolated entry.Port into the endpoint options
unconditionally. Aspire's port is the host (proxy) port; --isolated randomises only the
CLI-owned endpoints and cannot touch a port the AppHost pinned. The infrastructure generator in the
same directory already used the isolation-safe targetPort idiom for containers — the executable
registrations were simply never updated.

Slices

# Commit Proves
S1 e4fe771 Plan-Gate ran before implementation; research corrects the issue's mechanism
S2–S4 d73d3b4 Absent HostPort{ env: 'PORT' }; present ⇒ pinned; Port alias unchanged
S5–S7 91a5f5a Pristine netscript init pins nothing; guard wired to ci:quality; docs corrected

Gate evidence

Gate Result
deno test --allow-all (repo) 2245 passed, 0 failed, 12 ignored (3m8s)
run-deno-check.ts (packages/cli, packages/aspire) 0 occurrences
run-deno-lint.ts (789 files) 0 occurrences
run-deno-fmt.ts 0 findings on touched files; 3 pre-existing findings elsewhere in .llm/tools, verified against a stashed tree
deno task arch:check FAIL=0 on every root
deno task quality:scan ok: true, 0 findings
deno task publish:dry-run Success Dry run complete
deno task check:aspire-host-ports (new) 590 files, 0 findings
deno task e2e:cli run scaffold.runtime NOT RUN — needs Docker + dotnet Aspire host. Required before the next release cut per gates/release-gates.md.

Slice review gate (Amendment A1)

Reviewed each landed slice before its sign-off commit. The renderer is pure and total with one home
for the HostPort ?? Port ?? none rule; the one behavioural widening (a web app now always gets an
endpoint, because the endpoint used to be gated on a truthy Port and un-pinning would have removed
it) is bounded by a test proving a portless task app still gets none. No any, no deno-lint-ignore,
no plugin-name coupling introduced.

Drift

D-1 self-recorded evaluators (single-session assignment) · D-2/D-3 issue claims corrected ·
D-4 scope wider for apps, narrower for plugin APIs · D-5 scaffold.runtime not runnable here ·
D-6 packages/config dropped from the change set.

Records the full gate sweep (2245 tests, arch:check, quality:scan,
publish:dry-run, the new check:aspire-host-ports) and the end-to-end
scaffold proof, plus the two deferred-scope issues filed as #979/#980.

Refs #952

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@augmentcode

augmentcode Bot commented Jul 31, 2026

Copy link
Copy Markdown
🤖 Augment PR Summary

Summary: This PR makes Aspire host-port pinning opt-in so aspire start --isolated can run multiple NetScript workspaces side by side without port collisions.

Changes:

  • Introduces HostPort as the explicit “pin Aspire host/proxy port” setting, and keeps Port as a deprecated alias for backward compatibility.
  • Updates Aspire config schemas/types so ServiceEntry / PluginEntry no longer require Port; unpinned entries are now valid.
  • Adds a shared renderer (render-http-endpoint.ts) so generators emit .withHttpEndpoint({ env: 'PORT' }) by default and only include port: when a config entry opts in.
  • Adjusts app registration so web app entries always get an HTTP endpoint even when unpinned; task/tauri remain opt-in.
  • Updates the pristine netscript init scaffold to stop writing fixed ports into appsettings.json for the example service and app; pinning is only via --service-port (now validated against the unprivileged range).
  • Extends plugin scaffolding port collection to treat both HostPort and legacy Port as “taken”.
  • Adds a new repo guard task (check:aspire-host-ports) plus unit tests to prevent regressing to pinned scaffold defaults.
  • Updates docs/tutorials and the Aspire README to direct users to the dashboard for assigned URLs and to document deliberate host-port pinning.

🤖 Was this summary useful? React with 👍 or 👎

@augmentcode augmentcode Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. 2 suggestions posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

* recognised by the ternary on the same line.
*/
const ENTRY_PORT_KEY = /\b(?:Host)?Port:\s*\S/;
const CONDITIONAL_WRITE = /\?/;

@augmentcode augmentcode Bot Jul 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.llm/tools/validation/check-aspire-host-ports.ts:46CONDITIONAL_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

Fix This in Augment

🤖 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)`,

@augmentcode augmentcode Bot Jul 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Fix This in Augment

🤖 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.
@rickylabs

Copy link
Copy Markdown
Owner Author

Rebase onto main surfaced a real integration defect — fixed here

main moved under this branch (#974, #976, #957, #963 all landed). The overlap was the six
port-handling files flagged at triage, but the conflict was semantic, not textual, and it
would have broken scaffold-runtime on main.

What collided

#963 (app health probe, closes #954) introduced SCAFFOLD_APP_PORT as a pinned host port so
its new app-home probe could reach the app, and resolved it by reading
NetScript.Apps.<name>.Port out of appsettings.json.

This PR removes host-port pinning from the pristine scaffold — which is the entire point of
#952. So that appsettings entry is now:

"Apps": { "dashboard": { "Runtime": "deno", "Type": "app" } }   // no Port, no HostPort

readGeneratedAppPort throws on exactly that input. Verified against the real generator rather
than inferred:

pristine app entry: {"Runtime":"deno","Type":"app"}
HostPort = undefined | Port = undefined
OLD readGeneratedAppPort would THROW on this input: true

There was a second, quieter half: even a pinned app port would have missed, because this branch
writes HostPort while the probe read Port.

Neither PR's checks could have caught this. #978 last ran green against a main that did not
yet contain #963; #963 ran green against a main where the scaffold still pinned. The defect only
exists in the composition — the same shape as the original #952 escape, where the appsettings test
and the generator test were each green about one side of a seam.

Resolution

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.
@rickylabs

Copy link
Copy Markdown
Owner Author

CI caught the first resolution — and the second one is verified against a live AppHost

The previous push failed scaffold-runtime on behavior.app-home (60 attempts, 60s) while
runtime.wait.dashboard reported the app Healthy and runtime.aspire-describe passed. So
the app was rendering and describe worked; the probe simply could not reach it. Rather than reason
about why, I captured aspire describe --format Json from a live NetScript AppHost (Aspire 13.4.6).
It showed two defects, one of them dangerous.

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 — exactly the confusion this
gate exists to resolve. The gate now grants 127.0.0.1,localhost, and each localhost candidate
carries a 127.0.0.1 twin.

2. Taking urls[0] from a recursive scrape could have produced a FALSE PASS. A resource node
carries more than its own endpoint:

{
  "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
sibling would not merely be wrong — a sibling app's page is text/html containing <html> too,
which is the entire assertion probe-app-home.ts makes. The gate could have gone green against the
wrong service. Resolution now reads the resource's declared urls[] (its own contract), http before
https, and never touches environment.

Also anchored the lookup to the top-level resources[]. A free depth-first walk matched
resourceName inside relationships[] and would return a stub with no endpoint — reporting
"declared no HTTP endpoint" for a perfectly healthy app. Matching is displayName then name, with
a DCP instance-id prefix fallback.

The probe now tries every candidate per attempt (mirroring PROBE_SERVICE_HEALTH_SCRIPT, already
green in CI) and its failure message lists what each candidate returned.

Verified, not asserted

With every pinned port stripped from appsettings.json, forcing the aspire describe path against
a live AppHost:

probing board home page at: http://localhost:34120/, http://127.0.0.1:34120/

34120 is the port the pinned config independently declared. The tests use that captured 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.

Gates — all green on 34fc1b8

scaffold-runtime (aspire + docker + postgres) passes, which is the gate that failed before and
the one that proves the composed #952 × #963 path. Also green: check-test, quality,
code-quality, scaffold-static, desktop-native-linux, surface-diff, close-gate,
deps-report. Locally, scoped to the changed packages (root lint/fmt:check exclude
packages/cli): lint 0, fmt 0, check 0 across 785 files; 492 package tests and 63 e2e tests pass;
check:aspire-host-ports OK across 593 files; arch:check FAIL=0.

@rickylabs
rickylabs merged commit 37047e2 into main Jul 31, 2026
14 checks passed
@rickylabs
rickylabs deleted the fix/aspire-ephemeral-host-ports branch July 31, 2026 15:58
rickylabs added a commit that referenced this pull request Jul 31, 2026
Only conflict was an import line in validate-init.ts: this branch added `basename` for #967's
cwd-is-already-the-target check, while #978 added `USER_PORT_RANGE` for the widened host-port
validation. Both are used; the resolution is the union.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(aspire): generated fixed host ports defeat 'aspire start --isolated'

1 participant