device: Use multiplex script to allow multithread support

Previously the runInShell and outputForRunInShell methods were
exclusively processed single threaded, meaning all calls
were processed sequentially. With the multiplexed
helper script we can now run multiple processes simultaneously.
( see tst_manual_deviceshell )

Additionally the new script allows us to capture both
stdout and stderr from commands which was not possible previously.

Change-Id: I52f4fb46d872dc274edb9c11872d2f6543741b34
Reviewed-by: David Schulz <david.schulz@qt.io>
Reviewed-by: Jarek Kobus <jaroslaw.kobus@qt.io>
Reviewed-by: <github-actions-qt-creator@cristianadam.eu>
This commit is contained in:
Marcus Tillmanns
2022-05-13 15:13:35 +02:00
parent 13146fb0bd
commit 0135c47849
13 changed files with 937 additions and 142 deletions

View File

@@ -25,8 +25,9 @@
#include "deviceshell.h"
#include <qtcassert.h>
#include <qtcprocess.h>
#include "processinterface.h"
#include "qtcassert.h"
#include "qtcprocess.h"
#include <QLoggingCategory>
#include <QScopeGuard>
@@ -35,6 +36,123 @@ Q_LOGGING_CATEGORY(deviceShellLog, "qtc.utils.deviceshell", QtWarningMsg)
namespace Utils {
namespace {
/*!
* The multiplex script waits for input via stdin.
*
* To start a command, a message is send with the format "<cmd-id> "<base64-encoded-stdin-data>" <commandline>\n"
* To stop the script, simply send "exit\n" via stdin
*
* Once a message is received, two new streams are created that the new process redirects its output to ( $stdoutraw and $stderrraw ).
*
* These streams are piped through base64 into the two streams stdoutenc and stderrenc.
*
* Two subshells read from these base64 encoded streams, and prepend the command-id, as well as either "O:" or "E:" depending on whether its the stdout or stderr stream.
*
* Once the process exits its exit code is send to stdout with the command-id and the type "R".
*
*/
const QLatin1String r_execScript = QLatin1String(R"(
#!/bin/sh
readAndMark() {
local buffer
while read buffer
do
printf '%s:%s:%s\n' "$1" "$2" "$buffer"
done
}
base64decode()
{
base64 -d 2>/dev/null
}
base64encode()
{
base64 2>/dev/null
}
executeAndMark()
{
PID="$1"
INDATA="$2"
shift
shift
CMD="$@"
# Output Streams
stdoutenc=$(mktemp -u)
stderrenc=$(mktemp -u)
mkfifo "$stdoutenc" "$stderrenc"
# app output streams
stdoutraw=$(mktemp -u)
stderrraw=$(mktemp -u)
mkfifo "$stdoutraw" "$stderrraw"
# Cleanup
trap 'rm -f "$stdoutenc" "$stderrenc" "$stdoutraw" "$stderrraw" ' EXIT
# Pipe all app output through base64, and then into the output streams
cat $stdoutraw | base64encode > "$stdoutenc" &
cat $stderrraw | base64encode > "$stderrenc" &
# Mark the app's output streams
readAndMark $PID 'O' < "$stdoutenc" >&1 &
readAndMark $PID 'E' < "$stderrenc" >&1 &
# Start the app ...
if [ -z "$INDATA" ]
then
eval $CMD 1> "$stdoutraw" 2> "$stderrraw"
else
echo $INDATA | base64decode | eval "$CMD" 1> "$stdoutraw" 2> "$stderrraw"
fi
exitcode=$(echo $? | base64encode)
wait
echo "$PID:R:$exitcode"
}
execute()
{
PID="$1"
INDATA=$(eval echo "$2")
shift
shift
CMD=$@
executeAndMark $PID "$INDATA" "$CMD"
}
cleanup()
{
kill -- -$$
exit 1
}
if ! command -v base64 &> /dev/null
then
echo "base64 command could not be found" >&2
exit 1
fi
trap cleanup 1 2 3 6
echo SCRIPT_INSTALLED >&2
while read -r id inData cmd; do
if [ "$id" = "exit" ]; then
exit
fi
execute $id $inData $cmd &
done
)");
} // namespace
DeviceShell::DeviceShell()
{
m_thread.setObjectName("Shell Thread");
@@ -43,26 +161,14 @@ DeviceShell::DeviceShell()
DeviceShell::~DeviceShell()
{
m_shellProcess->deleteLater();
if (m_thread.isRunning()) {
m_thread.quit();
m_thread.wait();
}
}
bool DeviceShell::waitForStarted()
{
QTC_ASSERT(m_shellProcess, return false);
Q_ASSERT(QThread::currentThread() != &m_thread);
bool result;
QMetaObject::invokeMethod(
m_shellProcess,
[this] { return m_shellProcess->waitForStarted(); },
Qt::BlockingQueuedConnection,
&result);
return result;
}
/*!
* \brief DeviceShell::runInShell
* \param cmd The command to run
@@ -78,48 +184,8 @@ bool DeviceShell::runInShell(const CommandLine &cmd, const QByteArray &stdInData
QTC_ASSERT(m_shellProcess, return false);
Q_ASSERT(QThread::currentThread() != &m_thread);
bool result = false;
QMetaObject::invokeMethod(
m_shellProcess,
[this, &cmd, &stdInData] { return runInShellImpl(cmd, stdInData); },
Qt::BlockingQueuedConnection,
&result);
return result;
}
bool DeviceShell::runInShellImpl(const CommandLine &cmd, const QByteArray &stdInData)
{
QTC_ASSERT(QThread::currentThread() == &m_thread, return false);
QTC_ASSERT(m_shellProcess->isRunning(), return false);
QTC_ASSERT(m_shellProcess, return false);
QTC_CHECK(m_shellProcess->readAllStandardOutput().isNull()); // clean possible left-overs
QTC_CHECK(m_shellProcess->readAllStandardError().isNull()); // clean possible left-overs
auto cleanup = qScopeGuard(
[this] { m_shellProcess->readAllStandardOutput(); }); // clean on assert
QString prefix;
if (!stdInData.isEmpty())
prefix = "echo '" + QString::fromUtf8(stdInData.toBase64()) + "' | base64 -d | ";
const QString suffix = " > /dev/null 2>&1\necho $?\n";
const QString command = prefix + cmd.toUserOutput() + suffix;
qCDebug(deviceShellLog) << "Running:" << command;
m_shellProcess->write(command);
m_shellProcess->waitForReadyRead();
const QByteArray output = m_shellProcess->readAllStandardOutput();
bool ok = false;
const int result = output.toInt(&ok);
qCInfo(deviceShellLog) << "Run command in shell:" << cmd.toUserOutput() << "result: " << output
<< " ==>" << result;
QTC_ASSERT(ok, return false);
return result == EXIT_SUCCESS;
const RunResult result = run(cmd, stdInData);
return result.exitCode == 0;
}
/*!
@@ -138,59 +204,37 @@ DeviceShell::RunResult DeviceShell::outputForRunInShell(const CommandLine &cmd,
QTC_ASSERT(m_shellProcess, return {});
Q_ASSERT(QThread::currentThread() != &m_thread);
RunResult result;
QMetaObject::invokeMethod(
m_shellProcess,
[this, &cmd, &stdInData] { return outputForRunInShellImpl(cmd, stdInData); },
Qt::BlockingQueuedConnection,
&result);
return result;
return run(cmd, stdInData);
}
DeviceShell::RunResult DeviceShell::outputForRunInShellImpl(const CommandLine &cmd,
const QByteArray &stdInData)
DeviceShell::State DeviceShell::state() const { return m_shellScriptState; }
DeviceShell::RunResult DeviceShell::run(const CommandLine &cmd, const QByteArray &stdInData)
{
QTC_ASSERT(QThread::currentThread() == &m_thread, return {});
const RunResult errorResult{-1, {}, {}};
QTC_ASSERT(m_shellProcess, return errorResult);
QTC_ASSERT(m_shellScriptState == State::Succeeded, return errorResult);
QTC_ASSERT(m_shellProcess, return {});
QTC_CHECK(m_shellProcess->readAllStandardOutput().isNull()); // clean possible left-overs
QTC_CHECK(m_shellProcess->readAllStandardError().isNull()); // clean possible left-overs
auto cleanup = qScopeGuard(
[this] { m_shellProcess->readAllStandardOutput(); }); // clean on assert
QMutexLocker lk(&m_commandMutex);
QString prefix;
if (!stdInData.isEmpty())
prefix = "echo '" + QString::fromUtf8(stdInData.toBase64()) + "' | base64 -d | ";
QWaitCondition waiter;
const int id = ++m_currentId;
const auto it = m_commandOutput.insert(id, CommandRun{-1, {}, {}, &waiter});
const QString markerCmd = "echo __qtc$?qtc__ 1>&2\n";
const QString suffix = "\n" + markerCmd;
const QString command = prefix + cmd.toUserOutput() + suffix;
QMetaObject::invokeMethod(m_shellProcess, [this, id, &cmd, &stdInData]() {
const QString command = QString("%1 \"%2\" %3\n")
.arg(id)
.arg(QString::fromLatin1(stdInData.toBase64()))
.arg(cmd.toUserOutput());
qCDebug(deviceShellLog) << "Running:" << command;
m_shellProcess->writeRaw(command.toUtf8());
});
qCDebug(deviceShellLog) << "Running:" << command;
m_shellProcess->write(command);
waiter.wait(&m_commandMutex);
RunResult result;
const RunResult result = *it;
m_commandOutput.erase(it);
while (true) {
m_shellProcess->waitForReadyRead();
QByteArray stdErr = m_shellProcess->readAllStandardError();
if (stdErr.endsWith("qtc__\n")) {
QByteArray marker = stdErr.right(stdErr.length() - stdErr.lastIndexOf("__qtc"));
QByteArray exitCodeStr = marker.mid(5, marker.length() - 11);
bool ok = false;
const int exitCode = exitCodeStr.toInt(&ok);
result.stdOutput = m_shellProcess->readAllStandardOutput();
result.exitCode = ok ? exitCode : -1;
break;
}
}
//const QByteArray output = m_shellProcess->readAllStandardOutput();
qCDebug(deviceShellLog) << "Received output:" << result.stdOutput;
qCInfo(deviceShellLog) << "Run command in shell:" << cmd.toUserOutput()
<< "output size:" << result.stdOutput.size()
<< "exit code:" << result.exitCode;
return result;
}
@@ -221,7 +265,7 @@ void DeviceShell::setupShellProcess(QtcProcess *shellProcess)
*/
void DeviceShell::startupFailed(const CommandLine &cmdLine)
{
qCDebug(deviceShellLog) << "Failed to start shell via:" << cmdLine.toUserOutput();
qCWarning(deviceShellLog) << "Failed to start shell via:" << cmdLine.toUserOutput();
}
/*!
@@ -241,7 +285,6 @@ bool DeviceShell::start()
setupShellProcess(m_shellProcess);
m_shellProcess->setProcessMode(ProcessMode::Writer);
m_shellProcess->setWriteData("echo\n");
// Moving the process into its own thread ...
m_shellProcess->moveToThread(&m_thread);
@@ -252,12 +295,46 @@ bool DeviceShell::start()
[this] {
m_shellProcess->start();
if (!m_shellProcess->waitForStarted() || !m_shellProcess->waitForReadyRead()
|| m_shellProcess->readAllStandardOutput() != "\n") {
if (!m_shellProcess->waitForStarted()) {
closeShellProcess();
return false;
}
// TODO: Check if necessary tools are available ( e.g. base64, /bin/sh etc. )
connect(m_shellProcess, &QtcProcess::readyReadStandardOutput, m_shellProcess, [this] {
onReadyRead();
});
connect(m_shellProcess, &QtcProcess::readyReadStandardError, m_shellProcess, [this] {
const QByteArray stdErr = m_shellProcess->readAllStandardError();
if (m_shellScriptState == State::Unknown) {
if (stdErr.contains("SCRIPT_INSTALLED")) {
m_shellScriptState = State::Succeeded;
return;
}
if (stdErr.contains("ERROR_INSTALL_SCRIPT")) {
m_shellScriptState = State::FailedToStart;
qCWarning(deviceShellLog) << "Failed installing device shell script";
return;
}
}
qCWarning(deviceShellLog) << "Received unexpected output on stderr:" << stdErr;
});
connect(m_shellProcess, &QtcProcess::done, m_shellProcess, [this]() {
if (m_shellProcess->resultData().m_exitCode != EXIT_SUCCESS
|| m_shellProcess->resultData().m_exitStatus != QProcess::NormalExit) {
qCWarning(deviceShellLog) << "Shell exited with error code:"
<< m_shellProcess->resultData().m_exitCode << "("
<< m_shellProcess->exitMessage() << ")";
}
});
if (!installShellScript()) {
closeShellProcess();
return false;
}
return true;
},
Qt::BlockingQueuedConnection,
@@ -270,16 +347,158 @@ bool DeviceShell::start()
return result;
}
bool DeviceShell::installShellScript()
{
const QString installCmd
= QString("echo %1 | base64 -d > /tmp/shell.sh 2>/dev/null && "
"chmod +x /tmp/shell.sh && "
"/tmp/shell.sh || echo ERROR_INSTALL_SCRIPT >&2\n")
.arg(QString::fromLatin1(
QByteArray(r_execScript.begin(), r_execScript.size()).toBase64()));
qCDebug(deviceShellLog) << "Install shell script command:" << installCmd;
m_shellProcess->write(installCmd);
while (m_shellScriptState == State::Unknown) {
if (!m_shellProcess->waitForReadyRead()) {
qCWarning(deviceShellLog) << "Timeout while waiting for device shell script to install";
m_shellScriptState = State::FailedToStart;
return false;
}
}
return m_shellScriptState == State::Succeeded;
}
void DeviceShell::closeShellProcess()
{
if (m_shellProcess) {
if (m_shellProcess->isRunning()) {
m_shellProcess->write("exit\n");
m_shellProcess->write("exit\nexit\n");
if (!m_shellProcess->waitForFinished(2000))
m_shellProcess->terminate();
}
m_shellProcess->deleteLater();
}
}
QByteArray::const_iterator next(const QByteArray::const_iterator &bufferEnd,
const QByteArray::const_iterator &itCurrent)
{
for (QByteArray::const_iterator it = itCurrent; it != bufferEnd; ++it) {
if (*it == '\n')
return it;
}
return bufferEnd;
}
QByteArray byteArrayFromRange(QByteArray::const_iterator itStart, QByteArray::const_iterator itEnd)
{
return QByteArray(itStart, std::distance(itStart, itEnd));
}
QList<std::tuple<int, DeviceShell::ParseType, QByteArray>> parseShellOutput(const QByteArray &data)
{
auto itStart = data.cbegin();
const auto itEnd = data.cend();
QList<std::tuple<int, DeviceShell::ParseType, QByteArray>> result;
for (auto it = next(itEnd, itStart); it != itEnd; ++it, itStart = it, it = next(itEnd, it)) {
const QByteArray lineView = byteArrayFromRange(itStart, it);
QTC_ASSERT(lineView.size() > 0, continue);
const auto pidEnd = lineView.indexOf(':');
const auto typeEnd = lineView.indexOf(':', pidEnd + 1);
QTC_ASSERT(pidEnd != -1 && typeEnd != -1, continue);
bool ok = false;
const QLatin1String sId(lineView.begin(), pidEnd);
const quint64 id = QString(sId).toInt(&ok);
QTC_ASSERT(ok, continue);
const QByteArray data = byteArrayFromRange(lineView.begin() + typeEnd + 1, lineView.end());
const QByteArray decoded = QByteArray::fromBase64(data);
DeviceShell::ParseType t;
char type = lineView.at(typeEnd - 1);
switch (type) {
case 'O':
t = DeviceShell::ParseType::StdOut;
break;
case 'E':
t = DeviceShell::ParseType::StdErr;
break;
case 'R':
t = DeviceShell::ParseType::ExitCode;
break;
default:
QTC_CHECK(false);
continue;
}
result.append(std::make_tuple(id, t, decoded));
}
return result;
}
/*!
* \brief DeviceShell::onReadyRead
*
* Reads lines coming from the multiplex script.
*
* The format is: "<command-id>:<type>:base64-encoded-text-or-returnvalue"
* The possible <type>'s are:
* O for stdout
* E for stderr
* R for exit code
*
* Multiple O/E messages may be received for a process. Once
* a single "R" is received, the exit code is reported back
* and no further messages from that process are expected.
*/
void DeviceShell::onReadyRead()
{
m_commandBuffer += m_shellProcess->readAllStandardOutput();
const qsizetype lastLineEndIndex = m_commandBuffer.lastIndexOf('\n') + 1;
if (lastLineEndIndex == 0)
return;
const QByteArray input(m_commandBuffer.cbegin(), lastLineEndIndex);
const auto result = parseShellOutput(input);
QMutexLocker lk(&m_commandMutex);
for (const auto &line : result) {
const auto &[cmdId, type, data] = line;
const auto itCmd = m_commandOutput.find(cmdId);
QTC_ASSERT(itCmd != m_commandOutput.end(), continue);
switch (type) {
case Utils::DeviceShell::ParseType::StdOut:
itCmd->stdOut.append(data);
break;
case Utils::DeviceShell::ParseType::StdErr:
itCmd->stdErr.append(data);
break;
case Utils::DeviceShell::ParseType::ExitCode: {
bool ok = false;
int exitCode;
exitCode = QString::fromUtf8(data.begin(), data.size()).toInt(&ok);
QTC_ASSERT(ok, exitCode = -1);
itCmd->exitCode = exitCode;
itCmd->waiter->wakeOne();
break;
}
}
};
if (lastLineEndIndex == m_commandBuffer.size())
m_commandBuffer.clear();
else
m_commandBuffer = m_commandBuffer.mid(lastLineEndIndex);
}
} // namespace Utils