Skip to content

fix(dev): give services local ports in dependency order - #350

Merged
wmadden-electric merged 7 commits into
mainfrom
fix/dev-ports-in-graph-order
Oct 8, 2026
Merged

wmadden-electric merged 7 commits into
mainfrom
fix/dev-ports-in-graph-order

Conversation

@wmadden-electric

@wmadden-electric wmadden-electric commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Linked issue

n/a. Found by the nightly getting-started check, which follows the Composer getting-started guide from an empty directory.

Summary

The getting-started guide builds two services: quotes, and a public gateway that calls it. The guide says quotes runs on port 3000 and gateway on 3001, and has the reader run curl localhost:3001 to get a quote through the gateway. In about half of fresh prisma dev module.ts runs, the ports come out the other way round:

{"kind":"endpoint","name":"gateway","url":"http://localhost:3000",...}
{"kind":"endpoint","name":"quotes","url":"http://localhost:3001",...}

$ curl localhost:3001
{"error":"Unauthorized: missing or invalid service key"}

The curl reaches quotes, which only accepts calls from other services, so the reader gets a 401 instead of a quote. This happened in 3 of 7 fresh runs on Composer 0.29.0.

With this PR, prisma dev gives services their local ports in dependency order: a service gets its port before the services that call it. Services that don't depend on each other go in the order the module declares them. On a fresh start, each service gets the lowest free port from 3000 up, so quotes always gets 3000 and gateway 3001. A warm start keeps the ports services already have, as before.

Why the ports swapped

Locally, each service becomes one Prisma.App resource. When Alchemy applies an App, its provider asks the Compute emulator for the service's port, and the emulator hands a new service the lowest free port from 3000 up.

The App resources depend only on the project, not on each other, so Alchemy applies them at the same time. Whichever request reaches the emulator first gets 3000. The gateway does depend on quotes, but on the quotes App's identity, and that doesn't order the two port requests.

Reserving the ports first, in graph order

Before Alchemy runs, the Prisma Cloud extension's emulators hook now reserves a port for each service, one at a time, in graph.nodes order. Core already sorts that list with dependencies first and ties in declaration order. The work is done by a new reserveServicePorts(container, serviceAppNames) in @internal/local-target, which calls the emulator's ensureService once per service and waits for each.

When Alchemy then applies the App resources in parallel, each provider asks for a port that already exists and gets it back unchanged. A failed reservation names the service it was for.

Making --fresh start from 3000 as well

The new rule is that a fresh start gives the lowest free ports from 3000. prisma dev --fresh is the user's way to get a fresh start, and in two cases it didn't give those ports:

  1. The Compute emulator was stopped, for example after a reboot. Teardown skipped the delete for a stopped emulator. The emulator then restarted and reloaded the old ports, including swapped ones. Teardown now starts the Compute emulator before deleting the app, and a failed delete stops the run. Postgres and buckets keep their old behaviour: they are skipped when they aren't running.
  2. --fresh ran shortly after the previous run. The emulator chooses ports with the get-port package, which holds every port it hands out for up to 30 seconds. The freed ports were still held, so the app got 3002 and 3003. The emulator now releases those held ports when it deletes an app. Other apps' ports stay safe, because the emulator's saved state already keeps them out of every allocation.

One name for each App and its reservation

A reservation only helps if it uses the same name the App resource uses for that service. A new serviceAppName(address) in the Prisma Cloud extension now produces both, so they can't drift apart.

Docs

The rule is now in docs/design/10-domains/local-dev.md, docs/guides/running-locally.md and the core-concepts skill, worded the same in the guide and the skill. In core, the emulators hook's description now says it also prepares emulator state for each node before Alchemy runs.

Testing performed

  • local-target/src/__tests__/compute-port-order.test.ts (new; a real Compute emulator in its own directory for each test):
    • each App provider gets the port reserved for its service, even when the providers run at the same time in reverse order, 10 times over;
    • an app that already has ports keeps them;
    • a dotted address maps to one emulator service;
    • a failed reservation names the service.
      The first test fails without the reservation.
  • target/src/__tests__/local-target-emulators.test.ts (new): for a graph shaped like the getting-started app, the hook reserves quotes then gateway, after the Compute emulator is up. When nothing orders the services, it reserves them in declaration order, and only services.
  • target/src/__tests__/local-target-teardown.test.ts (new): teardown starts the Compute emulator before deleting the app.
  • dev-emulators/src/__tests__/compute-multi-app.test.ts (new case): an app that is deleted and then reserved again gets back the ports it freed. Before the fix it got 3002 instead of 3000.
  • Package tests: @internal/local-target 17 pass, @internal/prisma-cloud 395 pass, compute-multi-app and compute-deployment 23 pass.
  • pnpm turbo run typecheck for @internal/local-target, @internal/prisma-cloud, @internal/dev-emulators and @internal/core; biome check on the changed files; pnpm lint:deps.

