fix: retain legacy plugin login output during session binding

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-09 11:29:49 -05:00
parent 1e608d78fc
commit a31eba2381
2 changed files with 98 additions and 5 deletions

View File

@ -1631,7 +1631,7 @@ describe("plugin worker manager login pseudo-terminal concurrency", () => {
// ---------------------------------------------------------------------------
describe("plugin worker manager login pseudo-terminal missing hostRouteId diagnostic", () => {
it("delivers output from a legacy worker with no hostRouteId, resolved by the worker session id, and warns once", async () => {
it.each([false, true])("delivers legacy output and warns once (batched open reply: %s)", async (batchWithOpenReply) => {
const handle = makeLoginPtyHandle();
vi.mocked(logger.warn).mockClear();
try {
@ -1639,6 +1639,7 @@ describe("plugin worker manager login pseudo-terminal missing hostRouteId diagno
const chunks: string[] = [];
const route = await handle.openLoginPtySession(
ptyOpenInput({
batchWithOpenReply,
workerSessionId: "ws-A",
outputs: [{ chunk: "legacy-output", omitHostRouteId: true }],
}),
@ -1660,12 +1661,12 @@ describe("plugin worker manager login pseudo-terminal missing hostRouteId diagno
}
});
it("settles the login wait on a legacy worker's exit notification with no hostRouteId", async () => {
it.each([false, true])("settles a legacy exit notification (batched open reply: %s)", async (batchWithOpenReply) => {
const handle = makeLoginPtyHandle();
try {
await handle.start();
const route = await handle.openLoginPtySession(
ptyOpenInput({ workerSessionId: "ws-A", exitCode: 0, omitHostRouteIdOnExit: true }),
ptyOpenInput({ batchWithOpenReply, workerSessionId: "ws-A", exitCode: 0, omitHostRouteIdOnExit: true }),
);
await expect(route.wait()).resolves.toEqual({ exitCode: 0 });
} finally {
@ -1724,7 +1725,7 @@ describe("plugin worker manager login pseudo-terminal missing hostRouteId diagno
}
});
it("drops a no-hostRouteId message naming a concurrent route's session instead of cross-delivering it", async () => {
it.each([false, true])("drops ambiguous legacy output with concurrent routes (batched open reply: %s)", async (batchWithOpenReply) => {
const handle = makeLoginPtyHandle();
try {
await handle.start();
@ -1736,6 +1737,7 @@ describe("plugin worker manager login pseudo-terminal missing hostRouteId diagno
);
const second = await handle.openLoginPtySession(
ptyOpenInput({
batchWithOpenReply,
workerSessionId: "ws-B",
outputs: [{ chunk: "cross-route", sid: "ws-A", omitHostRouteId: true }],
}),
@ -1755,6 +1757,90 @@ describe("plugin worker manager login pseudo-terminal missing hostRouteId diagno
await handle.stop().catch(() => undefined);
}
});
it("validates queued legacy session IDs and preserves output/exit ordering", async () => {
const handle = makeLoginPtyHandle();
try {
await handle.start();
const route = await handle.openLoginPtySession(ptyOpenInput({
batchWithOpenReply: true,
workerSessionId: "ws-A",
outputs: [
{ chunk: "forged", sid: "ws-forged", omitHostRouteId: true },
{ chunk: "accepted", omitHostRouteId: true },
],
exitCode: 0,
omitHostRouteIdOnExit: true,
outputsAfterExit: [{ chunk: "after-exit", omitHostRouteId: true }],
}));
const chunks: string[] = [];
route.onData((chunk) => chunks.push(chunk));
await expect(route.wait()).resolves.toEqual({ exitCode: 0 });
expect(chunks).toEqual(["accepted"]);
await route.close();
} finally {
await handle.stop().catch(() => undefined);
}
});
it("drops a forged legacy exit queued before the session binds", async () => {
const handle = makeLoginPtyHandle();
try {
await handle.start();
const route = await handle.openLoginPtySession(ptyOpenInput({
batchWithOpenReply: true,
workerSessionId: "ws-A",
// An empty host route ID takes the same legacy resolution path as an
// omitted one. The fixture can order these exits around real output.
sequence: [
{ type: "exit", hostRouteId: "", sid: "ws-forged", exitCode: 17 },
{ type: "output", hostRouteId: "", chunk: "still-open" },
{ type: "exit", hostRouteId: "", exitCode: 0 },
],
}));
const chunks: string[] = [];
route.onData((chunk) => chunks.push(chunk));
await expect(route.wait()).resolves.toEqual({ exitCode: 0 });
expect(chunks).toEqual(["still-open"]);
await route.close();
} finally {
await handle.stop().catch(() => undefined);
}
});
it.each(["malformed-open", "frame-overflow", "character-overflow"])("clears legacy pre-bind records and releases the route after %s", async (failure) => {
const handle = makeLoginPtyHandle({
duplexRouteSlots: createDuplexRouteSlotController(1),
loginPtyLimits: {
...(failure === "frame-overflow" ? { maxPreBindFrames: 1 } : {}),
...(failure === "character-overflow" ? { maxPreBindChars: 20 } : {}),
},
});
try {
await handle.start();
await expect(handle.openLoginPtySession(ptyOpenInput({
batchWithOpenReply: true,
mode: failure === "malformed-open" ? "malformed-open" : "normal",
workerSessionId: "ws-A",
outputs: [
{ chunk: "old-private-output", omitHostRouteId: true },
{ chunk: "overflow", omitHostRouteId: true },
],
}))).rejects.toThrow("LOGIN_PTY_OPEN_FAILED");
// Reuse the same worker session identifier. Neither retained frames nor
// an unreleased route slot may leak from the rejected opening.
const route = await handle.openLoginPtySession(ptyOpenInput({
batchWithOpenReply: true, workerSessionId: "ws-A",
outputs: [{ chunk: "new", omitHostRouteId: true }],
}));
const chunks: string[] = [];
route.onData((chunk) => chunks.push(chunk));
expect(chunks).toEqual(["new"]);
await route.close();
} finally {
await handle.stop().catch(() => undefined);
}
});
});
// ---------------------------------------------------------------------------

View File

@ -1548,7 +1548,14 @@ export function createPluginWorkerHandle(
if (loginPtyRoutesByHostRouteId.size !== 1) return null;
const workerSessionId = readNonEmptyString(params.workerSessionId);
if (!workerSessionId) return null;
return loginPtyRoutesByWorkerSessionId.get(workerSessionId) ?? null;
const bound = loginPtyRoutesByWorkerSessionId.get(workerSessionId);
if (bound) return bound;
// The open reply and a legacy notification can share one stdout chunk,
// before the await continuation binds the session ID. Queue against the
// sole opening route; bounded replay still checks the claimed ID against
// the validated reply. Never guess between concurrent or bound routes.
const opening = loginPtyRoutesByHostRouteId.values().next().value;
return opening?.state === "opening" ? opening : null;
}
// Route one login pseudo-terminal output notification to the per-session