Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ jobs:
- hash-sha256
- secure-random
- text-similarity
- terminal-style
- terminal-input
- event-stream
- http-server
- http-client
Expand Down
5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ default = []
# Bounded OS entropy, independent of crash/profiling facilities.
secure-random = ["dep:getrandom"]
text-similarity = ["dep:strsim"]
terminal-style = []
# Native terminal capture and key decoding without PTY process spawning.
terminal-input = []
# Advisory locking and modification-time setting are implemented natively
# per host (see `src/platform_linux/fs.rs`, `src/platform_macos/fs.rs`,
# `src/platform_win/fs.rs`) rather than through a wrapper crate, matching the
Expand Down Expand Up @@ -70,7 +73,7 @@ daemon-registration = ["running-process/daemon-registration"]
# separate from v1 records and pulls no broker client, identity, IPC, or
# runtime policy into applications that dual-write during the v1-to-v2 rollout.
daemon-registration-v2 = ["running-process/daemon-registration-v2"]
pty = ["dep:portable-pty"]
pty = ["dep:portable-pty", "terminal-input"]
event-stream = ["dep:futures-core", "dep:tokio-stream", "tokio-stream/sync"]
http-server = ["dep:hyper", "hyper/server", "hyper/http1", "dep:hyper-util", "dep:http-body-util", "dep:bytes", "dep:futures-core"]
# Window-icon and stock-icon mechanics for the host console or a child. This
Expand Down
3 changes: 3 additions & 0 deletions ci/check_compilation_boundary_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import sys

CASES = (
("pty", "portable-pty"),
("text-similarity", "strsim"),
("wasm-sketch-host", "wasmtime"),
("ipc", "interprocess"),
Expand Down Expand Up @@ -74,6 +75,8 @@ def main() -> int:
unexpected = sorted(graph & SKETCH_AND_WEBVIEW_PACKAGES)
if unexpected:
failures.append(f"{label} graph unexpectedly contains {', '.join(unexpected)}")
if "portable-pty" in tree("terminal-input"):
failures.append("terminal-input unexpectedly enables PTY process spawning")
enabled_graphs: dict[str, set[str]] = {}
# getrandom already occurs transitively in the host substrate. Prove this
# capability activates the backend without importing unrelated facilities;
Expand Down
29 changes: 29 additions & 0 deletions docs/interrupt-notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Ctrl+C notifications

`async_engine::Runtime::interrupt_signal` eagerly registers an owned listener
for Unix SIGINT or Windows console CTRL_C. The listener borrows the runtime;
use a runtime built with `enable_all`, and drive it while waiting. Requesting
this facility without enabled drivers returns `Unsupported`, without installing
a handler. Native registration failures propagate as I/O errors.

`InterruptSignal::wait` waits for one notification and reports a closed receiver
as `BrokenPipe`. Dropping a pending wait does not consume a later notification.
Signals can coalesce; delivery is not counting, and each registered listener is
notified. There is no event backlog proportional to the number of interrupts.
No new runtime, polling thread or dependency is introduced.

`Runtime::wait_for_interrupt` is a convenience future that registers when first
polled. Applications that change terminal modes before polling must use eager
registration instead. A wait intentionally has no timeout; compose it with a
deadline or cancellation where required by application policy.

Registration affects process-wide signal handling. Dropping the listener does
not restore native default handlers. This facility does not handle SIGTERM,
Windows CTRL_BREAK, console close, shutdown or logoff, and does not replace the
existing daemon shutdown-request contract. Do not combine competing signal
installers without explicitly coordinating ownership. Applications decide exit
codes, repeated-interrupt policy and cleanup. Abrupt termination cannot run Drop.

Issue #187 is coordinated with zackees/fastled-wasm#242. Native delivery tests
run only in isolated child processes, with a fresh console on Windows and a
parent-enforced deadline. Publication and exact client adoption remain required.
93 changes: 93 additions & 0 deletions docs/terminal-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Native terminal input groundwork

The dependency-free `terminal-input` feature exposes key decoding and
`TerminalInputSession`, an owned raw-terminal capture session. The `pty` feature
includes it and additionally enables PTY process spawning through a private
backend. `new` snapshots the input mode and Drop attempts to restore it. Unix
returns `Ok(None)` for non-terminal stdin; Windows currently reports an error
when stdin is not an attached console. Callers must not assume parity yet.

Capture and active terminal-graphics probes share exclusive admission within
one kernel instance. A second capture returns `WouldBlock`; a probe declines
while capture owns input. This cannot coordinate other libraries, linked kernel
copies, or processes reading the same terminal. Restore failures during Drop
are best-effort, not a guarantee that the terminal mode was restored.

Windows capture holds at most 256 translated events and 64 KiB of queued input.
Key releases are ignored; a key press with more than 1024 repetitions stops
capture before repeated text is allocated. Queue overflow and native read/wait
failure stop capture and are reported by the fallible wait APIs, including
`TerminalInputSession::read_chunk`. Previously delivered events cannot be
recalled. Optional trace I/O is outside the queue mutex, but may still delay
the worker and its shutdown; this API does not promise a shutdown deadline.

`TerminalInputState` no longer exposes its Windows queue for external mutation.
The new capture-failure enum variants require downstream exhaustive matches to
be updated. Legacy `next_event` and `drain_events` do not report failures; new
consumers should use fallible waits instead.

These are raw chunks, not decoded key events. In particular, scanning a chunk
for spaces or newlines is not safe key matching: terminal escape sequences and
pasted input can contain those bytes. Use the decoded API below for key policy.

## Decoded keys

`keys::TerminalKeys` reuses native capture in noncanonical, no-echo mode and returns one
facade-owned `KeyEvent` per `poll`. Calls accept a requested wait of at most
100 ms and examine at most 64 KiB of bytes. Remaining queued bytes survive
subsequent calls, including zero-wait calls. Drop uses the native session's
best-effort restoration. Existing signal-key behavior is preserved (Unix ISIG
and Windows processed input), as are Unix input translations and output flags.
With normal initial settings Ctrl+C therefore remains a native signal, not a
decoded event. Applications must register graceful signal handling before
opening capture so interruption can drop the owner and restore modes; abrupt
process termination cannot run Rust Drop. Opening still has the platform-specific
non-terminal behavior above. The raw `TerminalInputSession::new` API used for
PTY forwarding is unchanged.

`keys::KeyDecoder` is the same incremental decoder without native ownership;
callers feeding bytes directly own its timing and use `finish_pending` after
their inter-byte deadline. TerminalKeys checks incomplete-sequence age on polls
when no previously buffered bytes remain: after 250 ms a lone Escape becomes an
Escape event and other partial sequences fail. Poll timing is a requested OS
wait plus bounded parsing, not a hard real-time guarantee.

The small contract recognizes UTF-8 characters, Enter, Escape and legacy
control characters and unambiguous Alt characters. Alt+Space and reserved Alt
introducers overlap terminal control-sequence encodings; they are conservatively
parsed as control sequences, not text keys. An incomplete sequence then fails
closed. Consumers must not assume full legacy-modifier equivalence with Crossterm.
Shift and key-release information cannot be recovered
from legacy terminal bytes. Windows capture ignores releases and expands
bounded repeats. CR/LF mean Enter; their legacy control-letter aliases are not
distinguished. CSI/SS3 and intermediate escape sequences produce `Other`, as do
complete bracketed pastes and control strings. Plain unbracketed paste cannot
be distinguished from typed text. Unsupported X10 mouse payloads fail closed.
This is not a general terminal emulator or a Crossterm event-type wrapper.

Decoder buffering is at most 128 bytes. A control string or bracketed paste
may consume at most 64 KiB including its introducer and terminator, without
buffering the body. Invalid UTF-8, malformed or oversized sequences poison the
decoder: no trailing spaces are reinterpreted as keys after an error.

## Diagnostic formatting

The independent `terminal-style` feature adds no dependency. It provides a
borrowed `StyledText` formatter with a facade-owned 16-color foreground palette.
The caller chooses the color and whether it is enabled. The formatter neither
reads environment variables nor detects terminals. Disabled output preserves
plain text; enabled output wraps it in ANSI foreground selection and default
foreground reset. It preserves embedded escapes and is not a sanitizer. Writer
errors propagate; a reset cannot be guaranteed after an output failure.

`prepare_stderr_ansi` enables Windows console ANSI processing while preserving
other mode flags. It returns false for redirected/non-console stderr or consoles
that reject VT support, and errors for other native failures. Preparation is
idempotent but persistent: writers sharing the console buffer can observe the
mode change. On Unix it needs no native action and returns true, which is not
a TTY or terminal-capability assertion. Applications own `NO_COLOR`, `TERM`,
redirection, fallback and diagnostic-message policy.

Issue #178 still requires decoded-key/style native validation, FastLED policy
adoption, release and exact published-version consumption. This does not yet
complete the Crossterm migration.
78 changes: 77 additions & 1 deletion src/async_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,40 @@ impl std::error::Error for TaskError {}
/// Owned asynchronous runtime.
pub struct Runtime {
inner: tokio::runtime::Runtime,
drivers_enabled: bool,
}

impl Runtime {
/// Register for native Ctrl+C notifications before returning.
///
/// Requires a runtime built with `enable_all`; otherwise returns
/// `Unsupported` without installing a signal handler. Registration failures
/// propagate. The listener borrows this runtime and uses its existing driver.
///
/// Registration changes process-wide handling: dropping listeners does not
/// restore the default handler. Applications must own graceful exit policy.
pub fn interrupt_signal(&self) -> std::io::Result<InterruptSignal<'_>> {
if !self.drivers_enabled {
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"interrupt notifications require enabled runtime drivers",
));
}
let _entered = self.inner.enter();
Ok(InterruptSignal {
inner: crate::platform_imp::interrupt::InterruptReceiver::new()?,
_runtime: self,
})
}