Checklist

  • All commits are signed off (git commit -s) per the DCO. The DCO status check will block merge if any commit is missing a Signed-off-by: trailer.
  • I read CONTRIBUTING.md and the change is scoped to one logical concern.
  • The PR title is a conventional commit (feat, fix, chore, docs, refactor, test, build) — PR titles drive the auto-generated release notes.
  • Tests are updated (or n/a if the change is doc-only / refactor with no behavioural delta).

Notes for the reviewer

Alternatives considered:

  • Link the App resources in dependency order, so Alchemy applies them one after another. This would add ordering to the deploy graph that only local dev needs, and slow the hosted deploy for it. Services that don't depend on each other would still race.
  • Make the emulator handle port requests one at a time. Ports would then be handed out in arrival order, which is still random, so they would still swap.
  • Derive each port from the service's name. Ports would no longer start at 3000 or match the guide, and two names could map to the same port.
  • Add a timeout to the reservation. Without one, a hung emulator now stops prisma dev at the reservation; before, it stopped it at the deploy step. The natural place for a timeout is adminFetch in the emulator client, but every emulator call uses it, including the long-running log stream. That is a separate, broader change.

Not fixed here: the emulator's port allocation isn't atomic, so two prisma dev processes for different apps can still be handed the same port at the same moment. And --fresh still skips the Postgres and buckets deletes when those emulators are stopped.

Agent: maui-32

…ne runs

prisma dev gave each service the next free port in whatever order Alchemy applied the App resources. Those resources are unlinked, so Alchemy applies them concurrently, and a fresh start of the getting-started app put quotes on 3001 and gateway on 3000 about half the time, against the docs.

The emulators hook now reserves every Prisma Cloud service's port one at a time, in graph.nodes order (dependencies first, ties in declaration order), right after the Compute emulator is up. The App providers then find their port already reserved. A service that already has a port keeps it, so warm starts are unchanged.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…rder

The design doc, the running-locally guide and the skill now state the rule: on a fresh start, services get ports from 3000 up in dependency order, ties in declaration order; a warm start keeps saved ports.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Two cases kept old or higher ports after --fresh. If the compute emulator was stopped, teardown skipped its delete, and the restarted daemon reloaded the saved ports. If the delete ran, get-port still held the freed ports for up to 30 seconds, so the next reservation skipped them.

Teardown now starts the compute emulator before deleting the app. The emulator clears get-port's held ports when it deletes an app; its own saved allocations already keep other apps' ports out.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ame the failing service

serviceAppName is now the one source for both the App displayName and the name the emulators hook reserves under, so the two cannot drift. A failed reservation names its service. The port-order test now checks that each App provider returns the port its reservation got, which does not depend on other processes on the machine, and removes its temp directories.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… description

The emulators hook now also sets up per-node emulator state before converge; its description in core and the dev command says so.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…scribe what teardown now does

The parameter sat next to appName, the Composer app, and read as the same kind of thing. The teardown module comment now says compute is started first and must succeed, while Postgres and buckets are skipped when unreachable.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@prisma-gizmo

prisma-gizmo Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

✅ Gizmo reviewed 88aa3d3 — posted 1 inline comment(s) this pass.

Open findings: 🟡 1 minor

Change walkthrough

This incremental delta hardens the --fresh teardown ordering introduced by the port-reservation fix: teardown.ts now runs the compute step (daemon start + app delete) inside a try/finally, so the buckets cleanup and removeLocalPaths always execute — even when the compute delete fails — while the failure still propagates to fail the run.

