diff --git a/app/src/androidTest/java/dev/threadline/core/session/AndroidStructuredShellIntegrationTest.kt b/app/src/androidTest/java/dev/threadline/core/session/AndroidStructuredShellIntegrationTest.kt index 5310816..0319808 100644 --- a/app/src/androidTest/java/dev/threadline/core/session/AndroidStructuredShellIntegrationTest.kt +++ b/app/src/androidTest/java/dev/threadline/core/session/AndroidStructuredShellIntegrationTest.kt @@ -347,8 +347,7 @@ class AndroidStructuredShellIntegrationTest { } assertTrue( "Bootstrap input reached terminal history: " + - "inputBytes=${bootstrapInput.size}, sharedBytes=$sharedBytes, " + - initialTerminal.substringAfter("builtin eval -- $'", "").take(120), + "inputBytes=${bootstrapInput.size}, sharedBytes=$sharedBytes", !initialTerminal.contains("builtin eval -- $'"), ) assertTrue(!terminal.text().contains("__threadline_run_")) @@ -424,6 +423,15 @@ class AndroidStructuredShellIntegrationTest { assertTrue(!renderedTurn.output.truncated) assertTrue(!terminal.text().contains("__threadline_run_")) + val turnsBeforeRawInput = manager.transcriptState.value.turns.size + manager.send("printf 'raw-input-proof\\n'\r".encodeToByteArray()) + withTimeout(COMMAND_TIMEOUT_MILLIS) { + while (!terminal.text().contains("raw-input-proof\r\n")) delay(10) + } + assertTrue(terminal.text().contains("printf 'raw-input-proof\\n'")) + assertEquals(turnsBeforeRawInput, manager.transcriptState.value.turns.size) + assertSuccessful(manager, "test \"\$PWD\" = /tmp") + executeInteractive( manager = manager, command = "less /etc/services", diff --git a/app/src/main/java/dev/threadline/core/session/SessionManager.kt b/app/src/main/java/dev/threadline/core/session/SessionManager.kt index 5708498..d3d19b7 100644 --- a/app/src/main/java/dev/threadline/core/session/SessionManager.kt +++ b/app/src/main/java/dev/threadline/core/session/SessionManager.kt @@ -149,8 +149,8 @@ class SessionManager( } try { runCatching { - if (input.hideExactPtyEcho) { - internalInputEchoFilter.expect(input.bytes) + if (input.maximumPtyEchoes > 0) { + internalInputEchoFilter.expect(input.bytes, input.maximumPtyEchoes) } input.session.send(input.bytes) } @@ -344,7 +344,7 @@ class SessionManager( ) if ( inputRequests.trySend( - SessionInput(session, invocation, hideExactPtyEcho = true), + SessionInput(session, invocation, maximumPtyEchoes = 1), ).isFailure ) { structuredStateMachine.apply( @@ -633,7 +633,8 @@ class SessionManager( } if ( inputRequests.trySend( - SessionInput(session, bootstrap, hideExactPtyEcho = true), + // Startup can echo the bootstrap twice before its lifecycle begins. + SessionInput(session, bootstrap, maximumPtyEchoes = 2), ).isFailure ) { downgradeStructuredShell( @@ -769,7 +770,7 @@ class SessionManager( private data class SessionInput( val session: LiveSshSession, val bytes: ByteArray, - val hideExactPtyEcho: Boolean = false, + val maximumPtyEchoes: Int = 0, val clearAfterSend: Boolean = false, val commandId: CommandId? = null, ) @@ -823,14 +824,17 @@ internal class InternalInputEchoFilter { private var pattern: ByteArray? = null private var prefixLengths = IntArray(0) private var matchedBytes = 0 + private var echoesRemaining = 0 @Synchronized - fun expect(input: ByteArray) { + fun expect(input: ByteArray, maximumEchoes: Int = 1) { check(pattern == null) { "Only one internal input echo may be pending" } + require(maximumEchoes in 1..2) val expected = input.withPtyEchoLineEnding() pattern = expected prefixLengths = expected.prefixLengths() matchedBytes = 0 + echoesRemaining = maximumEchoes } @Synchronized @@ -853,7 +857,11 @@ internal class InternalInputEchoFilter { if (byte == expected[matchedBytes]) { matchedBytes += 1 - if (matchedBytes == expected.size) reset() + if (matchedBytes == expected.size) { + matchedBytes = 0 + echoesRemaining -= 1 + if (echoesRemaining == 0) reset() + } } else { output.write(byte.toInt()) } @@ -874,6 +882,7 @@ internal class InternalInputEchoFilter { pattern = null prefixLengths = IntArray(0) matchedBytes = 0 + echoesRemaining = 0 } } diff --git a/app/src/test/java/dev/threadline/core/session/SessionManagerIoTest.kt b/app/src/test/java/dev/threadline/core/session/SessionManagerIoTest.kt index d0a1006..10896c6 100644 --- a/app/src/test/java/dev/threadline/core/session/SessionManagerIoTest.kt +++ b/app/src/test/java/dev/threadline/core/session/SessionManagerIoTest.kt @@ -158,6 +158,43 @@ class SessionManagerIoTest { assertArrayEquals(observed, filter.consume(observed)) } + @Test + fun `two startup echoes are removed across every byte split with surrounding output intact`() { + val input = "builtin eval -- setup\n".encodeToByteArray() + val echo = ptyEcho(input) + val greeting = "welcome\r\n".encodeToByteArray() + val prompt = "prompt> ".encodeToByteArray() + val output = "remote output\r\n".encodeToByteArray() + val observed = greeting + echo + prompt + echo + output + echo + + for (split in 0..observed.size) { + val filter = InternalInputEchoFilter() + filter.expect(input, maximumEchoes = 2) + val actual = filter.consume(observed.copyOfRange(0, split)) + + filter.consume(observed.copyOfRange(split, observed.size)) + filter.cancel() + + assertArrayEquals("split=$split", greeting + prompt + output + echo, actual) + } + } + + @Test + fun `ordinary input removes only one echo and incomplete startup redraw fails open`() { + val input = "abcab\n".encodeToByteArray() + val echo = ptyEcho(input) + val ordinary = InternalInputEchoFilter() + ordinary.expect(input) + assertArrayEquals(echo, ordinary.consume(echo + echo)) + + val startup = InternalInputEchoFilter() + startup.expect(input, maximumEchoes = 2) + val mismatched = "abcax\r\n".encodeToByteArray() + assertArrayEquals(mismatched, startup.consume(echo + mismatched)) + val partial = echo.copyOf(3) + assertTrue(startup.consume(partial).isEmpty()) + assertArrayEquals(partial, startup.cancel()) + assertArrayEquals(echo, startup.consume(echo)) + } + @Test fun `reply uses the active PTY without becoming a command or archived input`() = runBlocking { val session = RecordingSession() @@ -412,7 +449,8 @@ class SessionManagerIoTest { exitStatus = 0, currentDirectory = "/home/threadline", ) - val bootstrapStream = bootstrapEcho + bootstrapRaw + val startupOutput = "welcome\r\nprompt> ".encodeToByteArray() + val bootstrapStream = bootstrapEcho + startupOutput + bootstrapEcho + bootstrapRaw val bootstrapEchoSplit = bootstrapEcho.size / 2 session.output.send(bootstrapStream.copyOfRange(0, bootstrapEchoSplit)) session.output.send( @@ -483,7 +521,7 @@ class SessionManagerIoTest { turn.output, ) assertArrayEquals( - bootstrapRaw + backgroundOutput + commandRaw, + startupOutput + bootstrapRaw + backgroundOutput + commandRaw, terminal.received.flattenBytes(), ) assertEquals(