From 4bf914467aa869c580f2288f10a1bef581854486 Mon Sep 17 00:00:00 2001 From: Haoxiang Fei Date: Mon, 15 Jun 2026 11:13:54 +0800 Subject: [PATCH 1/3] chore(deps): upgrade moonbitlang/async 0.19.1 -> 0.19.4 Bump every workspace manifest (root, tests, tests/isatty, tests/resize, tests/open, examples) so the workspace resolves to a single async version. No source or public API change; .mbti files are unchanged. Stacked on the output write lock branch; the lock only needed Semaphore (already in 0.19.1), so the dependency bump is isolated here for review. A prior combined attempt timed out the windows pty Tty::open test on 0.19.4; this isolated bump lets CI judge whether that was a real regression or a flake. See docs/plans/2026-06-15-async-0.19.4-upgrade.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plan.md | 1 + docs/plans/2026-06-15-async-0.19.4-upgrade.md | 94 +++++++++++++++++++ examples/moon.mod | 2 +- moon.mod | 2 +- tests/isatty/moon.mod | 2 +- tests/moon.mod | 2 +- tests/open/moon.mod | 2 +- tests/resize/moon.mod | 2 +- 8 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 docs/plans/2026-06-15-async-0.19.4-upgrade.md diff --git a/docs/plan.md b/docs/plan.md index 0f212e3..f7815e8 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -59,6 +59,7 @@ This board tracks implementation direction for `moonbit-community/tty`. Use | TTY-22 | done | Windows input record enum | `docs/plans/2026-06-09-windows-input-record-enum.md`, root package | Windows `INPUT_RECORD` bytes parse into private typed variants, including native mouse records | `moon fmt`, targeted Windows wbtests, `moon test .`, `moon test`, `moon check`, `moon info`, `git diff --check` | | MVP-2 | done | queued input and shell commands in agent demo | `docs/plans/2026-05-21-agent-queued-shell.md`, `examples/agent` | `Tab` queues input for delayed submission and `!cmd` runs through `moonbitlang/async/process` without background tasks writing directly to tty | `moon fmt`, `moon check examples/agent`, `moon test examples/agent`, `moon check`, `moon test`, `moon info`, `git diff --check` | | TTY-23 | done | serialize root `Tty` output writes | `docs/plans/2026-06-15-output-write-lock.md`, `internal/lock/`, root package | a single `Tty::write`/`Tty::write_string` call lands atomically under concurrent writers via an internal `Lock`, with no public API change | `moon fmt`, `moon test internal/lock`, `moon test .`, `moon test`, `moon check`, `moon info`, `git diff --check` | +| DEP-1 | active | upgrade `moonbitlang/async` 0.19.1 -> 0.19.4 | `docs/plans/2026-06-15-async-0.19.4-upgrade.md`, all workspace `moon.mod` files | every module pins `0.19.4` and CI stays green on all platforms with no `.mbti` change | `moon fmt`, `moon check`, `moon check --target native`, `moon test`, `moon test` (`tests/`), `moon info`, `git diff --check` | ## Current Rules diff --git a/docs/plans/2026-06-15-async-0.19.4-upgrade.md b/docs/plans/2026-06-15-async-0.19.4-upgrade.md new file mode 100644 index 0000000..ee96f94 --- /dev/null +++ b/docs/plans/2026-06-15-async-0.19.4-upgrade.md @@ -0,0 +1,94 @@ +# Upgrade moonbitlang/async 0.19.1 -> 0.19.4 + +## Goal + +Move every workspace module from `moonbitlang/async@0.19.1` to +`moonbitlang/async@0.19.4` without changing this package's public API or +behavior, and confirm the upgrade is safe on all CI platforms (notably +windows-latest, where an earlier attempt regressed). + +## Status + +Active. + +## Context And Decisions + +- The output write lock (TTY-23, + `docs/plans/2026-06-15-output-write-lock.md`) only needs `Semaphore`, which + exists in `0.19.1`, so it intentionally stayed on `0.19.1`. This task does the + dependency bump on its own so the two concerns stay reviewable in isolation. +- Stacked on `feat/output-write-lock`; base this PR on that branch so the diff is + only the version bump plus this plan. +- A prior combined attempt on `feat/output-write-lock` saw windows-latest time + out in the `tests/open` `Tty::open` pty test on `0.19.4`, which passed again + after reverting to `0.19.1`. The intervening async diff is mostly mechanical + (`async fn` trait-method syntax) plus a new directory-watch subsystem and a + changed `IoHandle::detach_from_event_loop` signature; none of it obviously + breaks a plain CONOUT$ write, so the timeout may have been a flake. CI on this + isolated bump is the deciding signal. +- Bump all six manifests that pin async so the workspace resolves to a single + version and declared deps match what is actually used: + root, `tests`, `tests/isatty`, `tests/resize`, `tests/open`, `examples`. + +## Target Files + +- `moon.mod` +- `tests/moon.mod` +- `tests/isatty/moon.mod` +- `tests/resize/moon.mod` +- `tests/open/moon.mod` +- `examples/moon.mod` +- `docs/plan.md` + +## Public API Changes + +None. No `.mbti` change expected (dependency version only). + +## Invariants + +- No source change; only dependency version strings move. +- `Tty` public API and write behavior are unchanged. +- Workspace resolves to exactly one `moonbitlang/async` version (`0.19.4`). + +## Acceptance Criteria + +- All six manifests pin `0.19.4`. +- `moon check`, `moon check --target native`, and the full test suite pass + locally. +- CI passes on ubuntu-latest, macos-latest, and windows-latest (the windows + pty `Tty::open` test in particular). +- Generated `.mbti` diffs are empty. + +## Validation Commands + +- `moon fmt` +- `moon check` +- `moon check --target native` +- `moon test` (root module) +- `moon test` in `tests/` (pty-backed module) +- `moon info` +- review generated `.mbti` diff +- `git diff --check` + +## Public API Audit + +- No public API change. `.mbti` files unchanged after `moon info`. +- No new public type, mutable field, parser state, input event, platform handle, + backend selection, or capability query. +- The only dependency change is the async version (`0.19.1` -> `0.19.4`). + +## Validation Results + +- `moon check` passed. +- `moon check --target native` passed. +- `moon test` (root) passed: 165 tests. +- `moon test` (`tests/`) passed: 133 tests. +- `moon info` left `.mbti` files unchanged. +- `git diff --check` passed. +- CI result: pending (windows-latest is the gating signal). + +## Open Questions + +- If windows-latest regresses again on `0.19.4`, investigate the + `detach_from_event_loop` / event-loop changes against the CONOUT$ write+close + path before landing, or hold the upgrade. diff --git a/examples/moon.mod b/examples/moon.mod index 9a1620f..8b39717 100644 --- a/examples/moon.mod +++ b/examples/moon.mod @@ -4,7 +4,7 @@ version = "0.1.0" import { "moonbit-community/tty@0.1.0", - "moonbitlang/async@0.19.1", + "moonbitlang/async@0.19.4", "kawaz/grapheme@0.10.2", "rami3l/unicodewidth@0.2.0", } diff --git a/moon.mod b/moon.mod index d453eb5..3dab2d3 100644 --- a/moon.mod +++ b/moon.mod @@ -3,7 +3,7 @@ name = "moonbit-community/tty" version = "0.2.5" import { - "moonbitlang/async@0.19.1", + "moonbitlang/async@0.19.4", } readme = "README.md" diff --git a/tests/isatty/moon.mod b/tests/isatty/moon.mod index c4ed3cc..5ad8d16 100644 --- a/tests/isatty/moon.mod +++ b/tests/isatty/moon.mod @@ -4,7 +4,7 @@ version = "0.1.0" import { "moonbit-community/tty@0.1.0", - "moonbitlang/async@0.19.1", + "moonbitlang/async@0.19.4", } preferred_target = "native" diff --git a/tests/moon.mod b/tests/moon.mod index 4c7bb37..263d2f2 100644 --- a/tests/moon.mod +++ b/tests/moon.mod @@ -5,7 +5,7 @@ version = "0.1.0" import { "moonbit-community/tty@0.1.0", "moonbit-community/pty@0.2.1", - "moonbitlang/async@0.19.1", + "moonbitlang/async@0.19.4", } preferred_target = "native" diff --git a/tests/open/moon.mod b/tests/open/moon.mod index dd6d2cd..5b361cc 100644 --- a/tests/open/moon.mod +++ b/tests/open/moon.mod @@ -4,7 +4,7 @@ version = "0.1.0" import { "moonbit-community/tty@0.1.0", - "moonbitlang/async@0.19.1", + "moonbitlang/async@0.19.4", } preferred_target = "native" diff --git a/tests/resize/moon.mod b/tests/resize/moon.mod index e4767fa..851b3ef 100644 --- a/tests/resize/moon.mod +++ b/tests/resize/moon.mod @@ -4,7 +4,7 @@ version = "0.1.0" import { "moonbit-community/tty@0.1.0", - "moonbitlang/async@0.19.1", + "moonbitlang/async@0.19.4", } preferred_target = "native" From d3fe5162b90a4df3f8ee0a8b5fc18179b5698265 Mon Sep 17 00:00:00 2001 From: Haoxiang Fei Date: Mon, 15 Jun 2026 11:17:28 +0800 Subject: [PATCH 2/3] docs(plan): record windows 0.19.4 regression (DEP-1 blocked) CI on the isolated bump reproduces the windows-latest Tty::open timeout, confirming a real 0.19.4 regression rather than a flake. Mark DEP-1 blocked pending investigation of the async event-loop change. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plan.md | 2 +- docs/plans/2026-06-15-async-0.19.4-upgrade.md | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/plan.md b/docs/plan.md index f7815e8..0c7c8a6 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -59,7 +59,7 @@ This board tracks implementation direction for `moonbit-community/tty`. Use | TTY-22 | done | Windows input record enum | `docs/plans/2026-06-09-windows-input-record-enum.md`, root package | Windows `INPUT_RECORD` bytes parse into private typed variants, including native mouse records | `moon fmt`, targeted Windows wbtests, `moon test .`, `moon test`, `moon check`, `moon info`, `git diff --check` | | MVP-2 | done | queued input and shell commands in agent demo | `docs/plans/2026-05-21-agent-queued-shell.md`, `examples/agent` | `Tab` queues input for delayed submission and `!cmd` runs through `moonbitlang/async/process` without background tasks writing directly to tty | `moon fmt`, `moon check examples/agent`, `moon test examples/agent`, `moon check`, `moon test`, `moon info`, `git diff --check` | | TTY-23 | done | serialize root `Tty` output writes | `docs/plans/2026-06-15-output-write-lock.md`, `internal/lock/`, root package | a single `Tty::write`/`Tty::write_string` call lands atomically under concurrent writers via an internal `Lock`, with no public API change | `moon fmt`, `moon test internal/lock`, `moon test .`, `moon test`, `moon check`, `moon info`, `git diff --check` | -| DEP-1 | active | upgrade `moonbitlang/async` 0.19.1 -> 0.19.4 | `docs/plans/2026-06-15-async-0.19.4-upgrade.md`, all workspace `moon.mod` files | every module pins `0.19.4` and CI stays green on all platforms with no `.mbti` change | `moon fmt`, `moon check`, `moon check --target native`, `moon test`, `moon test` (`tests/`), `moon info`, `git diff --check` | +| DEP-1 | blocked | upgrade `moonbitlang/async` 0.19.1 -> 0.19.4 | `docs/plans/2026-06-15-async-0.19.4-upgrade.md`, all workspace `moon.mod` files | every module pins `0.19.4` and CI stays green on all platforms with no `.mbti` change | `moon fmt`, `moon check`, `moon check --target native`, `moon test`, `moon test` (`tests/`), `moon info`, `git diff --check` | ## Current Rules diff --git a/docs/plans/2026-06-15-async-0.19.4-upgrade.md b/docs/plans/2026-06-15-async-0.19.4-upgrade.md index ee96f94..c119fe5 100644 --- a/docs/plans/2026-06-15-async-0.19.4-upgrade.md +++ b/docs/plans/2026-06-15-async-0.19.4-upgrade.md @@ -9,7 +9,9 @@ windows-latest, where an earlier attempt regressed). ## Status -Active. +Blocked. The isolated bump reproduces the windows-latest regression (see +Validation Results); holding until the async event-loop change is understood or +fixed upstream. ## Context And Decisions @@ -85,7 +87,11 @@ None. No `.mbti` change expected (dependency version only). - `moon test` (`tests/`) passed: 133 tests. - `moon info` left `.mbti` files unchanged. - `git diff --check` passed. -- CI result: pending (windows-latest is the gating signal). +- CI result: ubuntu-latest and macos-latest passed; **windows-latest failed** + reproducibly on the isolated bump with the same failure as the earlier + combined attempt: `tests/open` `Tty::open` timed out (`TimeoutError`, + 176/177). This confirms a real `0.19.4` regression on the Windows CONOUT$ + write path, not a flake. ## Open Questions From 8a0a32478f505f4384dcaba2de4ef392b6a20253 Mon Sep 17 00:00:00 2001 From: Haoxiang Fei Date: Mon, 15 Jun 2026 11:57:16 +0800 Subject: [PATCH 3/3] fix(tty): open console devices directly to survive async 0.19.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit async 0.19.2 changed the Windows file-open worker to detect file kind via GetFileInformationByHandle (for the new FileIdentity / fs.watch feature) instead of GetFileType. That disk-file API fails on console (character) devices, so @async/fs.open("CONIN$"/"CONOUT$") now errors and the whole Windows Tty::open path broke under 0.19.4 (the tests/open pty test failed on windows-latest). Open CONIN$/CONOUT$ directly with CreateFileW (synchronous, non-overlapped) and wrap them in @async/raw_fd.RawFdStream, which classifies them by GetFileType (CharDevice) and never registers overlapped IO — exactly the working 0.19.1 console read/write path. Unify both platforms on RawFdStream: drop the hand-rolled ControllingTerminal struct and its six manual trait impls, and move the Fd/Reader/Writer impls for RawFdStream into the shared io.mbt/isatty.mbt next to the File/Pipe/stdio impls. The unix path is exercised by the pty-backed Tty::open test. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plan.md | 2 +- docs/plans/2026-06-15-async-0.19.4-upgrade.md | 127 +++++++++++------- io.mbt | 14 ++ isatty.mbt | 7 + pkg.generated.mbti | 4 + tty_open.c | 40 ++++++ tty_unix.mbt | 95 +------------ tty_win32.mbt | 48 +++++++ 8 files changed, 200 insertions(+), 137 deletions(-) create mode 100644 tty_win32.mbt diff --git a/docs/plan.md b/docs/plan.md index 0c7c8a6..04722bf 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -59,7 +59,7 @@ This board tracks implementation direction for `moonbit-community/tty`. Use | TTY-22 | done | Windows input record enum | `docs/plans/2026-06-09-windows-input-record-enum.md`, root package | Windows `INPUT_RECORD` bytes parse into private typed variants, including native mouse records | `moon fmt`, targeted Windows wbtests, `moon test .`, `moon test`, `moon check`, `moon info`, `git diff --check` | | MVP-2 | done | queued input and shell commands in agent demo | `docs/plans/2026-05-21-agent-queued-shell.md`, `examples/agent` | `Tab` queues input for delayed submission and `!cmd` runs through `moonbitlang/async/process` without background tasks writing directly to tty | `moon fmt`, `moon check examples/agent`, `moon test examples/agent`, `moon check`, `moon test`, `moon info`, `git diff --check` | | TTY-23 | done | serialize root `Tty` output writes | `docs/plans/2026-06-15-output-write-lock.md`, `internal/lock/`, root package | a single `Tty::write`/`Tty::write_string` call lands atomically under concurrent writers via an internal `Lock`, with no public API change | `moon fmt`, `moon test internal/lock`, `moon test .`, `moon test`, `moon check`, `moon info`, `git diff --check` | -| DEP-1 | blocked | upgrade `moonbitlang/async` 0.19.1 -> 0.19.4 | `docs/plans/2026-06-15-async-0.19.4-upgrade.md`, all workspace `moon.mod` files | every module pins `0.19.4` and CI stays green on all platforms with no `.mbti` change | `moon fmt`, `moon check`, `moon check --target native`, `moon test`, `moon test` (`tests/`), `moon info`, `git diff --check` | +| DEP-1 | in progress | upgrade `moonbitlang/async` 0.19.1 -> 0.19.4, working around the Windows console-open regression by opening `CONIN$`/`CONOUT$` directly and wrapping both platforms' terminal fd in `RawFdStream` | `docs/plans/2026-06-15-async-0.19.4-upgrade.md`, all workspace `moon.mod` files, `tty_open.c`, `tty_win32.mbt`, `tty_unix.mbt`, `io.mbt`, `isatty.mbt` | every module pins `0.19.4`, console devices bypass `@async/fs.open`, and CI stays green on all platforms; `.mbti` gains only the intended `RawFdStream` impls | `moon fmt --check`, `moon check --deny-warn`, `moon test --deny-warn`, `moon test` (`tests/`), `moon info`, `git diff --check` | ## Current Rules diff --git a/docs/plans/2026-06-15-async-0.19.4-upgrade.md b/docs/plans/2026-06-15-async-0.19.4-upgrade.md index c119fe5..c9d6f9a 100644 --- a/docs/plans/2026-06-15-async-0.19.4-upgrade.md +++ b/docs/plans/2026-06-15-async-0.19.4-upgrade.md @@ -3,15 +3,15 @@ ## Goal Move every workspace module from `moonbitlang/async@0.19.1` to -`moonbitlang/async@0.19.4` without changing this package's public API or -behavior, and confirm the upgrade is safe on all CI platforms (notably -windows-latest, where an earlier attempt regressed). +`moonbitlang/async@0.19.4`, working around the Windows console-open regression +that the bump exposes, and confirm the upgrade is safe on all CI platforms +(notably windows-latest). ## Status -Blocked. The isolated bump reproduces the windows-latest regression (see -Validation Results); holding until the async event-loop change is understood or -fixed upstream. +In progress. Root cause of the windows-latest regression is identified and a +workaround is implemented (open the console devices directly instead of through +`@async/fs.open`). Pending windows-latest CI confirmation. ## Context And Decisions @@ -19,54 +19,87 @@ fixed upstream. `docs/plans/2026-06-15-output-write-lock.md`) only needs `Semaphore`, which exists in `0.19.1`, so it intentionally stayed on `0.19.1`. This task does the dependency bump on its own so the two concerns stay reviewable in isolation. -- Stacked on `feat/output-write-lock`; base this PR on that branch so the diff is - only the version bump plus this plan. -- A prior combined attempt on `feat/output-write-lock` saw windows-latest time - out in the `tests/open` `Tty::open` pty test on `0.19.4`, which passed again - after reverting to `0.19.1`. The intervening async diff is mostly mechanical - (`async fn` trait-method syntax) plus a new directory-watch subsystem and a - changed `IoHandle::detach_from_event_loop` signature; none of it obviously - breaks a plain CONOUT$ write, so the timeout may have been a flake. CI on this - isolated bump is the deciding signal. - Bump all six manifests that pin async so the workspace resolves to a single version and declared deps match what is actually used: root, `tests`, `tests/isatty`, `tests/resize`, `tests/open`, `examples`. +### Root cause of the windows-latest regression + +- `Tty::open` on Windows opened `CONIN$` / `CONOUT$` through + `@async/fs.open(..., mode=ReadWrite)`. +- In async `0.19.2` (carried into `0.19.4`), the Windows file-open worker changed + how it detects the file kind. `0.19.1` used `GetFileType()` + (`moonbitlang_async_kind_of_fd`), which classifies a console as `CharDevice` + and always succeeds. `0.19.2+` instead calls `GetFileInformationByHandle()` + (then `kind_from_attr`) to also obtain `dev_id` / `file_id` for the new + `FileIdentity` / `fs.watch` feature. +- `GetFileInformationByHandle` is a disk-file API; it fails on console + (character) devices. On failure the open worker sets `job->err` and closes the + handle, so `@async/fs.open("CONIN$"/"CONOUT$")` now fails. That broke the + entire Windows `Tty::open` path and showed up as the `tests/open` pty test + failing on windows-latest. +- The regression is upstream and was introduced in `0.19.2`, not `0.19.4`. + +### Workaround + +- `CONIN$` / `CONOUT$` are console devices, not filesystem files, so they should + not go through `@async/fs.open`. Open them directly with `CreateFileW` + (synchronous, non-overlapped) in `tty_open.c`, then wrap each handle with + `@async/raw_fd.RawFdStream`. `RawFdStream` detects the kind with `GetFileType` + (`CharDevice`) and never registers the handle for overlapped IO + (`is_async=false`), which exactly reproduces the working `0.19.1` console + read/write path. +- Unify the controlling-terminal handling: the unix path previously hand-rolled + a `ControllingTerminal` struct around `@async/raw_fd.RawFd` with six manual + trait impls. `RawFdStream` provides the same buffered `@io.Reader`/`@io.Writer` + behavior, so `ControllingTerminal` is removed and both platforms now wrap their + terminal fd in `RawFdStream`. The unix change is exercised by the pty-backed + `Tty::open` test on macOS/Linux. + ## Target Files -- `moon.mod` -- `tests/moon.mod` -- `tests/isatty/moon.mod` -- `tests/resize/moon.mod` -- `tests/open/moon.mod` -- `examples/moon.mod` +- `moon.mod`, `tests/moon.mod`, `tests/isatty/moon.mod`, + `tests/resize/moon.mod`, `tests/open/moon.mod`, `examples/moon.mod` +- `tty_open.c` (Windows `CONIN$` / `CONOUT$` open + handle-valid helper) +- `tty_win32.mbt` (new: Windows `Tty::open` via `RawFdStream`) +- `tty_unix.mbt` (drop `ControllingTerminal`; open `/dev/tty` and wrap in + `RawFdStream`) +- `io.mbt` (`Reader` / `Writer` impls for `RawFdStream`) +- `isatty.mbt` (`Fd` impl for `RawFdStream`) - `docs/plan.md` +- generated `.mbti` files from `moon info` ## Public API Changes -None. No `.mbti` change expected (dependency version only). +- New public impls on all platforms: `Fd`, `Reader`, and `Writer` for + `@async/raw_fd.RawFdStream`. This lets raw fd streams be used with `Tty::new` + the same way `@async/fs.File`, pipes, and stdio already can. +- `Tty` public shape, `Tty::open`, `Tty::write`, and `Tty::write_string` + signatures are unchanged. ## Invariants -- No source change; only dependency version strings move. -- `Tty` public API and write behavior are unchanged. +- `Tty::open` produces a coordinated handle whose output write path is the + non-overlapped worker path on Windows (`CharDevice`, `is_async=false`), + identical to the `0.19.1` behavior. +- Console devices are never opened through `@async/fs.open`. - Workspace resolves to exactly one `moonbitlang/async` version (`0.19.4`). +- Unix and Windows share one terminal-stream wrapper (`RawFdStream`). ## Acceptance Criteria - All six manifests pin `0.19.4`. -- `moon check`, `moon check --target native`, and the full test suite pass - locally. +- `moon check`, `moon check --deny-warn`, and the full test suite pass locally, + including the pty-backed `Tty::open` test. - CI passes on ubuntu-latest, macos-latest, and windows-latest (the windows pty `Tty::open` test in particular). -- Generated `.mbti` diffs are empty. +- Generated `.mbti` reflects only the intended `RawFdStream` impl additions. ## Validation Commands -- `moon fmt` -- `moon check` -- `moon check --target native` -- `moon test` (root module) +- `moon fmt --check` +- `moon check --deny-warn` +- `moon test --deny-warn` (root module) - `moon test` in `tests/` (pty-backed module) - `moon info` - review generated `.mbti` diff @@ -74,27 +107,29 @@ None. No `.mbti` change expected (dependency version only). ## Public API Audit -- No public API change. `.mbti` files unchanged after `moon info`. +- Added `pub impl Fd`, `pub impl Reader`, and `pub impl Writer` for + `@async/raw_fd.RawFdStream`, plus the `moonbitlang/async/raw_fd` import in the + generated `.mbti`. These are intentional, consistent with the existing + `File`/`Pipe`/`stdio` impls, and let callers wrap raw fd streams. - No new public type, mutable field, parser state, input event, platform handle, backend selection, or capability query. -- The only dependency change is the async version (`0.19.1` -> `0.19.4`). +- The dependency change is the async version (`0.19.1` -> `0.19.4`). ## Validation Results -- `moon check` passed. -- `moon check --target native` passed. -- `moon test` (root) passed: 165 tests. -- `moon test` (`tests/`) passed: 133 tests. -- `moon info` left `.mbti` files unchanged. +- `moon fmt --check` passed. +- `moon check --deny-warn` passed. +- `moon test --deny-warn` (root) passed: 165 tests. +- `moon test` (`tests/`) passed: 133 tests, including the pty-backed `Tty::open` + test that drives `Tty::open` -> `RawFdStream` -> a real terminal write. +- `moon info` produced only the intended `RawFdStream` impl additions. - `git diff --check` passed. -- CI result: ubuntu-latest and macos-latest passed; **windows-latest failed** - reproducibly on the isolated bump with the same failure as the earlier - combined attempt: `tests/open` `Tty::open` timed out (`TimeoutError`, - 176/177). This confirms a real `0.19.4` regression on the Windows CONOUT$ - write path, not a flake. +- windows-latest CI: pending. ## Open Questions -- If windows-latest regresses again on `0.19.4`, investigate the - `detach_from_event_loop` / event-loop changes against the CONOUT$ write+close - path before landing, or hold the upgrade. +- Report the upstream `GetFileInformationByHandle`-on-console regression to + `moonbitlang/async` (the open worker should fall back to `GetFileType` / + `kind_of_fd` when `GetFileInformationByHandle` fails, instead of erroring). +- The Windows-only `Tty::open` path cannot be compiled or run locally on macOS; + it relies on windows-latest CI for verification. diff --git a/io.mbt b/io.mbt index 7eeee79..d9c95bb 100644 --- a/io.mbt +++ b/io.mbt @@ -51,3 +51,17 @@ pub impl Reader for @async/stdio.Input with fn close(_) { pub impl Writer for @async/stdio.Output with fn close(_) { () } + +///| +/// Allow raw file-descriptor streams to serve as terminal input handles. +/// Used for the controlling terminal (`/dev/tty`) and Windows console devices. +pub impl Reader for @async/raw_fd.RawFdStream with fn close(self) { + @async/raw_fd.RawFdStream::close(self) +} + +///| +/// Allow raw file-descriptor streams to serve as terminal output handles. +/// Used for the controlling terminal (`/dev/tty`) and Windows console devices. +pub impl Writer for @async/raw_fd.RawFdStream with fn close(self) { + @async/raw_fd.RawFdStream::close(self) +} diff --git a/isatty.mbt b/isatty.mbt index 39da3b1..aea2bba 100644 --- a/isatty.mbt +++ b/isatty.mbt @@ -54,3 +54,10 @@ pub impl Fd for @async/stdio.Input with fn fd(self) { pub impl Fd for @async/stdio.Output with fn fd(self) { @async/stdio.Output::fd(self) } + +///| +/// Allow raw file-descriptor streams (used for the controlling terminal and +/// Windows console devices) to be used with file-descriptor based terminal APIs. +pub impl Fd for @async/raw_fd.RawFdStream with fn fd(self) { + self.fd +} diff --git a/pkg.generated.mbti b/pkg.generated.mbti index ef16d63..2ff1c11 100644 --- a/pkg.generated.mbti +++ b/pkg.generated.mbti @@ -8,6 +8,7 @@ import { "moonbitlang/async/io", "moonbitlang/async/os_error", "moonbitlang/async/pipe", + "moonbitlang/async/raw_fd", "moonbitlang/async/stdio", } @@ -128,6 +129,7 @@ pub(open) trait Fd { pub impl Fd for @fs.File pub impl Fd for @pipe.PipeRead pub impl Fd for @pipe.PipeWrite +pub impl Fd for @raw_fd.RawFdStream pub impl Fd for @stdio.Input pub impl Fd for @stdio.Output @@ -136,6 +138,7 @@ pub(open) trait Reader : @io.Reader + Fd { } pub impl Reader for @fs.File pub impl Reader for @pipe.PipeRead +pub impl Reader for @raw_fd.RawFdStream pub impl Reader for @stdio.Input pub(open) trait Writer : @io.Writer + Fd { @@ -143,5 +146,6 @@ pub(open) trait Writer : @io.Writer + Fd { } pub impl Writer for @fs.File pub impl Writer for @pipe.PipeWrite +pub impl Writer for @raw_fd.RawFdStream pub impl Writer for @stdio.Output diff --git a/tty_open.c b/tty_open.c index f93b3ff..4510a09 100644 --- a/tty_open.c +++ b/tty_open.c @@ -19,3 +19,43 @@ moonbit_tty_open_controlling_terminal(void) { #endif } #endif + +#ifdef _WIN32 +#include + +// `CONIN$` / `CONOUT$` are console (character) devices, not filesystem files. +// They are opened directly here as synchronous (non-overlapped) handles so they +// can be wrapped with `@async/raw_fd.RawFdStream`, which classifies them by +// `GetFileType` (CharDevice). Going through `@async/fs.open` instead would probe +// the handle with `GetFileInformationByHandle`, which fails on console devices. +static HANDLE +moonbit_tty_open_console(const wchar_t *name) { + return CreateFileW( + name, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, + OPEN_EXISTING, + 0, // no FILE_FLAG_OVERLAPPED: RawFdStream requires a non-overlapped handle + NULL + ); +} + +MOONBIT_FFI_EXPORT +HANDLE +moonbit_tty_open_console_input(void) { + return moonbit_tty_open_console(L"CONIN$"); +} + +MOONBIT_FFI_EXPORT +HANDLE +moonbit_tty_open_console_output(void) { + return moonbit_tty_open_console(L"CONOUT$"); +} + +MOONBIT_FFI_EXPORT +int32_t +moonbit_tty_handle_is_valid(HANDLE fd) { + return (fd != INVALID_HANDLE_VALUE && fd != NULL) ? 1 : 0; +} +#endif diff --git a/tty_unix.mbt b/tty_unix.mbt index 1690b18..25353dd 100644 --- a/tty_unix.mbt +++ b/tty_unix.mbt @@ -3,100 +3,15 @@ extern "C" fn open_controlling_terminal() -> @async/types.Fd = "moonbit_tty_open_controlling_terminal" ///| -#cfg(platform="windows") -fn use_raw_fd_import() -> @async/raw_fd.RawFd { - abort("unreachable") -} - -///| -#cfg(platform="windows") -fn init { - ignore(use_raw_fd_import) -} - -///| -#warnings("-14") +/// Open the controlling terminal (`/dev/tty`) as one coordinated terminal +/// handle. The single bidirectional handle is wrapped as a raw stream and used +/// for both input and output. #cfg(not(platform="windows")) -priv struct ControllingTerminal { - raw : @async/raw_fd.RawFd - read_buf : @async/io.ReaderBuffer -} - -///| -#warnings("-14") -#cfg(not(platform="windows")) -async fn ControllingTerminal::open() -> ControllingTerminal { +pub async fn Tty::open() -> Tty { let fd = open_controlling_terminal() if fd < 0 { @os_error.check_errno("Tty::open") } - { raw: RawFd(fd), read_buf: @async/io.ReaderBuffer::new() } -} - -///| -#cfg(not(platform="windows")) -impl @async/io.Reader for ControllingTerminal with fn _direct_read( - self, - buf, - offset~, - max_len~, -) { - self.raw.read(buf, offset~, max_len~) -} - -///| -#cfg(not(platform="windows")) -impl @async/io.Reader for ControllingTerminal with fn _get_internal_buffer(self) { - self.read_buf -} - -///| -#cfg(not(platform="windows")) -impl @async/io.Writer for ControllingTerminal with fn write_once( - self, - buf, - offset~, - len~, -) { - self.raw.write(buf, offset~, len~) -} - -///| -#cfg(not(platform="windows")) -impl Fd for ControllingTerminal with fn fd(self) { - self.raw.fd -} - -///| -#cfg(not(platform="windows")) -impl Reader for ControllingTerminal with fn close(self) { - self.raw.close() -} - -///| -#cfg(not(platform="windows")) -impl Writer for ControllingTerminal with fn close(self) { - self.raw.close() -} - -///| -/// Open the controlling terminal as one coordinated terminal handle. -#cfg(not(platform="windows")) -pub async fn Tty::open() -> Tty { - let terminal = ControllingTerminal::open() + let terminal = @async/raw_fd.RawFdStream(fd) Tty::new(terminal, terminal) } - -///| -/// Open the controlling terminal as one coordinated terminal handle. -#cfg(platform="windows") -pub async fn Tty::open() -> Tty { - let input = @async/fs.open("CONIN$", mode=ReadWrite) - let output = @async/fs.open("CONOUT$", mode=ReadWrite) catch { - error => { - input.close() - raise error - } - } - Tty::new(input, output) -} diff --git a/tty_win32.mbt b/tty_win32.mbt new file mode 100644 index 0000000..19314f8 --- /dev/null +++ b/tty_win32.mbt @@ -0,0 +1,48 @@ +///| +/// Open the Windows console input device (`CONIN$`) as a synchronous, +/// non-overlapped handle. Returns an invalid handle on failure, with the +/// failure reason available through `GetLastError` (read by `@os_error`). +#cfg(platform="windows") +extern "C" fn open_console_input() -> @async/types.Fd = "moonbit_tty_open_console_input" + +///| +/// Open the Windows console output device (`CONOUT$`) as a synchronous, +/// non-overlapped handle. Returns an invalid handle on failure, with the +/// failure reason available through `GetLastError` (read by `@os_error`). +#cfg(platform="windows") +extern "C" fn open_console_output() -> @async/types.Fd = "moonbit_tty_open_console_output" + +///| +#cfg(platform="windows") +extern "C" fn console_handle_is_valid(fd : @async/types.Fd) -> Int = "moonbit_tty_handle_is_valid" + +///| +/// Open the controlling terminal as one coordinated terminal handle. +/// +/// `CONIN$` / `CONOUT$` are console (character) devices, not filesystem files, +/// so they are opened directly and wrapped as raw, non-overlapped handles via +/// `@async/raw_fd.RawFdStream`. `RawFdStream` detects the handle kind with +/// `GetFileType` (`CharDevice`) and never registers it for overlapped IO, which +/// is exactly what console devices need. Routing through `@async/fs.open` +/// instead would probe the handle with `GetFileInformationByHandle`, which fails +/// on console devices. +#cfg(platform="windows") +pub async fn Tty::open() -> Tty { + let input_fd = open_console_input() + if console_handle_is_valid(input_fd) == 0 { + @os_error.check_errno("Tty::open") + } + let input = @async/raw_fd.RawFdStream(input_fd) + let output_fd = open_console_output() + if console_handle_is_valid(output_fd) == 0 { + input.close() + @os_error.check_errno("Tty::open") + } + let output = @async/raw_fd.RawFdStream(output_fd) catch { + error => { + input.close() + raise error + } + } + Tty::new(input, output) +}