Teardown semantics (teardown.ts:48-54). Previously, a failed ensureDaemon or compute deleteApp aborted teardown before local directories were removed, leaving stale local state behind after a failed --fresh. The restructure keeps the original contract — compute failure stops the run — while guaranteeing the local half of the cleanup happens regardless. This closes the gap where a failed compute delete (the signal that port allocations were not freed) would leave the workspace half-torn-down. The buckets delete stays inside tolerateUnreachable, preserving the documented rule that Postgres and buckets are skipped when their emulators are down, and the module doc comment was updated to state the new guarantee.

Test coverage (local-target-teardown.test.ts:60-67). A new test drives the failure path: the mocked compute deleteApp records its event and then throws, and the test asserts both that the teardown rejects with the compute error and that the full cleanup sequence (daemon compute → delete compute my-app → remove local paths) still ran. The shared computeDeleteFails flag is reset in the existing beforeEach, so the earlier happy-path test is unaffected. The mock design — event recorded before the throw — is what lets the events assertion distinguish "cleanup skipped" from "cleanup ran despite failure".

The change is a narrow, self-contained delta within the extension's teardown hook (the only caller is descriptor.ts's teardown entry, whose contract — error propagation to the CLI — is unchanged). It matches the PR's stated rule that "a failed delete stops the run" while making that failure leave no stale local state, and it pairs with the new failing-delete test rather than altering any emulator client or reservation code.

@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: be321389-d5a6-4f05-ade3-1dea7ac03aba
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@prisma/composer@350
npm i https://pkg.pr.new/@prisma/composer-cli@350
npm i https://pkg.pr.new/@prisma/composer-prisma-cloud@350

commit: 88aa3d3

@prisma-gizmo prisma-gizmo 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.

New findings: 🟡 1 minor · trace

Comment thread packages/1-prisma-cloud/1-extensions/target/src/local-target/teardown.ts Outdated

@prisma-gizmo prisma-gizmo 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.

No critical or major Gizmo finding is open and the head commit has been reviewed. Approving.

@wmadden-electric wmadden-electric changed the title fix(dev): give services local ports in dependency order, so a fresh prisma dev always puts quotes on 3000 and gateway on 3001 fix(dev): give services local ports in dependency order Oct 8, 2026
…fails

Teardown now removes the dev state directory and the local Alchemy state, and tries the buckets delete, in a finally around the compute step. A compute failure still fails --fresh, but no longer leaves stale state on disk.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>

@prisma-gizmo prisma-gizmo 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.

New findings: 🟡 1 minor · trace

Comment on lines +48 to +54
try {
await ensureDaemon('compute', daemonEntry('compute'));
await computeClient().deleteApp(app);
} finally {
await tolerateUnreachable(() => bucketsClient().deleteApp(app));
removeLocalPaths([`${cwd}/${DEV_DIR}`, `${cwd}/.alchemy/state/${app}/dev`]);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor · error-handling — A throwing cleanup inside the finally can mask the compute delete failure

The new finally block runs removeLocalPaths(...) (and the tolerated buckets delete) after the compute step failed. removeLocalPaths uses fs.rmSync(..., { force: true }), which tolerates missing paths but still throws on real filesystem errors (EACCES, EPERM, EBUSY — plausible on Windows while a dev server holds a handle). A throw from inside a finally block replaces the exception pending from the try body, so the user would see the cleanup error instead of compute delete failed — the signal the design depends on, since packages/1-prisma-cloud/1-extensions/target/src/local-target/teardown.ts:46-47 says the compute delete must succeed because it persists port allocations. The run still stops, but the reported cause misdirects debugging of a failed --fresh. Note packages/1-prisma-cloud/1-extensions/target/src/tests/local-target-teardown.test.ts:60-67 cannot catch this because its mocked removeLocalPaths never throws.

Recommended fix

Make the cleanup step unable to mask the primary failure — either wrap the finally body in its own try { ... } catch that reports the cleanup error without discarding the pending one (e.g. console.error it, or attach it to the original error before rethrowing), or hoist the cleanup out of finally into a catch that records the compute error, runs cleanup tolerantly, then rethrows the recorded error.

@prisma-gizmo prisma-gizmo 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.

No critical or major Gizmo finding is open and the head commit has been reviewed. Approving.

@wmadden-electric
wmadden-electric merged commit c8f4736 into main Oct 8, 2026
23 checks passed
@wmadden-electric
wmadden-electric deleted the fix/dev-ports-in-graph-order branch October 8, 2026 21:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants