diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index a5b9de5..27d5976 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -6,6 +6,17 @@ on: - master pull_request: workflow_dispatch: + inputs: + probe_source_gzip_base64: + description: Optional gzip/base64 Rust test source (at most 24000 characters) + type: string + required: false + default: "" + probe_source_sha256: + description: SHA-256 of the decompressed UTF-8 source bytes + type: string + required: false + default: "" permissions: contents: read @@ -41,6 +52,20 @@ jobs: RUST_BACKTRACE: "1" steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Validate and mask optional probe input + if: github.event_name == 'workflow_dispatch' && (inputs.probe_source_gzip_base64 != '' || inputs.probe_source_sha256 != '') + run: | + # Read the event file before putting the payload in a step environment: + # otherwise the runner would log the raw env value before it is masked. + $event = Get-Content -LiteralPath $env:GITHUB_EVENT_PATH -Raw | ConvertFrom-Json + $payload = $event.inputs.probe_source_gzip_base64 + $digest = $event.inputs.probe_source_sha256 + if ([string]::IsNullOrEmpty($payload) -or $payload.Length -gt 24000) { throw 'Probe payload must contain 1..24000 characters' } + if ($payload -cnotmatch '\A[A-Za-z0-9+/]+={0,2}\z' -or $payload.Length % 4 -ne 0) { throw 'Probe payload must be single-line base64' } + if ($digest -cnotmatch '\A[0-9a-fA-F]{64}\z') { throw 'Probe requires a SHA-256 digest' } + Write-Output "::add-mask::$payload" - name: Enable long dependency paths run: git config --global core.longpaths true - uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5 @@ -89,6 +114,7 @@ jobs: # The native Ghostty build is now an output of the workspace crate. cache-workspace-crates: true cache-on-failure: true + save-if: ${{ github.event_name != 'workflow_dispatch' || (inputs.probe_source_gzip_base64 == '' && inputs.probe_source_sha256 == '') }} - name: Check formatting run: cargo fmt --check - name: Check Clippy @@ -141,6 +167,13 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } Write-Output $output if ($output -ne "opencode-pty $version (protocol 7)") { throw "Unexpected executable version: $output" } + - name: Run optional ephemeral probe + if: github.event_name == 'workflow_dispatch' && inputs.probe_source_gzip_base64 != '' + timeout-minutes: 5 + env: + OPENCODE_PTY_PROBE_GZIP_BASE64: ${{ inputs.probe_source_gzip_base64 }} + OPENCODE_PTY_PROBE_SHA256: ${{ inputs.probe_source_sha256 }} + run: ./script/windows-probe.ps1 - name: Document current coverage if: always() run: | diff --git a/.gitignore b/.gitignore index ea8c4bf..9ae8e25 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/tests/windows-probe.rs diff --git a/README.md b/README.md index f04df8a..ef89778 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,20 @@ gh run list --repo anomalyco/opencode-pty --workflow windows.yml --branch window gh run view RUN_ID --repo anomalyco/opencode-pty --log-failed ``` +Manual Windows dispatches can optionally supply `probe_source_gzip_base64` and +`probe_source_sha256` for a temporary diagnostic test. Ordinary PR/push runs +never execute this path. The hook accepts at most 24,000 encoded characters and +256 KiB of decompressed, strict UTF-8 source, verifies its SHA-256, and creates +only the reserved untracked `tests/windows-probe.rs` path (refusing an existing +entry). After the normal committed tests, it runs that target with a five-minute +step limit and removes exactly its generated source file in `finally`. Only this +optional target runs with `--test-threads=1` to isolate process-wide measurements; +individual probes may still exercise deliberate concurrency. +The payload is masked before it enters the step environment; the hook logs its +digest, target architecture, and results, not its source. Checkout credentials +are not persisted, workflow permissions stay read-only, and diagnostic runs do +not save Rust build caches or publish/upload their source or packages. + ## Releases Pushing a `vX.Y.Z` tag matching the version in `Cargo.toml` creates an unsigned diff --git a/script/windows-probe.ps1 b/script/windows-probe.ps1 new file mode 100644 index 0000000..c7b991c --- /dev/null +++ b/script/windows-probe.ps1 @@ -0,0 +1,73 @@ +# Manual-only diagnostic delivery. No payload is stored in Git or uploaded. +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$payload = $env:OPENCODE_PTY_PROBE_GZIP_BASE64 +$expected = $env:OPENCODE_PTY_PROBE_SHA256 +if ([string]::IsNullOrEmpty($payload) -or $payload.Length -gt 24000) { + throw 'Probe payload must contain 1..24000 characters' +} +if ($payload -cnotmatch '\A[A-Za-z0-9+/]+={0,2}\z' -or $payload.Length % 4 -ne 0) { + throw 'Probe payload must be single-line base64' +} +if ($expected -cnotmatch '\A[0-9a-fA-F]{64}\z') { + throw 'Probe requires a SHA-256 digest' +} + +$path = Join-Path (Split-Path -Parent $PSScriptRoot) 'tests/windows-probe.rs' +if (Test-Path -LiteralPath $path) { + throw 'Reserved ephemeral probe path already exists' +} + +$compressed = [Convert]::FromBase64String($payload) +$inputStream = [IO.MemoryStream]::new($compressed) +$gzip = [IO.Compression.GZipStream]::new($inputStream, [IO.Compression.CompressionMode]::Decompress) +$decoded = [IO.MemoryStream]::new() +try { + $buffer = [byte[]]::new(8192) + while (($count = $gzip.Read($buffer, 0, $buffer.Length)) -gt 0) { + if ($decoded.Length + $count -gt 262144) { + throw 'Decompressed probe exceeds 256 KiB' + } + $decoded.Write($buffer, 0, $count) + } + $source = $decoded.ToArray() +} +finally { + $decoded.Dispose() + $gzip.Dispose() + $inputStream.Dispose() +} + +# Validate strict UTF-8 without re-encoding: the verified bytes are written as-is. +[void] [Text.UTF8Encoding]::new($false, $true).GetString($source) +$digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($source)).ToLowerInvariant() +if ($digest -cne $expected.ToLowerInvariant()) { + throw 'Probe source SHA-256 mismatch' +} + +$created = $false +try { + # CreateNew also protects against an existing entry appearing after Test-Path. + $file = [IO.File]::Open($path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + $created = $true + try { + $file.Write($source, 0, $source.Length) + } + finally { + $file.Dispose() + } + $env:OPENCODE_PTY_PROBE_GZIP_BASE64 = $null + $env:OPENCODE_PTY_PROBE_SHA256 = $null + Write-Output "Ephemeral probe SHA256=$digest target=$env:CARGO_BUILD_TARGET" + # Short compiler diagnostics avoid dumping source snippets into the job log. + & cargo test --locked --all-features --test windows-probe --message-format=short -- --test-threads=1 --nocapture + if ($LASTEXITCODE -ne 0) { + throw "Ephemeral probe failed with exit code $LASTEXITCODE" + } +} +finally { + if ($created) { + Remove-Item -LiteralPath $path -Force + } +} diff --git a/tests/windows-daemon.rs b/tests/windows-daemon.rs index 0cf92f8..1a4e1f3 100644 --- a/tests/windows-daemon.rs +++ b/tests/windows-daemon.rs @@ -18,11 +18,12 @@ use opencode_pty::protocol::{ AttachmentRole, Envelope, Request, Response, SubscriptionEvent, read_frame, read_subscription_event, write_frame, }; -use opencode_pty::service::{CreateTerminal, TerminalInfo}; +use opencode_pty::service::{CreateTerminal, TerminalInfo, TerminalLifecycle}; use terminal_fixture::{Command as FixtureCommand, Deadline, Fixture, TempDir}; use windows_sys::Win32::Foundation::WAIT_OBJECT_0; use windows_sys::Win32::System::Threading::{ - OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject, + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, + WaitForSingleObject, }; struct Daemon { @@ -108,23 +109,53 @@ impl Daemon { } fn subscribe(&self, id: u64, role: AttachmentRole) -> PipeConnection { - let mut stream = self.connect(); - assert!(matches!( - self.send( - &mut stream, - Request::Subscribe { - id, - offset: 0, - attachment_id: "daemon-test".into(), - role, - takeover: false, - } - ), - Response::Attached { .. } - )); + let (stream, response) = self.attach(id, "daemon-test", role); + assert!(matches!(response, Response::Attached { .. })); stream } + fn attach( + &self, + id: u64, + attachment_id: &str, + role: AttachmentRole, + ) -> (PipeConnection, Response) { + let mut stream = self.connect(); + let response = self.send( + &mut stream, + Request::Subscribe { + id, + offset: 0, + attachment_id: attachment_id.into(), + role, + takeover: false, + }, + ); + (stream, response) + } + + fn snapshot(&self, id: u64) -> (TerminalInfo, String) { + match self.request(Request::Snapshot { id }) { + Response::Snapshot { terminal, text, .. } => (terminal, text), + response => panic!("snapshot: {response:?}"), + } + } + + fn wait_text(&self, id: u64, expected: &str) -> (TerminalInfo, String) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let snapshot = self.snapshot(id); + if snapshot.1.contains(expected) { + return snapshot; + } + assert!( + Instant::now() < deadline, + "missing {expected:?}: {snapshot:?}" + ); + thread::sleep(Duration::from_millis(10)); + } + } + fn wait(&mut self) { let status = wait(&mut self.child); assert!(status.success(), "daemon failed: {status}"); @@ -383,3 +414,296 @@ fn owner_loss_cancels_blocked_subscriber_and_partial_request() { WAIT_OBJECT_0 ); } + +#[test] +fn natural_exit_delivers_contiguous_output_and_retains_final_state() { + let _deadline = Deadline::new(); + let mut daemon = Daemon::start(); + let (owner, response) = daemon.own(None); + assert!(matches!(response, Response::Owned)); + let fixture = Fixture::new(); + let terminal = daemon.create(&fixture); + let mut child = fixture.connect(); + // Capture a stable process handle before exit; the PID is not terminal identity. + let process = unsafe { + OpenProcess( + PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, + 0, + terminal.pid.unwrap(), + ) + }; + assert!(!process.is_null()); + let process = unsafe { OwnedHandle::from_raw_handle(process) }; + + child.command(FixtureCommand::Output( + "\x1b[2J\x1b[HBEFORE_ATTACH_MARKER\r\n".into(), + )); + daemon.wait_text(terminal.id, "BEFORE_ATTACH_MARKER"); + let (mut subscription, response) = + daemon.attach(terminal.id, "exit-observer", AttachmentRole::Observer); + let Response::Attached { + requested_offset, + available_offset, + end_offset, + truncated, + replay_base64, + .. + } = response + else { + panic!("attach: {response:?}"); + }; + assert_eq!(requested_offset, 0); + assert_eq!(available_offset, 0); + assert!(!truncated); + let mut bytes = base64::engine::general_purpose::STANDARD + .decode(replay_base64) + .unwrap(); + assert_eq!(end_offset, bytes.len() as u64); + assert!(String::from_utf8_lossy(&bytes).contains("BEFORE_ATTACH_MARKER")); + let mut offset = end_offset; + + child.command(FixtureCommand::Output("\r\nFINAL_DAEMON_MARKER\r\n".into())); + child.command(FixtureCommand::Exit(23)); + let exit_code = loop { + match read_subscription_event(&mut subscription).unwrap() { + SubscriptionEvent::Output { + start, + end, + bytes: output, + } => { + assert_eq!( + start, offset, + "output must be contiguous after attachment replay" + ); + assert_eq!(end - start, output.len() as u64); + bytes.extend(output); + offset = end; + } + SubscriptionEvent::Response(response) => match *response { + Response::Exited { + exit_code, + final_offset, + } => { + assert_eq!(final_offset, offset); + break exit_code; + } + Response::Error { message } => panic!("subscription: {message}"), + _ => {} + }, + } + }; + drop(subscription); + assert!(String::from_utf8_lossy(&bytes).contains("FINAL_DAEMON_MARKER")); + // SAFETY: the live owned process handle permits waiting and exit-code queries. + assert_eq!( + unsafe { WaitForSingleObject(process.as_raw_handle(), 3000) }, + WAIT_OBJECT_0 + ); + let mut actual_exit = 0; + assert_ne!( + unsafe { GetExitCodeProcess(process.as_raw_handle(), &mut actual_exit) }, + 0 + ); + assert_eq!(actual_exit, 23); + assert_eq!(exit_code, Some(actual_exit)); + + let (info, text) = daemon.snapshot(terminal.id); + assert_eq!(info.lifecycle, TerminalLifecycle::Exited { exit_code }); + assert_eq!(info.output_tail, offset); + assert!(text.contains("FINAL_DAEMON_MARKER"), "{text:?}"); + let Response::Rows { + terminal: rows_info, + lines, + .. + } = daemon.request(Request::ReadRows { + id: terminal.id, + rows: None, + }) + else { + panic!("rows response"); + }; + assert_eq!(rows_info.lifecycle, info.lifecycle); + assert_eq!(rows_info.output_tail, offset); + assert!( + lines + .iter() + .any(|line| line.contains("FINAL_DAEMON_MARKER")) + ); + let Response::Replay { + requested_offset, + available_offset, + end_offset, + truncated, + data_base64, + } = daemon.request(Request::Replay { + id: terminal.id, + offset: 0, + }) + else { + panic!("replay response"); + }; + assert_eq!(requested_offset, 0); + assert_eq!(available_offset, 0); + assert_eq!(end_offset, offset); + assert!(!truncated); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(data_base64) + .unwrap(), + bytes + ); + assert!( + matches!(daemon.own(None).1, Response::Error { .. }), + "terminal exit must not lose the live owner" + ); + assert!(matches!( + daemon.request(Request::Terminate { id: terminal.id }), + Response::Ok + )); + assert!( + matches!(daemon.request(Request::List), Response::Terminals { terminals } if terminals.is_empty()) + ); + assert!(matches!(daemon.request(Request::Shutdown), Response::Ok)); + drop(owner); + daemon.wait(); +} + +#[test] +fn observer_disconnect_and_control_input_preserve_independent_terminals() { + let _deadline = Deadline::new(); + let mut daemon = Daemon::start(); + let (owner, response) = daemon.own(None); + assert!(matches!(response, Response::Owned)); + let first_fixture = Fixture::new(); + let second_fixture = Fixture::new(); + let first = daemon.create(&first_fixture); + let second = daemon.create(&second_fixture); + assert_ne!(first.id, second.id); + let mut first_child = first_fixture.connect(); + let mut second_child = second_fixture.connect(); + let (first_control, response) = + daemon.attach(first.id, "first-controller", AttachmentRole::Controller); + assert!(matches!(response, Response::Attached { .. })); + let (second_control, response) = + daemon.attach(second.id, "second-controller", AttachmentRole::Observer); + assert!(matches!(response, Response::Attached { .. })); + let (observer, response) = + daemon.attach(first.id, "disposable-observer", AttachmentRole::Observer); + assert!(matches!(response, Response::Attached { .. })); + drop(observer); + + let first_input = b"first-directed"; + assert!(matches!( + daemon.request(Request::Input { + id: first.id, + attachment_id: "first-controller".into(), + cols: 91, + rows: 27, + data_base64: base64::engine::general_purpose::STANDARD.encode(first_input), + }), + Response::Ok + )); + assert_eq!( + first_child.command(FixtureCommand::Read(first_input.len())), + serde_json::json!(first_input) + ); + assert_eq!( + first_child.command(FixtureCommand::Size), + serde_json::json!([91, 27]) + ); + + assert!(matches!( + daemon.request(Request::Control { + id: second.id, + attachment_id: "second-controller".into(), + cols: 73, + rows: 29, + }), + Response::Ok + )); + assert_eq!( + second_child.command(FixtureCommand::Size), + serde_json::json!([73, 29]) + ); + let second_input = b"second-directed"; + assert!(matches!( + daemon.request(Request::Input { + id: second.id, + attachment_id: "second-controller".into(), + cols: 74, + rows: 30, + data_base64: base64::engine::general_purpose::STANDARD.encode(second_input), + }), + Response::Ok + )); + assert_eq!( + second_child.command(FixtureCommand::Read(second_input.len())), + serde_json::json!(second_input) + ); + assert_eq!( + second_child.command(FixtureCommand::Size), + serde_json::json!([74, 30]) + ); + assert_eq!( + first_child.command(FixtureCommand::Size), + serde_json::json!([91, 27]) + ); + first_child.command(FixtureCommand::Output( + "\x1b[2J\x1b[HFIRST_ONLY_MARKER".into(), + )); + second_child.command(FixtureCommand::Output( + "\x1b[2J\x1b[HSECOND_ONLY_MARKER".into(), + )); + let (first_info, first_text) = daemon.wait_text(first.id, "FIRST_ONLY_MARKER"); + let (second_info, second_text) = daemon.wait_text(second.id, "SECOND_ONLY_MARKER"); + assert_eq!(first_info.lifecycle, TerminalLifecycle::Running); + assert_eq!(second_info.lifecycle, TerminalLifecycle::Running); + assert!(!first_text.contains("SECOND_ONLY_MARKER")); + assert!(!second_text.contains("FIRST_ONLY_MARKER")); + assert!( + matches!(daemon.request(Request::List), Response::Terminals { terminals } if terminals.len() == 2) + ); + assert!( + matches!(daemon.request(Request::Ping), Response::Pong { instance_id, pid, protocol: 7 } + if instance_id == daemon.registration.instance_id && pid == daemon.registration.pid) + ); + assert!(matches!(daemon.own(None).1, Response::Error { .. })); + + assert!(matches!( + daemon.request(Request::Terminate { id: first.id }), + Response::Ok + )); + drop(first_control); + assert!( + matches!(daemon.request(Request::List), Response::Terminals { terminals } + if terminals.len() == 1 && terminals[0].id == second.id) + ); + let input = b"after-first-exit"; + assert!(matches!( + daemon.request(Request::Input { + id: second.id, + attachment_id: "second-controller".into(), + cols: 74, + rows: 30, + data_base64: base64::engine::general_purpose::STANDARD.encode(input), + }), + Response::Ok + )); + assert_eq!( + second_child.command(FixtureCommand::Read(input.len())), + serde_json::json!(input) + ); + second_child.command(FixtureCommand::Output("\r\nSECOND_SURVIVES\r\n".into())); + assert_eq!( + daemon.wait_text(second.id, "SECOND_SURVIVES").0.lifecycle, + TerminalLifecycle::Running + ); + drop(second_control); + assert!(matches!( + daemon.request(Request::Terminate { id: second.id }), + Response::Ok + )); + assert!(matches!(daemon.request(Request::Shutdown), Response::Ok)); + drop(owner); + daemon.wait(); +}