Bug: Non-blocking stdin polling deadlocks on Windows anonymous pipes in subprocess mode (chartread/spotread) #24

Closed
opened 2026-08-29 14:37:05 +01:00 by gronod · 0 comments
Owner

Bug Summary

When running interactive measurement utilities (such as chartread, spotread, dispcal, etc.) as child processes from frontend applications on Windows (e.g. Tauri, Node.js, Python subprocesses) with redirected standard input, holding or pressing the instrument trigger button does not initiate measurement and the measurement lamp never illuminates.

While calibration succeeds, the process hangs in the polling loop because ReadFile() deadlocks on the anonymous standard input pipe during non-blocking UI callback checks.


Root Cause Analysis

  1. Failure of SetNamedPipeHandleState on Anonymous Pipes:
    In numlib/numsup.c (check_if_not_interactive()), ArgyllCMS attempts to configure standard input for non-blocking I/O when redirected:

    if ((stdinh = GetStdHandle(STD_INPUT_HANDLE)) != INVALID_HANDLE_VALUE) {
        stdin_type = GetFileType(stdinh);
        if (stdin_type == FILE_TYPE_PIPE) {
            DWORD mode = PIPE_READMODE_BYTE | PIPE_NOWAIT;
            SetNamedPipeHandleState(stdinh, &mode, NULL, NULL);
        }
    }
    

    Under Windows Win32 API, SetNamedPipeHandleState() fails on anonymous pipes (e.g. pipes created by Win32 CreatePipe() or standard library subprocess spawn implementations like Rust Stdio::piped()). The anonymous pipe remains in synchronous blocking mode.

  2. Blocking Read in Non-Blocking Character Polling:
    In spectro/conv.c (con_char(int wait)), when not_interactive == 1 and stdin_type == FILE_TYPE_PIPE:

    } else if (stdin_type == FILE_TYPE_PIPE) {
        int i, bib;
        for (bib = 0; bib < 10;) {
            if ((!ReadFile(stdinh, buf + bib, 10 - bib, &bread, NULL) || bread == 0)
             && !wait) {
                break;
            }
            bib += bread;
            ...
        }
    }
    

    When wait == 0 (non-blocking poll via poll_con_char()), ReadFile() is called expecting it to return immediately if no data is available. Because the anonymous pipe is blocking, ReadFile() blocks indefinitely waiting for stdin input from the parent process.

  3. Deadlock in Instrument Switch Loop:
    During strip/spot measurement, i1pro_imp_measure() periodically invokes p->uicallback() to check for user abort/trigger commands. This invokes def_uicallback() (spectro/instappsup.c) -> poll_con_char() -> con_char(0).
    Because con_char(0) hangs on ReadFile(), the measurement thread never resumes to poll USB endpoint 0x84 for instrument switch events, completely preventing hardware trigger detection and lamp activation.


Deterministic Remediation Plan

1. File Modification: spectro/conv.c