/// Register and wait for one Ctrl+C notification when first polled.
///
/// For registration before polling, use [`Self::interrupt_signal`] instead.
/// The same driver requirements and process-wide handler effects apply.
pub async fn wait_for_interrupt(&self) -> std::io::Result<()> {
self.interrupt_signal()?.wait().await
}

/// Run one future to completion on this runtime.
pub fn run<F: Future>(&self, future: F) -> F::Output {
self.inner.block_on(future)
Expand All @@ -172,26 +203,30 @@ impl Runtime {
/// Builder for an owned asynchronous runtime.
pub struct RuntimeBuilder {
inner: tokio::runtime::Builder,
drivers_enabled: bool,
}

impl RuntimeBuilder {
/// Build a runtime that executes tasks on the calling thread.
pub fn current_thread() -> Self {
Self {
inner: tokio::runtime::Builder::new_current_thread(),
drivers_enabled: false,
}
}

/// Build a runtime backed by a worker pool.
pub fn multi_thread() -> Self {
Self {
inner: tokio::runtime::Builder::new_multi_thread(),
drivers_enabled: false,
}
}

/// Enable the engine's I/O, time, and signal drivers.
pub fn enable_all(mut self) -> Self {
self.inner.enable_all();
self.drivers_enabled = true;
self
}

Expand All @@ -209,7 +244,48 @@ impl RuntimeBuilder {

/// Create the configured runtime.
pub fn build(mut self) -> std::io::Result<Runtime> {
self.inner.build().map(|inner| Runtime { inner })
self.inner.build().map(|inner| Runtime {
inner,
drivers_enabled: self.drivers_enabled,
})
}
}

/// Owned subscription to native Ctrl+C notifications.
///
/// Created by [`Runtime::interrupt_signal`], not an ambient or second runtime.
/// Unix observes SIGINT; Windows observes console CTRL_C, not CTRL_BREAK,
/// close, logoff or shutdown. Notifications may coalesce and every registered
/// listener is notified; this is not an exact interrupt counter.
///
/// The borrowed runtime must be driven to deliver notifications. No timeout is
/// imposed on an intentional wait for user input; callers may use [`timeout`]
/// or cancel the wait. Dropping this subscription does not restore process-wide
/// signal handlers. It does not cancel work or select an exit code.
///
/// A listener cannot outlive the runtime that drives it:
///
/// ```compile_fail
/// let runtime = kernal_api::async_engine::RuntimeBuilder::current_thread()
/// .enable_all().build().unwrap();
/// let listener = runtime.interrupt_signal().unwrap();
/// drop(runtime);
/// drop(listener);
/// ```
pub struct InterruptSignal<'runtime> {
inner: crate::platform_imp::interrupt::InterruptReceiver,
_runtime: &'runtime Runtime,
}

impl InterruptSignal<'_> {
/// Wait for one notification, or report a closed native receiver.
///
/// Cancellation-safe: dropping a pending wait does not consume a later
/// notification. A subsequent wait can observe it. Signals may coalesce.
pub async fn wait(&mut self) -> std::io::Result<()> {
self.inner.recv().await.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::BrokenPipe, "interrupt receiver closed")
})
}
}

Expand Down
Loading
Loading