mirror of
https://github.com/logos-co/logos-view-module-runtime.git
synced 2026-08-27 19:11:16 +00:00
* feat(ui-host): give a view its chance to finish before teardown
ui-host deleted the plugin the instant app.exec() returned. Core modules
have had a grace period since the teardown hook shipped; views did not.
Calls the shared logos::runPluginAboutToUnload() (logos-plugin-qt#23)
between exec() returning and `delete pluginObject`. Safe to run a nested
event loop there: the application loop has already returned, so this is the
same shape as logos_host's call site rather than a re-entrant exec(). It
must come BEFORE the delete — it is the plugin it asks, and the plugin has
to still be alive to answer.
THE GRACE PERIOD IS 2000ms, NOT logos_host's 3000. That difference is the
reason the helper takes it as a parameter instead of owning a constant.
ViewModuleHost::stop() gives this process terminate() plus
waitForFinished(3000) before it resorts to kill(), and everything after the
signal has to fit inside that 3s: unwinding exec() through the self-pipe
notifier, this grace period, `delete pluginObject`, the QRemoteObjectHost
destructor unlinking the QtRO socket, and process exit. Porting 3000 would
have consumed the entire budget and left every async view hard-killed
mid-teardown — the precise failure the hook exists to prevent.
Measured, through the real ViewModuleHost driving the real ui-host binary:
no hook 0.001s dtor
async finishes 1.003s about-to-unload, tick x10, work-done, signalled, dtor
async hangs 1.903s about-to-unload, tick x19, dtor
All exit 0 — none hard-killed. The hang case costs 1.9s rather than 2.0
because the deadline is a default coarse QTimer, which Qt may fire up to 5%
early; that errs toward giving up sooner, the safe direction for a budget
carved out of someone else's kill timer.
The assertions are on ENTRY ORDER and relative tick counts, never elapsed
time: `work-done` before `dtor` proves the wait happened, the ticks prove a
nested loop actually ran, and hangTicks > finishTicks proves the deadline
outlasts a completion — i.e. that deadline.isActive() really does
distinguish "finished" from "gave up". Wall clock is asserted only where a
bound IS the contract (stopMs < 3000).
Negative control: removing the call fails 3 of the 4 tests, and correctly
leaves the no-hook one passing.
Two pins move forward, both required rather than incidental:
* logos-plugin-qt -> #23, which adds the helper. TODO: re-point at master
once it merges.
* logos-protocol f4407ff -> 7989472. plugin-qt follows THIS repo's
protocol, and its logos_qt_host_shared refuses to configure against one
that does not export logos_protocol_shared (protocol#65) — a deliberate
FATAL_ERROR, because the alternative is falling back to the archive and
reintroducing the duplicate singleton at runtime.
10/10 tests pass, UiHostUnloadTests included.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(deps): track logos-plugin-qt master now that the helper has landed
logos-plugin-qt#23 merged as ef11c21, so the temporary rev pin goes. The
relocked narHash is identical to the branch tip this was pinned to, so
nothing about the build changes — only the pin.
The logos-protocol bump stays: plugin-qt follows THIS repo's protocol, and
its logos_qt_host_shared refuses to configure against one that does not
export logos_protocol_shared (protocol#65).
10/10 tests green, UiHostUnloadTests included.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
131 lines
5.0 KiB
C++
131 lines
5.0 KiB
C++
// A view plugin whose ONLY job is to record what ui-host did to it on the way
|
|
// down, and in what order.
|
|
//
|
|
// Built three times, differing only in UNLOAD_MODE:
|
|
//
|
|
// 0 none no teardown hook at all -- the module that predates it, and
|
|
// the case that has to stay free. ui-host's invokeMethod finds
|
|
// no such meta-method and moves on.
|
|
// 1 finishes Asynchronous, completes after ~1s.
|
|
// 2 hangs Asynchronous, never signals. The host must give up on it.
|
|
//
|
|
// WHY A JOURNAL, and why a HEARTBEAT.
|
|
//
|
|
// A host-side call that finds no meta-method is a silent no-op, and it looks
|
|
// exactly like success from the outside: the process still exits 0, still on
|
|
// time. Elapsed wall-clock cannot tell the two apart either -- a host that
|
|
// never waited and a module that finished instantly produce the same number.
|
|
//
|
|
// So each plugin appends to a journal file, and every Asynchronous mode also
|
|
// arms a repeating heartbeat in aboutToUnload(). Ticks can only be written by
|
|
// an event loop that is actually running, which is the thing under test: the
|
|
// nested QEventLoop the host spins while it waits. Their ORDER against
|
|
// "work-done" and "dtor", and the RELATIVE count between the two async modes,
|
|
// establish the behaviour without a single timing assertion:
|
|
//
|
|
// * "work-done" before "dtor" -> the host waited for the completion
|
|
// * ticks present in the hang case -> the host really did wait, not skip
|
|
// * hang ticks > finish ticks -> the deadline outlasts a completion,
|
|
// i.e. giving up is a bound and not
|
|
// the mechanism
|
|
#include <QCoreApplication>
|
|
#include <QObject>
|
|
#include <QTimer>
|
|
#include <QtPlugin>
|
|
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
|
|
#ifndef UNLOAD_MODE
|
|
#error "UNLOAD_MODE must be defined (0=none, 1=finishes, 2=hangs)"
|
|
#endif
|
|
|
|
namespace {
|
|
|
|
// Append one line and flush. Line-at-a-time and unbuffered on purpose: the
|
|
// hang case exists to be abandoned mid-teardown, and a journal still sitting
|
|
// in stdio's buffer when that happens would lose exactly the evidence the test
|
|
// is here to read.
|
|
void journal(const char* entry)
|
|
{
|
|
const char* path = ::getenv("LOGOS_TEST_UNLOAD_JOURNAL");
|
|
if (!path || !*path) return;
|
|
FILE* f = ::fopen(path, "a");
|
|
if (!f) return;
|
|
::fprintf(f, "%s\n", entry);
|
|
::fflush(f);
|
|
::fclose(f);
|
|
}
|
|
|
|
// How often the heartbeat writes while the host is waiting. Small enough that
|
|
// the ~1s completion and the 2s deadline land far apart in tick counts, which
|
|
// is what lets the test compare the two runs instead of trusting a clock.
|
|
constexpr int kHeartbeatMs = 100;
|
|
|
|
// When the "finishes" mode reports completion. Comfortably inside ui-host's
|
|
// 2s grace period, and comfortably outside the noise of a single heartbeat.
|
|
constexpr int kWorkMs = 1000;
|
|
|
|
} // namespace
|
|
|
|
class UnloadFixturePlugin : public QObject
|
|
{
|
|
Q_OBJECT
|
|
// No FILE: ui-host never reads a view plugin's metadata, it loads the
|
|
// instance and remotes it. One IID across all three modes is fine -- they
|
|
// are three separate plugin files loaded by three separate processes, and
|
|
// Qt keys its plugin cache on the file path.
|
|
//
|
|
// No Q_INTERFACES either, so ui-host's qobject_cast<LogosViewPlugin*> fails
|
|
// and it falls back to dynamic remoting. That is the path a view plugin
|
|
// without a .rep takes, and it keeps this fixture free of the generated
|
|
// ViewPluginBase machinery that has nothing to do with teardown.
|
|
Q_PLUGIN_METADATA(IID "org.logos.test.UnloadFixture")
|
|
|
|
public:
|
|
UnloadFixturePlugin() = default;
|
|
|
|
~UnloadFixturePlugin() override
|
|
{
|
|
// The last thing that happens. Everything the host did for this plugin
|
|
// must appear above this line.
|
|
journal("dtor");
|
|
}
|
|
|
|
// Deliberately absent in mode 0, so that build produces a plugin whose
|
|
// meta-object genuinely has no such method -- the real "module predates
|
|
// the hook" shape, not a stub that returns Synchronous.
|
|
#if UNLOAD_MODE != 0
|
|
Q_INVOKABLE int aboutToUnload()
|
|
{
|
|
journal("about-to-unload");
|
|
|
|
// Armed here rather than in the constructor: before this point the
|
|
// application event loop is running normally and ticks would say
|
|
// nothing. From here on, the only loop that can run it is the nested
|
|
// one the host spins to wait for us.
|
|
auto* beat = new QTimer(this);
|
|
connect(beat, &QTimer::timeout, this, []() { journal("tick"); });
|
|
beat->start(kHeartbeatMs);
|
|
|
|
#if UNLOAD_MODE == 1
|
|
// Finish, late but inside the grace period.
|
|
QTimer::singleShot(kWorkMs, this, [this]() {
|
|
journal("work-done");
|
|
emit unloadFinished();
|
|
journal("signalled");
|
|
});
|
|
#endif
|
|
// 1 == LogosShutdown::Asynchronous. Sent as a bare int because that is
|
|
// what the host reads back with Q_RETURN_ARG(int) -- it resolves this
|
|
// by signature and must not need an SDK enum to do it.
|
|
return 1;
|
|
}
|
|
|
|
Q_SIGNALS:
|
|
void unloadFinished();
|
|
#endif
|
|
};
|
|
|
|
#include "test_unload_fixture.moc"
|