Use PeekNamedPipe() to query available bytes before calling ReadFile() on pipe handles. PeekNamedPipe() is fully supported on both anonymous pipes and named pipes in Win32:

		/* We assume pipe has been set to NOWAIT mode. */
		} else if (stdin_type == FILE_TYPE_PIPE) {
			int i, bib;
			DWORD bytes_avail = 0;

			/* Check available bytes in pipe without blocking */
			if (!PeekNamedPipe(stdinh, NULL, 0, NULL, &bytes_avail, NULL) || bytes_avail == 0) {
				if (!wait) {
					return 0;
				}
			}

			for (bib = 0; bib < 10;) {
				if (!PeekNamedPipe(stdinh, NULL, 0, NULL, &bytes_avail, NULL) || bytes_avail == 0) {
					if (!wait) break;
				}
				if (!ReadFile(stdinh, buf + bib, 10 - bib, &bread, NULL) || bread == 0) {
					if (!wait) break;
				}
				bib += bread;

				for (i = 0; i < bib; i++) {
					if (buf[i] == '\n' || buf[i] == '\r' || buf[i] == 0x3) {
						break;
					}
				}
				if (i < bib) {
					break;		/* Found '\n' */
				}
				Sleep(100);		/* Wait for a line ending in '\n' */
			}
			rv = buf[0];

Step-by-Step Release Workflow Instructions (v3.5.0-ICCery.1.2)

  1. Create Fix Branch:
    Create a branch named fix/win32-pipe-peek-named-pipe based on development.

    git checkout development
    git pull origin development
    git checkout -b fix/win32-pipe-peek-named-pipe
    
  2. Apply Code Changes:
    Update spectro/conv.c with the PeekNamedPipe non-blocking check.

  3. Commit and Push:

    git add spectro/conv.c
    git commit -m "fix(spectro): use PeekNamedPipe to prevent blocking ReadFile on anonymous stdin pipes"
    git push -u origin fix/win32-pipe-peek-named-pipe
    
  4. Pull Request into development & Close Issue:

    • Open PR from fix/win32-pipe-peek-named-pipe into development.
    • Merge PR into development.
    • Close this issue.
  5. Pull Request into main:

    • Open PR from development into main.
    • Merge PR into main.
  6. Create Release v3.5.0-ICCery.1.2:

    • Tag and publish release v3.5.0-ICCery.1.2 targeting main.
    • Ensure CI workflow packages Windows binary archive with all drivers, docs, and verified executables.
### Bug Summary When running interactive measurement utilities (such as `chartread`, `spotread`, `dispcal`, etc.) as child processes from frontend applications on Windows (e.g. Tauri, Node.js, Python subprocesses) with redirected standard input, holding or pressing the instrument trigger button does not initiate measurement and the measurement lamp never illuminates. While calibration succeeds, the process hangs in the polling loop because `ReadFile()` deadlocks on the anonymous standard input pipe during non-blocking UI callback checks. --- ### Root Cause Analysis 1. **Failure of `SetNamedPipeHandleState` on Anonymous Pipes**: In `numlib/numsup.c` (`check_if_not_interactive()`), ArgyllCMS attempts to configure standard input for non-blocking I/O when redirected: ```c if ((stdinh = GetStdHandle(STD_INPUT_HANDLE)) != INVALID_HANDLE_VALUE) { stdin_type = GetFileType(stdinh); if (stdin_type == FILE_TYPE_PIPE) { DWORD mode = PIPE_READMODE_BYTE | PIPE_NOWAIT; SetNamedPipeHandleState(stdinh, &mode, NULL, NULL); } } ``` Under Windows Win32 API, `SetNamedPipeHandleState()` **fails on anonymous pipes** (e.g. pipes created by Win32 `CreatePipe()` or standard library subprocess spawn implementations like Rust `Stdio::piped()`). The anonymous pipe remains in synchronous **blocking** mode. 2. **Blocking Read in Non-Blocking Character Polling**: In `spectro/conv.c` (`con_char(int wait)`), when `not_interactive == 1` and `stdin_type == FILE_TYPE_PIPE`: ```c } else if (stdin_type == FILE_TYPE_PIPE) { int i, bib; for (bib = 0; bib < 10;) { if ((!ReadFile(stdinh, buf + bib, 10 - bib, &bread, NULL) || bread == 0) && !wait) { break; } bib += bread; ... } } ``` When `wait == 0` (non-blocking poll via `poll_con_char()`), `ReadFile()` is called expecting it to return immediately if no data is available. Because the anonymous pipe is blocking, **`ReadFile()` blocks indefinitely waiting for stdin input from the parent process**. 3. **Deadlock in Instrument Switch Loop**: During strip/spot measurement, `i1pro_imp_measure()` periodically invokes `p->uicallback()` to check for user abort/trigger commands. This invokes `def_uicallback()` (`spectro/instappsup.c`) -> `poll_con_char()` -> `con_char(0)`. Because `con_char(0)` hangs on `ReadFile()`, the measurement thread never resumes to poll USB endpoint `0x84` for instrument switch events, completely preventing hardware trigger detection and lamp activation. --- ### Deterministic Remediation Plan #### 1. File Modification: `spectro/conv.c` Use `PeekNamedPipe()` to query available bytes before calling `ReadFile()` on pipe handles. `PeekNamedPipe()` is fully supported on both anonymous pipes and named pipes in Win32: ```c /* We assume pipe has been set to NOWAIT mode. */ } else if (stdin_type == FILE_TYPE_PIPE) { int i, bib; DWORD bytes_avail = 0; /* Check available bytes in pipe without blocking */ if (!PeekNamedPipe(stdinh, NULL, 0, NULL, &bytes_avail, NULL) || bytes_avail == 0) { if (!wait) { return 0; } } for (bib = 0; bib < 10;) { if (!PeekNamedPipe(stdinh, NULL, 0, NULL, &bytes_avail, NULL) || bytes_avail == 0) { if (!wait) break; } if (!ReadFile(stdinh, buf + bib, 10 - bib, &bread, NULL) || bread == 0) { if (!wait) break; } bib += bread; for (i = 0; i < bib; i++) { if (buf[i] == '\n' || buf[i] == '\r' || buf[i] == 0x3) { break; } } if (i < bib) { break; /* Found '\n' */ } Sleep(100); /* Wait for a line ending in '\n' */ } rv = buf[0]; ``` --- ### Step-by-Step Release Workflow Instructions (`v3.5.0-ICCery.1.2`) 1. **Create Fix Branch**: Create a branch named `fix/win32-pipe-peek-named-pipe` based on `development`. ```bash git checkout development git pull origin development git checkout -b fix/win32-pipe-peek-named-pipe ``` 2. **Apply Code Changes**: Update `spectro/conv.c` with the `PeekNamedPipe` non-blocking check. 3. **Commit and Push**: ```bash git add spectro/conv.c git commit -m "fix(spectro): use PeekNamedPipe to prevent blocking ReadFile on anonymous stdin pipes" git push -u origin fix/win32-pipe-peek-named-pipe ``` 4. **Pull Request into `development` & Close Issue**: - Open PR from `fix/win32-pipe-peek-named-pipe` into `development`. - Merge PR into `development`. - Close this issue. 5. **Pull Request into `main`**: - Open PR from `development` into `main`. - Merge PR into `main`. 6. **Create Release `v3.5.0-ICCery.1.2`**: - Tag and publish release `v3.5.0-ICCery.1.2` targeting `main`. - Ensure CI workflow packages Windows binary archive with all drivers, docs, and verified executables.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: gronod/argyllcms#24