Files
PaperClipAI/server/src/services/runtime-exposure/port-reservation.test.ts
T
Dotta 1a17cbf232 fix(runtime-exposure): mediate leased app/HMR port pairs centrally (#11526)
<!-- Simplified Technical English (ASD-STE100). -->

> **Stacked pull request.** This targets #11525, which targets #11524.
Merge those first. Review only the last commit, `fix(runtime-exposure):
mediate leased app/HMR port pairs centrally`.

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip starts managed runtime services for execution workspaces,
and #11524 and #11525 make those services reachable over Tailscale HTTPS
on a loopback port pair
> - An HTTPS lane is only safe if one execution workspace holds its port
pair exclusively for the whole life of the lane
> - A managed start reused a pair that a stopped but still leased
workspace owned. Paperclip reported that workspace stopped and its
exposure removed, while the host listeners and the Serve mappings for
those ports were live and belonged to an unrelated workspace
> - The cause is that ownership was decided in more than one place, and
no single place saw persisted reservations, live listeners, and Serve
mappings together
> - This pull request adds one mediator that owns the decision, and
makes every mismatch fail closed while naming the conflicting workspace
> - The benefit is that a later start cannot collide with, adopt, or
interfere with another issue's service, and cannot produce security
evidence attributed to the wrong workspace

## Linked Issues or Issue Description

No public GitHub issue exists. The change follows the bug report
template.

**What happened**

A managed HTTPS start reused the loopback port pair of a stopped but
still exclusively leased execution workspace. The ports were then held
by an unrelated workspace. Paperclip continued to report the first
workspace's runtime as stopped and its exposure as removed, while the
host listeners and the `tailscale serve` mappings for those exact ports
were live and owned by the other workspace.

**Expected behavior**

An active execution-workspace lease reserves its app and HMR pair until
the lease is explicitly released or torn down. A start that finds the
pair held by a different workspace fails closed and names the conflict.
Paperclip never adopts a process or a Serve mapping across
execution-workspace ids.

**Root cause**

Three separate readers each had an incomplete view:

- `deprovisionExposure` replaces the exposure status with a fresh
`removed` status whose `listeners` array is empty. A later reader asking
"which ports did this row own?" gets no answer, so a stopped row's pair
looked free even while the row was leased.
- Startup reconciliation adopted a persisted service by `row.port`
alone, then terminated the local service when its health check failed.
Under the `project_primary` strategy, where workspaces share a working
directory, the containment check cannot separate two workspaces, so the
sweep could adopt and then kill an unrelated workspace's live service.
- Allocation checked live port availability but never checked which
pairs active leases still reserve.

**Impact**

Two workspaces can collide on one lane. A start can adopt or interfere
with another issue's service, and evidence about an exposure can be
attributed to the wrong workspace.

## What Changed

- Add `server/src/services/runtime-exposure/port-reservation.ts`, one
mediator that decides allocation and ownership from persisted
reservations plus live listener and Serve ownership together.
- Reserve a pair for as long as its execution workspace holds an active
lease, until the lease is explicitly released or torn down.
- Re-derive a row's pair from the `port` column and `deriveViteHmrPort`
instead of the status `listeners` array, so a `removed` status no longer
hides which ports a leased row still reserves.
- Refuse to adopt a process or a Serve mapping across
execution-workspace ids. A mismatch fails closed and names the
conflicting workspace and issue.
- Treat an unattributable holder as a conflict. A Serve mapping that is
present but cannot be attributed means the host has something there that
could not be named, so it fails closed instead of falling through to
"allowed".
- Make reconciliation surface a stopped or removed row whose reserved
ports are live or mapped by another workspace, instead of reporting
success.
- Leave manual and unknown Serve mappings alone on release and teardown.

## Verification

- `npx vitest run --root server src/services/runtime-exposure/
src/__tests__/workspace-runtime-exposure-reservation.test.ts
src/services/workspace-runtime-exposure-backfill.test.ts` — 8 files,
**112 tests pass**.
- `npx tsc --noEmit -p server/tsconfig.json` — **0 errors** with
`@paperclipai/plugin-sdk` built.
- `pnpm --filter @paperclipai/db typecheck` — migration numbering and
safety checks pass.
- `pnpm --filter @paperclipai/tailscale-https-broker test` — 87 tests
pass.

The five required regressions are covered by
`workspace-runtime-exposure-reservation.test.ts` and
`port-reservation.test.ts`:

1. Reuse of a stopped-but-leased pair is denied.
2. Cross-execution-workspace process adoption is denied.
3. A Serve mapping ownership mismatch is visible and fails closed.
4. Concurrent allocators return unique pairs.
5. Release and teardown make the pair reusable without harming manual or
unknown mappings.

Note for reviewers:
`server/src/services/workspace-runtime-exposure.test.ts` fails on a
development host that already runs an HTTPS canary holding ports 42000,
42001, 52000, and 52001, because that fixture stubs port availability
and then allocates into the occupied range. It is unaffected by this
change and is expected to pass in CI, where no such listener exists.
Please read the CI result rather than a local run on an exposing host.

## Risks

- The mediator is now the single decision point for allocation and
adoption, so a defect in it affects every managed start. This is
deliberate: the incident happened because the decision was spread across
three readers, and concentrating it is the fix.
- Behavior becomes stricter. A start that previously reused a pair now
fails closed with a named conflict. This is the intended change, and it
can surface pre-existing collisions that used to pass silently.
- The remediation path does not stop an unrelated service that already
holds a pair. It reports the conflict instead, so it cannot disturb
another issue's running lane.
- No migration runs in this pull request.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR.

## Model Used

Claude Opus 5 (`claude-opus-5`), 1M context window, extended thinking,
with tool use and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-08-17 06:47:55 -04:00

552 lines
21 KiB
TypeScript

/**
* PAP-17419 regression coverage for central exposure reservation/ownership
* mediation.
*
* Each `describe` below is one of the five regression requirements on the
* issue, named so a reviewer can map test → requirement without reading the
* bodies. The scenario ports and workspace IDs mirror the PAP-17251 finding:
* lane B held `42001/52001` under an open lease while the unrelated workspace
* `PAP-16986-add-posthog-mcp` took the pair.
*/
import { describe, expect, it } from "vitest";
import {
buildExposureReservationLedger,
collectRowExposurePorts,
describeExposurePortConflict,
describeExposureReservationDrift,
ExposurePortOwnershipConflictError,
ExposurePortPairClaims,
findExposurePairConflict,
findExposureReservationDrift,
isExposureAdoptionPermitted,
type ExposureOwnerIdentity,
type PersistedExposureRowSnapshot,
} from "./port-reservation.js";
import { allocateExposurePortPair } from "./port-pair.js";
/** The stopped-but-leased lane from the finding. */
const LEASED_WORKSPACE = "b7ce28b4-7921-4e23-8be2-8b42193bf4ad";
/** The unrelated workspace that took its pair. */
const OTHER_WORKSPACE = "9f2c1d40-1111-4222-8333-444455556666";
const LEASED_ISSUE = "17251aaa-1111-4222-8333-444455556666";
function identity(overrides: Partial<ExposureOwnerIdentity> = {}): ExposureOwnerIdentity {
return {
runtimeServiceId: null,
executionWorkspaceId: null,
projectWorkspaceId: null,
issueId: null,
...overrides,
};
}
/**
* A lane that has been stopped AND torn down.
*
* `deprovisionExposure` writes a fresh `removed` status whose `listeners` array
* is empty, so the `port` column is the only surviving record of which pair the
* lease owns. Reproducing that faithfully is the whole point of the fixture.
*/
function stoppedTornDownRow(overrides: Partial<PersistedExposureRowSnapshot> = {}): PersistedExposureRowSnapshot {
return {
id: "runtime-lane-b",
status: "stopped",
port: 42001,
exposure: { state: "removed", listeners: [] },
executionWorkspaceId: LEASED_WORKSPACE,
projectWorkspaceId: null,
issueId: LEASED_ISSUE,
...overrides,
};
}
describe("collectRowExposurePorts", () => {
it("derives the pair from the port column when teardown erased the listeners", () => {
expect([...collectRowExposurePorts(stoppedTornDownRow())]).toEqual([42001, 52001]);
});
it("ignores a legacy pinned port outside the dedicated range", () => {
const row = stoppedTornDownRow({ port: 45439 });
expect([...collectRowExposurePorts(row)]).toEqual([]);
});
it("unions declared listeners with the port-derived pair", () => {
const row = stoppedTornDownRow({
exposure: { state: "ready", listeners: [{ targetPort: 42001 }, { targetPort: 52001 }] },
});
expect([...collectRowExposurePorts(row)].sort()).toEqual([42001, 52001]);
});
});
describe("regression 1: stopped-but-leased pair reuse is denied", () => {
it("reserves a torn-down lane's pair while its lease is still open", () => {
const ledger = buildExposureReservationLedger({
persistedRows: [stoppedTornDownRow()],
activeExecutionWorkspaceIds: [LEASED_WORKSPACE],
});
expect(ledger.reservedPorts.has(42001)).toBe(true);
expect(ledger.reservedPorts.has(52001)).toBe(true);
expect(ledger.reservationByPort.get(42001)).toMatchObject({
source: "leased_workspace",
owner: { executionWorkspaceId: LEASED_WORKSPACE, issueId: LEASED_ISSUE },
});
});
it("denies an unrelated workspace the leased pair, naming the holder", () => {
const ledger = buildExposureReservationLedger({
persistedRows: [stoppedTornDownRow()],
activeExecutionWorkspaceIds: [LEASED_WORKSPACE],
});
const conflict = findExposurePairConflict({
pair: { appPort: 42001, hmrPort: 52001 },
claimant: identity({ executionWorkspaceId: OTHER_WORKSPACE, runtimeServiceId: "runtime-posthog" }),
ledger,
});
expect(conflict).toMatchObject({ code: "reserved_by_other_workspace", port: 42001 });
const message = describeExposurePortConflict(conflict!);
expect(message).toContain("42001");
expect(message).toContain(LEASED_WORKSPACE);
expect(message).toContain(LEASED_ISSUE);
// The operator must be told the lane looks stopped on purpose, or the
// report reads as a stale-row bug rather than a live reservation.
expect(message).toContain("lease is still open");
});
it("lets the leaseholder's own lane reclaim its pair on restart", () => {
const ledger = buildExposureReservationLedger({
persistedRows: [stoppedTornDownRow()],
activeExecutionWorkspaceIds: [LEASED_WORKSPACE],
});
expect(findExposurePairConflict({
pair: { appPort: 42001, hmrPort: 52001 },
claimant: identity({ executionWorkspaceId: LEASED_WORKSPACE, runtimeServiceId: "runtime-lane-b-restart" }),
ledger,
})).toBeNull();
});
it("keeps the HMR companion reserved even when only the app port is claimed", () => {
const ledger = buildExposureReservationLedger({
persistedRows: [stoppedTornDownRow()],
activeExecutionWorkspaceIds: [LEASED_WORKSPACE],
});
// A different pair whose app port is free but whose companion is the
// lease's HMR port can never happen with the fixed offset, so assert the
// companion is independently defended instead.
expect(findExposurePairConflict({
pair: { appPort: 52001, hmrPort: 62001 },
claimant: identity({ executionWorkspaceId: OTHER_WORKSPACE }),
ledger,
})).toMatchObject({ code: "reserved_by_other_workspace", port: 52001 });
});
it("still reserves nothing for a row with no lease and no exposure", () => {
const ledger = buildExposureReservationLedger({
persistedRows: [stoppedTornDownRow()],
activeExecutionWorkspaceIds: [],
});
expect(ledger.reservedPorts.size).toBe(0);
});
});
describe("regression 2: cross-execution-workspace process adoption is denied", () => {
it("refuses adoption when the execution workspace IDs differ", () => {
expect(isExposureAdoptionPermitted(
identity({ executionWorkspaceId: LEASED_WORKSPACE }),
identity({ executionWorkspaceId: OTHER_WORKSPACE }),
)).toBe(false);
});
it("refuses adoption when either side's workspace is unknown", () => {
expect(isExposureAdoptionPermitted(
identity({ executionWorkspaceId: null, runtimeServiceId: "runtime-unknown" }),
identity({ executionWorkspaceId: OTHER_WORKSPACE }),
)).toBe(false);
expect(isExposureAdoptionPermitted(
identity({ executionWorkspaceId: LEASED_WORKSPACE }),
identity({ executionWorkspaceId: null }),
)).toBe(false);
// Two nulls are not "the same workspace"; they are two unknowns.
expect(isExposureAdoptionPermitted(identity(), identity())).toBe(false);
});
it("permits the same workspace and the same runtime service", () => {
expect(isExposureAdoptionPermitted(
identity({ executionWorkspaceId: LEASED_WORKSPACE }),
identity({ executionWorkspaceId: LEASED_WORKSPACE }),
)).toBe(true);
expect(isExposureAdoptionPermitted(
identity({ runtimeServiceId: "runtime-lane-b" }),
identity({ runtimeServiceId: "runtime-lane-b" }),
)).toBe(true);
});
it("denies a pair whose live listener belongs to another workspace", () => {
const conflict = findExposurePairConflict({
pair: { appPort: 42001, hmrPort: 52001 },
claimant: identity({ executionWorkspaceId: OTHER_WORKSPACE }),
ledger: buildExposureReservationLedger({}),
listenerOwners: new Map([[42001, identity({ executionWorkspaceId: LEASED_WORKSPACE, issueId: LEASED_ISSUE })]]),
});
expect(conflict).toMatchObject({ code: "listener_owned_by_other_workspace", port: 42001 });
expect(describeExposurePortConflict(conflict!)).toContain(LEASED_WORKSPACE);
});
it("denies a live listener it cannot attribute rather than assuming it is ours", () => {
const conflict = findExposurePairConflict({
pair: { appPort: 42001, hmrPort: 52001 },
claimant: identity({ executionWorkspaceId: OTHER_WORKSPACE }),
ledger: buildExposureReservationLedger({}),
listenerOwners: new Map([[52001, null]]),
});
expect(conflict).toMatchObject({ code: "listener_owned_by_other_workspace", port: 52001 });
expect(describeExposurePortConflict(conflict!)).toContain("an unidentified owner");
});
it("throws a terminal, attributed error on the allocation path", () => {
const conflict = findExposurePairConflict({
pair: { appPort: 42001, hmrPort: 52001 },
claimant: identity({ executionWorkspaceId: OTHER_WORKSPACE }),
ledger: buildExposureReservationLedger({
persistedRows: [stoppedTornDownRow()],
activeExecutionWorkspaceIds: [LEASED_WORKSPACE],
}),
});
const error = new ExposurePortOwnershipConflictError(conflict!);
expect(error.code).toBe("exposure_port_ownership_conflict");
expect(error.message).toContain("HTTPS exposure allocation denied");
expect(error.message).toContain(LEASED_WORKSPACE);
});
});
describe("regression 3: Serve mapping ownership mismatch is visible and fails closed", () => {
it("denies a pair published by another workspace's Serve mapping", () => {
const rows: PersistedExposureRowSnapshot[] = [
{
id: "runtime-posthog",
status: "running",
port: 42001,
exposure: { state: "ready", listeners: [{ targetPort: 42001 }, { targetPort: 52001 }] },
executionWorkspaceId: OTHER_WORKSPACE,
projectWorkspaceId: null,
issueId: null,
},
];
const ledger = buildExposureReservationLedger({
persistedRows: rows,
brokerMappings: [{ runtimeId: "runtime-posthog", port: 42001 }],
});
expect(ledger.reservationByPort.get(42001)).toMatchObject({
source: "broker_mapping",
owner: { executionWorkspaceId: OTHER_WORKSPACE },
});
const conflict = findExposurePairConflict({
pair: { appPort: 42001, hmrPort: 52001 },
claimant: identity({ executionWorkspaceId: LEASED_WORKSPACE }),
ledger,
serveMappingOwners: new Map([[42001, identity({ executionWorkspaceId: OTHER_WORKSPACE })]]),
});
expect(conflict).toMatchObject({ port: 42001 });
expect(describeExposurePortConflict(conflict!)).toContain(OTHER_WORKSPACE);
});
it("reports a serve-mapping mismatch when the ledger alone would allow it", () => {
const conflict = findExposurePairConflict({
pair: { appPort: 42005, hmrPort: 52005 },
claimant: identity({ executionWorkspaceId: LEASED_WORKSPACE }),
ledger: buildExposureReservationLedger({}),
serveMappingOwners: new Map([[52005, identity({ executionWorkspaceId: OTHER_WORKSPACE })]]),
});
expect(conflict).toMatchObject({ code: "serve_mapping_owned_by_other_workspace", port: 52005 });
expect(describeExposurePortConflict(conflict!)).toContain("Tailscale Serve mapping");
});
it("surfaces a stopped/removed row whose reserved port is mapped to another runtime", () => {
const drift = findExposureReservationDrift({
persistedRows: [
stoppedTornDownRow(),
{
id: "runtime-posthog",
status: "running",
port: null,
exposure: null,
executionWorkspaceId: OTHER_WORKSPACE,
projectWorkspaceId: null,
issueId: null,
},
],
brokerMappings: [{ runtimeId: "runtime-posthog", port: 42001 }],
});
expect(drift).toHaveLength(1);
expect(drift[0]).toMatchObject({
runtimeServiceId: "runtime-lane-b",
port: 42001,
reason: "serve_mapping",
conflictingOwner: { executionWorkspaceId: OTHER_WORKSPACE },
});
const description = describeExposureReservationDrift(drift[0]!);
expect(description).toContain("recorded stopped/removed");
expect(description).toContain(OTHER_WORKSPACE);
});
it("surfaces a stopped/removed row whose reserved port still has a live listener", () => {
const drift = findExposureReservationDrift({
persistedRows: [stoppedTornDownRow()],
livePorts: new Set([52001]),
});
expect(drift).toMatchObject([{ port: 52001, reason: "live_listener", conflictingOwner: null }]);
});
it("does not call a lane's own live listener drift", () => {
const drift = findExposureReservationDrift({
persistedRows: [stoppedTornDownRow()],
livePorts: new Set([42001]),
listenerOwners: new Map([[42001, identity({ runtimeServiceId: "runtime-lane-b" })]]),
});
expect(drift).toEqual([]);
});
it("does not report a healthy running lane as drift", () => {
const drift = findExposureReservationDrift({
persistedRows: [{
id: "runtime-lane-a",
status: "running",
port: 42000,
exposure: { state: "ready", listeners: [{ targetPort: 42000 }, { targetPort: 52000 }] },
executionWorkspaceId: LEASED_WORKSPACE,
projectWorkspaceId: null,
issueId: null,
}],
livePorts: new Set([42000, 52000]),
brokerMappings: [{ runtimeId: "runtime-lane-a", port: 42000 }],
});
expect(drift).toEqual([]);
});
});
describe("regression 4: concurrent allocators return unique pairs", () => {
it("claims both ports of a pair or neither", () => {
const claims = new ExposurePortPairClaims();
expect(claims.claim({ appPort: 42000, hmrPort: 52000 }, 0)).toBe(true);
// Overlapping on either port is refused, and the refusal must not leave a
// partial hold behind.
expect(claims.claim({ appPort: 42000, hmrPort: 52000 }, 0)).toBe(false);
expect(claims.claim({ appPort: 42001, hmrPort: 52000 }, 0)).toBe(false);
expect([...claims.activePorts(0)].sort()).toEqual([42000, 52000]);
});
it("expires a claim after its TTL so a crashed start cannot burn the range", () => {
const claims = new ExposurePortPairClaims(1_000);
expect(claims.claim({ appPort: 42000, hmrPort: 52000 }, 0)).toBe(true);
expect(claims.claim({ appPort: 42000, hmrPort: 52000 }, 500)).toBe(false);
expect(claims.claim({ appPort: 42000, hmrPort: 52000 }, 1_001)).toBe(true);
});
it("hands two concurrent allocators distinct pairs", async () => {
const claims = new ExposurePortPairClaims();
const allocate = () => allocateExposurePortPair({
isPortAvailable: async () => true,
claimPair: (pair) => claims.claim(pair),
});
const [first, second, third] = await Promise.all([allocate(), allocate(), allocate()]);
expect(first).toEqual({ appPort: 42000, hmrPort: 52000 });
expect(second).toEqual({ appPort: 42001, hmrPort: 52001 });
expect(third).toEqual({ appPort: 42002, hmrPort: 52002 });
});
it("gives concurrent allocators distinct pairs even when both prefer the same port", async () => {
const claims = new ExposurePortPairClaims();
const allocate = () => allocateExposurePortPair({
isPortAvailable: async () => true,
preferredAppPort: 42007,
claimPair: (pair) => claims.claim(pair),
});
const [first, second] = await Promise.all([allocate(), allocate()]);
expect(first.appPort).toBe(42007);
expect(second.appPort).not.toBe(42007);
expect(second.hmrPort).not.toBe(first.hmrPort);
});
it("does not claim a pair it rejects for availability", async () => {
const claims = new ExposurePortPairClaims();
await allocateExposurePortPair({
isPortAvailable: async (port) => port !== 42000 && port !== 52000,
claimPair: (pair) => claims.claim(pair),
});
// 42000 was probed and refused; holding it would waste the port for a full TTL.
expect(claims.activePorts()).toEqual(new Set([42001, 52001]));
});
it("folds live claims into a ledger so a second allocator sees them", () => {
const claims = new ExposurePortPairClaims();
claims.claim({ appPort: 42000, hmrPort: 52000 }, 0);
const ledger = buildExposureReservationLedger({ inFlightClaimedPorts: claims.activePorts(0) });
expect(findExposurePairConflict({
pair: { appPort: 42000, hmrPort: 52000 },
claimant: identity({ executionWorkspaceId: OTHER_WORKSPACE }),
ledger,
})).toMatchObject({ code: "reserved_by_other_workspace" });
});
});
describe("regression 5: release/teardown makes the pair reusable, sparing manual mappings", () => {
it("frees the pair once the lease is released", () => {
const row = stoppedTornDownRow();
const leased = buildExposureReservationLedger({
persistedRows: [row],
activeExecutionWorkspaceIds: [LEASED_WORKSPACE],
});
expect(leased.reservedPorts.has(42001)).toBe(true);
// Lease released: the workspace is archived/closed, so it no longer appears.
const released = buildExposureReservationLedger({
persistedRows: [row],
activeExecutionWorkspaceIds: [],
});
expect(released.reservedPorts.has(42001)).toBe(false);
expect(findExposurePairConflict({
pair: { appPort: 42001, hmrPort: 52001 },
claimant: identity({ executionWorkspaceId: OTHER_WORKSPACE }),
ledger: released,
})).toBeNull();
});
it("releases an in-process claim on teardown", () => {
const claims = new ExposurePortPairClaims();
claims.claim({ appPort: 42001, hmrPort: 52001 }, 0);
claims.release({ appPort: 42001, hmrPort: 52001 });
expect(claims.activePorts(0).size).toBe(0);
expect(claims.claim({ appPort: 42001, hmrPort: 52001 }, 0)).toBe(true);
});
it("keeps quarantined ports out of circulation after release", () => {
const ledger = buildExposureReservationLedger({
persistedRows: [stoppedTornDownRow()],
activeExecutionWorkspaceIds: [],
quarantinedPorts: [42001],
});
const conflict = findExposurePairConflict({
pair: { appPort: 42001, hmrPort: 52001 },
claimant: identity({ executionWorkspaceId: OTHER_WORKSPACE }),
ledger,
});
expect(conflict).toMatchObject({ code: "quarantined_port" });
expect(describeExposurePortConflict(conflict!)).toContain("quarantined");
});
it("never reserves or reports a manual/unknown mapping the broker does not own", () => {
// The broker's `list()` structurally cannot return unknown/manual entries,
// so a manual mapping on 42010 reaches neither the ledger nor drift. Assert
// the absence explicitly: a release path that started reserving or
// reporting them would be the PAP-17285 regression coming back.
const ledger = buildExposureReservationLedger({
persistedRows: [stoppedTornDownRow()],
activeExecutionWorkspaceIds: [],
brokerMappings: [],
});
expect(ledger.reservedPorts.has(42010)).toBe(false);
expect(findExposureReservationDrift({
persistedRows: [stoppedTornDownRow({ port: 42010 })],
brokerMappings: [],
})).toEqual([]);
});
it("lets a fresh workspace take the released pair end to end", async () => {
const claims = new ExposurePortPairClaims();
const ledger = buildExposureReservationLedger({
persistedRows: [stoppedTornDownRow()],
activeExecutionWorkspaceIds: [],
inFlightClaimedPorts: claims.activePorts(),
});
const pair = await allocateExposurePortPair({
isPortAvailable: async (port) => port >= 42001,
reserved: ledger.reservedPorts,
preferredAppPort: 42001,
claimPair: (candidate) => claims.claim(candidate),
});
expect(pair).toEqual({ appPort: 42001, hmrPort: 52001 });
expect(findExposurePairConflict({
pair,
claimant: identity({ executionWorkspaceId: OTHER_WORKSPACE }),
ledger,
})).toBeNull();
});
});
describe("reservation precedence", () => {
it("reports the strongest available evidence for a contested port", () => {
const ledger = buildExposureReservationLedger({
persistedRows: [stoppedTornDownRow()],
activeExecutionWorkspaceIds: [LEASED_WORKSPACE],
inMemoryRuntimes: [{
runtimeServiceId: "runtime-lane-b",
executionWorkspaceId: LEASED_WORKSPACE,
projectWorkspaceId: null,
issueId: LEASED_ISSUE,
ports: [42001],
}],
brokerMappings: [{ runtimeId: "runtime-lane-b", port: 42001 }],
});
// Running in this process beats a Serve mapping, which beats the lease.
expect(ledger.reservationByPort.get(42001)?.source).toBe("in_memory_runtime");
expect(ledger.reservationByPort.get(52001)?.source).toBe("leased_workspace");
});
it("quarantine outranks every other claim", () => {
const ledger = buildExposureReservationLedger({
persistedRows: [stoppedTornDownRow()],
activeExecutionWorkspaceIds: [LEASED_WORKSPACE],
quarantinedPorts: [42001],
});
expect(ledger.reservationByPort.get(42001)?.source).toBe("quarantine");
// Even the leaseholder cannot take a quarantined port back.
expect(findExposurePairConflict({
pair: { appPort: 42001, hmrPort: 52001 },
claimant: identity({ executionWorkspaceId: LEASED_WORKSPACE }),
ledger,
})).toMatchObject({ code: "quarantined_port" });
});
it("names an unresolvable broker runtime rather than reporting no holder", () => {
const ledger = buildExposureReservationLedger({
brokerMappings: [{ runtimeId: "runtime-vanished", port: 42003 }],
});
const conflict = findExposurePairConflict({
pair: { appPort: 42003, hmrPort: 52003 },
claimant: identity({ executionWorkspaceId: OTHER_WORKSPACE }),
ledger,
});
expect(describeExposurePortConflict(conflict!)).toContain("runtime-vanished");
});
});