From a65b71a26ec040a8e6ec4b6e461e01796ab427ae Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:05:19 +0900 Subject: [PATCH 1/4] fix(web): normalize USB Serial/JTAG lines before close --- web/src/serial.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/web/src/serial.ts b/web/src/serial.ts index 37ff277..6376b80 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; } @@ -174,6 +180,8 @@ export class SerialSession implements DeviceTransport, CanonicalV2Transport { this.staleInitialHelloResponseId = undefined; this.staleInitialHelloResponsesRemaining = 0; + await this.normalizeUsbSerialJtagControlLinesBeforeClose(); + try { await this.reader.cancel(); } catch { @@ -193,6 +201,27 @@ export class SerialSession implements DeviceTransport, CanonicalV2Transport { await this.port.close(); } + private async normalizeUsbSerialJtagControlLinesBeforeClose(): Promise { + const setSignals = this.port.setSignals?.bind(this.port); + if (!setSignals) return; + + // ESP32-S3 USB Serial/JTAG treats RTS=1,DTR=0 as 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"); From 851bacb738beeef033949f56583325055aec283e Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:05:24 +0900 Subject: [PATCH 2/4] test(web): cover safe serial control-line close ordering --- web/src/serial.test.ts | 62 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/web/src/serial.test.ts b/web/src/serial.test.ts index 6156868..b8b5578 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,57 @@ afterEach(() => { vi.unstubAllGlobals(); }); +describe("SerialSession USB Serial/JTAG close boundary", () => { + it("deasserts RTS before DTR in separate operations before closing the port", async () => { + const { port, closeEvents } = makeMockPort("", [helloResponse()]); + installSerial(port); + + const { session } = await SerialSession.connect(); + 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 when RTS deassertion cannot be established", async () => { + const { port, closeEvents } = makeMockPort("", [helloResponse()]); + port.setSignals.mockImplementationOnce(async () => { + closeEvents.push("rts:failed"); + throw new Error("synthetic RTS failure"); + }); + installSerial(port); + + const { session } = await SerialSession.connect(); + 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()]); + 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"); + }); + installSerial(port); + + const { session } = await SerialSession.connect(); + 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( From dd981d15ddd799b74bca6dcf78fbc9ef9b4b1475 Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:06:10 +0900 Subject: [PATCH 3/4] fix(web): establish safe serial signals on connect --- web/src/serial.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/web/src/serial.ts b/web/src/serial.ts index 6376b80..6ba41ba 100644 --- a/web/src/serial.ts +++ b/web/src/serial.ts @@ -126,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) }; @@ -180,7 +181,7 @@ export class SerialSession implements DeviceTransport, CanonicalV2Transport { this.staleInitialHelloResponseId = undefined; this.staleInitialHelloResponsesRemaining = 0; - await this.normalizeUsbSerialJtagControlLinesBeforeClose(); + await this.normalizeUsbSerialJtagControlLines(); try { await this.reader.cancel(); @@ -201,14 +202,15 @@ export class SerialSession implements DeviceTransport, CanonicalV2Transport { await this.port.close(); } - private async normalizeUsbSerialJtagControlLinesBeforeClose(): Promise { + private async normalizeUsbSerialJtagControlLines(): Promise { const setSignals = this.port.setSignals?.bind(this.port); if (!setSignals) return; - // ESP32-S3 USB Serial/JTAG treats RTS=1,DTR=0 as 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. + // 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 { From 0c0f476fd40cee0e537e36636775d3680cf4eedd Mon Sep 17 00:00:00 2001 From: Miso Tanaka <22117028+miso-develop@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:06:13 +0900 Subject: [PATCH 4/4] test(web): cover safe serial signals on connect and close --- web/src/serial.test.ts | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/web/src/serial.test.ts b/web/src/serial.test.ts index b8b5578..9cb44b4 100644 --- a/web/src/serial.test.ts +++ b/web/src/serial.test.ts @@ -104,12 +104,26 @@ afterEach(() => { vi.unstubAllGlobals(); }); -describe("SerialSession USB Serial/JTAG close boundary", () => { - it("deasserts RTS before DTR in separate operations before closing the port", async () => { +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 }); @@ -118,15 +132,17 @@ describe("SerialSession USB Serial/JTAG close boundary", () => { expect(port.close).toHaveBeenCalledOnce(); }); - it("does not deassert DTR when RTS deassertion cannot be established", async () => { + 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"); }); - installSerial(port); - - const { session } = await SerialSession.connect(); await session.close(); expect(port.setSignals).toHaveBeenCalledTimes(1); @@ -137,6 +153,11 @@ describe("SerialSession USB Serial/JTAG close boundary", () => { 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)}`); }); @@ -144,9 +165,6 @@ describe("SerialSession USB Serial/JTAG close boundary", () => { closeEvents.push("dtr:failed"); throw new Error("synthetic DTR failure"); }); - installSerial(port); - - const { session } = await SerialSession.connect(); await session.close(); expect(port.setSignals).toHaveBeenCalledTimes(2);