Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 78 additions & 2 deletions web/src/serial.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ function response(id: number, data: Record<string, unknown> = {}): string {
function makeMockPort(startup: string, responses: MockResponse[]) {
let readableController: ReadableStreamDefaultController<Uint8Array> | undefined;
const writes: string[] = [];
const closeEvents: string[] = [];

const readable = new ReadableStream<Uint8Array>({
start(controller) {
Expand Down Expand Up @@ -74,10 +75,16 @@ function makeMockPort(startup: string, responses: MockResponse[]) {
readable,
writable,
open: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
setSignals: vi.fn(async (signals: { requestToSend?: boolean; dataTerminalReady?: boolean }) => {
if ("requestToSend" in signals) closeEvents.push(`rts:${String(signals.requestToSend)}`);
if ("dataTerminalReady" in signals) closeEvents.push(`dtr:${String(signals.dataTerminalReady)}`);
}),
close: vi.fn(async () => {
closeEvents.push("close");
}),
};

return { port, writes };
return { port, writes, closeEvents };
}

function installSerial(port: ReturnType<typeof makeMockPort>["port"]): void {
Expand All @@ -97,6 +104,75 @@ afterEach(() => {
vi.unstubAllGlobals();
});

describe("SerialSession USB Serial/JTAG control-line boundary", () => {
it("establishes the safe RTS-then-DTR state immediately after opening", async () => {
const { port, closeEvents } = makeMockPort("", [helloResponse()]);
installSerial(port);

const { session } = await SerialSession.connect();

expect(port.setSignals).toHaveBeenNthCalledWith(1, { requestToSend: false });
expect(port.setSignals).toHaveBeenNthCalledWith(2, { dataTerminalReady: false });
expect(closeEvents).toEqual(["rts:false", "dtr:false"]);
await session.close();
});

it("re-establishes RTS before DTR in separate operations before closing the port", async () => {
const { port, closeEvents } = makeMockPort("", [helloResponse()]);
installSerial(port);

const { session } = await SerialSession.connect();
port.setSignals.mockClear();
closeEvents.length = 0;
await session.close();

expect(port.setSignals).toHaveBeenNthCalledWith(1, { requestToSend: false });
expect(port.setSignals).toHaveBeenNthCalledWith(2, { dataTerminalReady: false });
expect(closeEvents).toEqual(["rts:false", "dtr:false", "close"]);
expect(port.close).toHaveBeenCalledOnce();
});

it("does not deassert DTR during close when RTS deassertion cannot be established", async () => {
const { port, closeEvents } = makeMockPort("", [helloResponse()]);
installSerial(port);

const { session } = await SerialSession.connect();
port.setSignals.mockClear();
closeEvents.length = 0;
port.setSignals.mockImplementationOnce(async () => {
closeEvents.push("rts:failed");
throw new Error("synthetic RTS failure");
});
await session.close();

expect(port.setSignals).toHaveBeenCalledTimes(1);
expect(port.setSignals).toHaveBeenCalledWith({ requestToSend: false });
expect(closeEvents).toEqual(["rts:failed", "close"]);
expect(port.close).toHaveBeenCalledOnce();
});

it("still closes after DTR deassertion fails once RTS is safely deasserted", async () => {
const { port, closeEvents } = makeMockPort("", [helloResponse()]);
installSerial(port);

const { session } = await SerialSession.connect();
port.setSignals.mockClear();
closeEvents.length = 0;
port.setSignals.mockImplementationOnce(async (signals) => {
closeEvents.push(`rts:${String(signals.requestToSend)}`);
});
port.setSignals.mockImplementationOnce(async () => {
closeEvents.push("dtr:failed");
throw new Error("synthetic DTR failure");
});
await session.close();

expect(port.setSignals).toHaveBeenCalledTimes(2);
expect(closeEvents).toEqual(["rts:false", "dtr:failed", "close"]);
expect(port.close).toHaveBeenCalledOnce();
});
});

describe("SerialSession initial Protocol 2 synchronization", () => {
it("skips bounded ESP-IDF startup and blank lines before the first hello response", async () => {
const { port, writes } = makeMockPort(
Expand Down
31 changes: 31 additions & 0 deletions web/src/serial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,16 @@ interface SerialPortOptions {
baudRate: number;
}

interface SerialOutputSignals {
dataTerminalReady?: boolean;
requestToSend?: boolean;
}

interface SerialPortLike {
readable: ReadableStream<Uint8Array> | null;
writable: WritableStream<Uint8Array> | null;
open(options: SerialPortOptions): Promise<void>;
setSignals?(signals: SerialOutputSignals): Promise<void>;
close(): Promise<void>;
}

Expand Down Expand Up @@ -120,6 +126,7 @@ export class SerialSession implements DeviceTransport, CanonicalV2Transport {
}

const session = new SerialSession(port, port.readable.getReader(), port.writable.getWriter());
await session.normalizeUsbSerialJtagControlLines();
try {
const data = await session.requestInitialHello();
return { session, hello: parseCanonicalHelloData(data) };
Expand Down Expand Up @@ -174,6 +181,8 @@ export class SerialSession implements DeviceTransport, CanonicalV2Transport {
this.staleInitialHelloResponseId = undefined;
this.staleInitialHelloResponsesRemaining = 0;

await this.normalizeUsbSerialJtagControlLines();

try {
await this.reader.cancel();
} catch {
Expand All @@ -193,6 +202,28 @@ export class SerialSession implements DeviceTransport, CanonicalV2Transport {
await this.port.close();
}

private async normalizeUsbSerialJtagControlLines(): Promise<void> {
const setSignals = this.port.setSignals?.bind(this.port);
if (!setSignals) return;

// Keep ESP32-S3 USB Serial/JTAG in a safe host-control state throughout
// the open session and immediately before close. RTS=1,DTR=0 is a reset
// request. Web Serial applies DTR before RTS when both are supplied
// together, so never normalize both lines in one call. Deassert RTS first;
// only after that succeeds is it safe to deassert DTR.
try {
await setSignals({ requestToSend: false });
} catch {
return;
}

try {
await setSignals({ dataTerminalReady: false });
} catch {
// RTS is already deasserted. Port/resource teardown must still proceed.
}
}

private async requestInitialHello(): Promise<Record<string, unknown>> {
if (this.closed) throw new Error("Device is not connected");
if (this.inFlight) throw new Error("Another device request is already in progress");
Expand Down
Loading