Summary
Host calls are today the most painful consumer of the PSRP value layer: a flat dispatch over 56 numbered methods (RemoteHostMethodId, see reference engine/remoting/host/RemoteHostMethodInfo.cs:15-90) with positional CLIXML params, implicit void-vs-response semantics, and bespoke ~1,700-line handler files per frontend (ironposh-client-tokio/src/hostcall.rs, ironposh-web/src/hostcall.rs). This RFC proposes restructuring our side into capability traits + a default answer policy + typed exactly-once responders, with the wire dispatch generated from a declarative method table.
Companion to #12 (typed PSRP value layer) — the param/return types and table codegen here are Layer 3/4/5 artifacts of that design.
Background: what PSHost / UI / RawUI actually are
The protocol RPCs PowerShell's v1 local hosting class hierarchy backwards over the wire. The three levels are a capability ladder, each optional:
PSHost — "the application hosting the engine." Lifecycle, not I/O: SetShouldExit, nested prompts, NotifyBegin/EndApplication. A windowless service implements only this.
PSHostUserInterface (UI) — "a conversation with the human." Content-level, device-agnostic: WriteLine (colors as intent), ReadLine, Prompt (field forms), PromptForCredential/Choice, WriteProgress. Meaningful with no terminal at all (a GUI host renders dialogs). What Write-Host/Read-Host/Get-Credential call.
PSHostRawUserInterface (RawUI) — "the terminal as a device." A character-cell framebuffer: cursor/buffer/window geometry, console attributes, ReadKey, scrolling, Get/SetBufferContents (screen scraping), window title. Needed by Clear-Host, PSReadLine, progress rendering.
Semantically: app ↔ human ↔ device. Defensible as a local class hierarchy; painful because MS-PSRP v2 flattened all three into one numbered method table with positional PsValue params, implicit response requirements (forgetting to answer ReadLine hangs the remote pipeline), and a dual source of truth (HostDefaultData snapshot in HostInfo vs live RPCs).
Proposed layer assignment (all 56 methods)
| Wire methods (ids) |
Layer |
Behavior |
GetName/Version/InstanceId/CurrentCulture/CurrentUICulture (1-5) |
Policy |
Static session identity from config; frontend never sees them |
SetShouldExit (6) |
Session core |
Becomes a session lifecycle event (REPL exit w/ code) |
Enter/ExitNestedPrompt (7-8) |
Session core |
Decline with typed not-supported error (protocol-legal; server unwinds) |
NotifyBegin/EndApplication (9-10) |
Session core |
Void no-op acks |
Write1/2, WriteLine1/2/3, WriteError/Debug/Verbose/WarningLine, WriteProgress (13-22) |
HostOutput trait |
Fire-and-forget; no responder |
ReadLine(AsSecureString), Prompt, PromptForCredential1/2, PromptForChoice (11-12, 23-26), PromptForChoiceMultipleSelection (56) |
HostPrompts trait |
Response-mandatory with concrete return types (String, SecureString, field map, PSCredential, i32, Vec<i32>) |
RawUI getters: colors, cursor/window pos, cursor size, buffer/window size, title, max sizes, GetKeyAvailable, GetBufferContents (27,29,31,33,35,37,39,41,43,44,45,50) |
Policy, overridable by HostScreen |
Answered from HostDefaultData snapshot + fallbacks (KeyAvailable→false, GetBufferContents→empty); live HostScreen values win when implemented |
RawUI setters/actions: set colors/cursor/window/buffer/title, FlushInputBuffer, SetBufferContents1/2, ScrollBufferContents (28,30,32,34,36,38,40,42,47,48,49,51) |
HostScreen trait |
Void; maps ~1:1 onto xterm.js / crossterm; unimplemented → Policy acks + logs |
ReadKey (46) |
HostScreen |
The one RawUI response-mandatory call (KeyInfo) |
PushRunspace/PopRunspace/GetIsRunspacePushed/GetRunspace (52-55) |
Session core |
Interactive-session nesting; answer false/not-supported |
Net: a frontend implements 9 output + 7 prompt (+ optionally ~14 screen) methods; 26 of 56 never reach it. A headless -c runner implements HostOutput only.
Typed + mandatory responses (no hand-written switch)
Trait implementors: the signature is the mandatory callback.
trait HostPrompts {
async fn read_line(&mut self) -> Result<String, HostDecline>;
async fn credential(&mut self, req: CredentialRequest) -> Result<PSCredential, HostDecline>;
// ...
}
Cannot forget to reply (doesn't compile), cannot reply with the wrong type, HostDecline is the typed "can't do this" path so the server unwinds instead of hanging. The router awaits the future and writes the PSRP response.
Event-loop UIs that can't await inline (web, the tokio terminal thread): a one-shot token at the routing boundary —
#[must_use = "dropping a Responder auto-replies the policy default and logs an error"]
pub struct Responder<T: HostReturn> { /* call id + oneshot */ }
impl<T: HostReturn> Responder<T> {
pub fn respond(self, value: T); // consumes self → no double-reply
pub fn decline(self, why: HostDecline);
}
// Drop (not consumed) → reply T::policy_default() + error!(method, "responder dropped")
Exactly-once by construction: consume-on-send kills double-reply, Drop kills never-reply (a panicked UI path degrades to a policy default instead of a hung remote pipeline), T kills wrong-shape. Strictly stronger than today's per-arm tribal knowledge of which methods need responses.
The wire dispatch becomes a declarative table (one decode point over 56 ids must exist, but generated, not hand-written):
host_methods! {
11 ReadLine () -> String => Prompts::read_line,
20 WriteProgress (record: ProgressRecord) -> () => Output::progress,
31 GetCursorPosition () -> Coordinates => Screen?::cursor | Policy,
46 ReadKey (options: ReadKeyOptions) -> KeyInfo => Screen::read_key,
// ... 56 rows
}
The macro generates: positional-param parsing into typed structs (the existing host/methods.rs types — Coordinates, KeyInfo, ProgressRecord, FieldDescription, … — become the #12 Layer-3 derive types), return serialization, the void/response distinction, and routing glue including the Policy fallback chain. Adding a method = one table row. The 56-arm match still exists — as generated code nobody reads, like a serde impl.
Expected impact
ironposh-client-tokio/src/hostcall.rs (~1,700 LOC) and ironposh-web/src/hostcall.rs collapse to trait impls expressing only that frontend's deltas; the shared Policy + router live once in client-core.
host/params.rs + returns.rs (~800 LOC, 84 raw PsValue manipulations) replaced by table-generated conversions.
- Hung-pipeline bugs from missed responses become unrepresentable.
- New frontends (e.g. a future GUI credential prompt) implement one small trait instead of re-deciding 56 cases.
Sequencing
Depends on #12 Layers 1+3 (accessors + derive) for the param/return conversions. Suggested order: land #12 phases 1-3 → method table + Policy + router in client-core (behind the existing dispatch, migrated method-group by method-group: Output first, Prompts, then Screen) → port tokio frontend → port web frontend → delete the bespoke handlers. Existing PTY e2e matrix (23 suites incl. hostcall matrix) is the regression net.
References
Summary
Host calls are today the most painful consumer of the PSRP value layer: a flat dispatch over 56 numbered methods (
RemoteHostMethodId, see referenceengine/remoting/host/RemoteHostMethodInfo.cs:15-90) with positional CLIXML params, implicit void-vs-response semantics, and bespoke ~1,700-line handler files per frontend (ironposh-client-tokio/src/hostcall.rs,ironposh-web/src/hostcall.rs). This RFC proposes restructuring our side into capability traits + a default answer policy + typed exactly-once responders, with the wire dispatch generated from a declarative method table.Companion to #12 (typed PSRP value layer) — the param/return types and table codegen here are Layer 3/4/5 artifacts of that design.
Background: what PSHost / UI / RawUI actually are
The protocol RPCs PowerShell's v1 local hosting class hierarchy backwards over the wire. The three levels are a capability ladder, each optional:
PSHost— "the application hosting the engine." Lifecycle, not I/O:SetShouldExit, nested prompts,NotifyBegin/EndApplication. A windowless service implements only this.PSHostUserInterface(UI) — "a conversation with the human." Content-level, device-agnostic:WriteLine(colors as intent),ReadLine,Prompt(field forms),PromptForCredential/Choice,WriteProgress. Meaningful with no terminal at all (a GUI host renders dialogs). WhatWrite-Host/Read-Host/Get-Credentialcall.PSHostRawUserInterface(RawUI) — "the terminal as a device." A character-cell framebuffer: cursor/buffer/window geometry, console attributes,ReadKey, scrolling,Get/SetBufferContents(screen scraping), window title. Needed byClear-Host, PSReadLine, progress rendering.Semantically: app ↔ human ↔ device. Defensible as a local class hierarchy; painful because MS-PSRP v2 flattened all three into one numbered method table with positional
PsValueparams, implicit response requirements (forgetting to answerReadLinehangs the remote pipeline), and a dual source of truth (HostDefaultDatasnapshot inHostInfovs live RPCs).Proposed layer assignment (all 56 methods)
GetName/Version/InstanceId/CurrentCulture/CurrentUICulture(1-5)SetShouldExit(6)Enter/ExitNestedPrompt(7-8)NotifyBegin/EndApplication(9-10)Write1/2,WriteLine1/2/3,WriteError/Debug/Verbose/WarningLine,WriteProgress(13-22)HostOutputtraitReadLine(AsSecureString),Prompt,PromptForCredential1/2,PromptForChoice(11-12, 23-26),PromptForChoiceMultipleSelection(56)HostPromptstraitString,SecureString, field map,PSCredential,i32,Vec<i32>)GetKeyAvailable,GetBufferContents(27,29,31,33,35,37,39,41,43,44,45,50)HostScreenHostDefaultDatasnapshot + fallbacks (KeyAvailable→false,GetBufferContents→empty); liveHostScreenvalues win when implementedFlushInputBuffer,SetBufferContents1/2,ScrollBufferContents(28,30,32,34,36,38,40,42,47,48,49,51)HostScreentraitReadKey(46)HostScreenKeyInfo)PushRunspace/PopRunspace/GetIsRunspacePushed/GetRunspace(52-55)false/not-supportedNet: a frontend implements 9 output + 7 prompt (+ optionally ~14 screen) methods; 26 of 56 never reach it. A headless
-crunner implementsHostOutputonly.Typed + mandatory responses (no hand-written switch)
Trait implementors: the signature is the mandatory callback.
Cannot forget to reply (doesn't compile), cannot reply with the wrong type,
HostDeclineis the typed "can't do this" path so the server unwinds instead of hanging. The router awaits the future and writes the PSRP response.Event-loop UIs that can't await inline (web, the tokio terminal thread): a one-shot token at the routing boundary —
Exactly-once by construction: consume-on-send kills double-reply,
Dropkills never-reply (a panicked UI path degrades to a policy default instead of a hung remote pipeline),Tkills wrong-shape. Strictly stronger than today's per-arm tribal knowledge of which methods need responses.The wire dispatch becomes a declarative table (one decode point over 56 ids must exist, but generated, not hand-written):
The macro generates: positional-param parsing into typed structs (the existing
host/methods.rstypes —Coordinates,KeyInfo,ProgressRecord,FieldDescription, … — become the #12 Layer-3 derive types), return serialization, the void/response distinction, and routing glue including the Policy fallback chain. Adding a method = one table row. The 56-arm match still exists — as generated code nobody reads, like a serde impl.Expected impact
ironposh-client-tokio/src/hostcall.rs(~1,700 LOC) andironposh-web/src/hostcall.rscollapse to trait impls expressing only that frontend's deltas; the shared Policy + router live once in client-core.host/params.rs+returns.rs(~800 LOC, 84 rawPsValuemanipulations) replaced by table-generated conversions.Sequencing
Depends on #12 Layers 1+3 (accessors + derive) for the param/return conversions. Suggested order: land #12 phases 1-3 → method table + Policy + router in client-core (behind the existing dispatch, migrated method-group by method-group: Output first, Prompts, then Screen) → port tokio frontend → port web frontend → delete the bespoke handlers. Existing PTY e2e matrix (23 suites incl. hostcall matrix) is the regression net.
References
engine/remoting/host/RemoteHostMethodInfo.cs:15-90(method ids),engine/hostifaces/(PSHost/UI/RawUI contracts),HostDefaultDatasnapshot inHostInfocrates/ironposh-client-core/src/host/{methods,params,returns,host_call}.rs,crates/ironposh-client-tokio/src/hostcall.rs,crates/ironposh-web/src/hostcall.rs