Skip to content

fix(unix): prevent close/drain hangs after write timeouts#237

Open
dcodeIO wants to merge 2 commits intoserialport:mainfrom
dcodeIO:fix/linux-drain-close-hang
Open

fix(unix): prevent close/drain hangs after write timeouts#237
dcodeIO wants to merge 2 commits intoserialport:mainfrom
dcodeIO:fix/linux-drain-close-hang

Conversation

@dcodeIO
Copy link

@dcodeIO dcodeIO commented Feb 18, 2026

While working with #235 (in WSL), I noticed that the Unix serial port implementation could hang on close/drain timeout paths, leaving the process alive after final output.

Particularly, the close path is hardened by setting O_NONBLOCK, flushing pending TX (tcflush(..., TCOFLUSH)), and then closing the fd. This avoids blocking close behavior after prior write timeouts.

The Linux drain path is made close-aware: DrainBaton now checks TIOCOUTQ and exits early when close is in progress, rather than continuing to wait in timeout-heavy scenarios.

Close intent is now marked at Close(...) API entry (before queueing the close worker), so in-flight drain workers can observe cancellation immediately. This removes a race where worker-pool saturation could delay close and leave timeout-path runs hanging.

Repros:

import { autoDetect } from "@serialport/bindings-cpp";

const Binding = autoDetect();
const path = process.argv[2] ?? "/dev/ttyACM0";
const port = await Binding.open({ path, baudRate: 115200 });
const payload = Buffer.alloc(46, 0x55);

function withTimeout(promise, ms, label) {
  return Promise.race([
    promise,
    new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)),
  ]);
}

// Repro A: drain-close interaction (drain cancellation + early close-intent timing)
for (let i = 0; i < 7; i++) {
  try {
    await withTimeout(port.write(payload), 3000, `write#${i + 1}`);
    await withTimeout(port.drain(), 3000, `drain#${i + 1}`);
  } catch {
    // intentionally continue stressing timeout path
  }
}

await withTimeout(port.close(), 3000, "close");
console.log("closed cleanly");
// Before this patch: process could linger after this point
...

// Repro B: close hardening path (close after timeout-path write, no drain loop)
try {
  await withTimeout(port.write(payload), 3000, "write");
} catch {
  // expected on stressed/non-responsive path
}

await withTimeout(port.close(), 3000, "close");
console.log("closed cleanly");
// Before this patch: close could block/linger on Unix timeout path

I also tested equivalent timeout paths on Windows and did not reproduce a similar close/drain lingering issue. I did observe a post-close "GetOverlappedResult: Operation aborted" error event there, but this appears to be separate platform-specific behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant