SnLib v1.27.0
A continuation a caller can chain onto
Nothing on SnFuture returned a new stage. thenSync, exceptionally and orDisablePlugin all end in return this, so a method ending in
return write(...).thenSync(publish);whose caller writes callee().thenSync(next) did not build a chain. It registered two dependents on ONE CompletableFuture. Sibling order is unspecified and OpenJDK pops dependents last-registered-first, so next ran before publish, and read exactly the state the publish was about to install. Hopping to the main thread did not rescue it: both tasks were then queued in that same inverted order.
"B after A's continuation" was inexpressible on this surface, so no amount of documentation could fix it.
SnFuture.chainSync(Consumer<T>)
Runs the consumer on the main thread like thenSync, but returns a new future settled from inside that same task, after the consumer returned. Whatever the caller registers on it is a successor.
The fix belongs in the producer: swap its one thenSync and every existing caller becomes correct without being touched.
// before: the caller's step is a sibling of the publish
return writeState(KEY, date).thenSync(ignored -> lastResetDate = date);
// after: the caller's step is a successor
return writeState(KEY, date).chainSync(ignored -> lastResetDate = date);Three deliberate divergences from thenSync
They all follow from the caller's handler now hanging off the derived future instead of the source.
- A failure is propagated rather than swallowed into one WARN, so terminate the chain with
exceptionallyororDisablePlugin. A chained failure nobody consumes is a failure nobody sees. - A consumer that throws fails the chain instead of only reaching Bukkit's task reporter, because a link that never settles is a permanent hang for whoever waits above it.
- A skipped hop (plugin already disabled, context tearing down, or the disable race) completes the returned future normally with the value, so a teardown never turns into a hang. A normal completion therefore does not prove the consumer ran, and the two are deliberately indistinguishable.
Never wait on a chained future
While the plugin is running, only a main-thread task can complete it, so it carries the wrapMainCompleted marking and a main-thread join()/joinWithin throws instead of deadlocking the server. The marking is not applied when the plugin is already disabled or the context is already tearing down at the moment chainSync is called, because from then on the hop can never happen and the future settles on the completing thread; marking it anyway would make the guard fire during a teardown flush on a wait that would have returned.
A producer whose future a teardown flush joins must keep using thenSync. Joins belong on the future the database module returned.
One window does not settle and is documented rather than closed: a hop that was queued and then cancelled by a disable leaves the future pending. That is precisely what thenSync already did in the same window.
Also in this release
SnPapi.applyOnMain returned SnFuture.wrap for a future only its own main-thread task can complete. A future produced off the main thread and then waited on from it was a silent, permanent server deadlock with no log line. Both overloads now return wrapMainCompleted on the off-main branch, which turns the hang into an immediate throw. Shipped as its own commit, because it is a behaviour change rather than an addition.
Compatibility
Strictly additive. API level 17 -> 18. Every existing method is byte-for-byte unchanged, japicmp passes against the baseline, and every one of the 111 thenSync call sites across the consumer plugins keeps its exact current behaviour. An old consumer jar runs on 1.27.0 unchanged.
A consumer compiled against 1.27.0 inlines API level 18 into its bytecode and will refuse to enable against an older installed SnLib.jar, so update the library on the server before the plugin that needs it.
508 tests, 8 of them new, covering every branch of the sequencing core including the residual window above.