fix: retain legacy plugin login output during session binding
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
1e608d78fc
commit
a31eba2381
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue