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
Original file line number Diff line number Diff line change
Expand Up @@ -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_"))
Expand Down Expand Up @@ -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",
Expand Down
23 changes: 16 additions & 7 deletions app/src/main/java/dev/threadline/core/session/SessionManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -344,7 +344,7 @@ class SessionManager(
)
if (
inputRequests.trySend(
SessionInput(session, invocation, hideExactPtyEcho = true),
SessionInput(session, invocation, maximumPtyEchoes = 1),
).isFailure
) {
structuredStateMachine.apply(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Expand All @@ -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())
}
Expand All @@ -874,6 +882,7 @@ internal class InternalInputEchoFilter {
pattern = null
prefixLengths = IntArray(0)
matchedBytes = 0
echoesRemaining = 0
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -483,7 +521,7 @@ class SessionManagerIoTest {
turn.output,
)
assertArrayEquals(
bootstrapRaw + backgroundOutput + commandRaw,
startupOutput + bootstrapRaw + backgroundOutput + commandRaw,
terminal.received.flattenBytes(),
)
assertEquals(
Expand Down
Loading