diff --git a/web/src/serial.test.ts b/web/src/serial.test.ts index 6156868..9cb44b4 100644 --- a/web/src/serial.test.ts +++ b/web/src/serial.test.ts @@ -41,6 +41,7 @@ function response(id: number, data: Record = {}): string { function makeMockPort(startup: string, responses: MockResponse[]) { let readableController: ReadableStreamDefaultController | undefined; const writes: string[] = []; + const closeEvents: string[] = []; const readable = new ReadableStream({ start(controller) { @@ -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["port"]): void { @@ -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( diff --git a/web/src/serial.ts b/web/src/serial.ts index 37ff277..6ba41ba 100644 --- a/web/src/serial.ts +++ b/web/src/serial.ts @@ -36,10 +36,16 @@ interface SerialPortOptions { baudRate: number; } +interface SerialOutputSignals { + dataTerminalReady?: boolean; + requestToSend?: boolean; +} + interface SerialPortLike { readable: ReadableStream | null; writable: WritableStream | null; open(options: SerialPortOptions): Promise; + setSignals?(signals: SerialOutputSignals): Promise; close(): Promise; } @@ -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) }; @@ -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 { @@ -193,6 +202,28 @@ export class SerialSession implements DeviceTransport, CanonicalV2Transport { await this.port.close(); } + private async normalizeUsbSerialJtagControlLines(): Promise { + 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> { if (this.closed) throw new Error("Device is not connected"); if (this.inFlight) throw new Error("Another device request is already in progress");