Traps#
Bugs that crash find themselves. What is collected here are the bugs that do not crash.
Cookies do not work locally#
Symptom: session, login, CSRF, flash — none of them work, and there is no error.
Cause: cookie.secure defaults to true. Local is http, so the browser
never sends the cookie back.
Fix: put this in your local application.conf. Take it out in production.
cookie {
secure = false
}
A WARN is printed at startup, so read the log.
The route is not registered#
Symptom: you get a 404. The code is right there.
Cause: you forgot to write install(SomeController::new).
jimble does not scan the classpath, so if you do not write it, nothing is registered.
Fix: look at the route list in the startup log. If it is not there, it is not registered.
before is not applying (and it is the same path)#
before applies only inside the block you wrote it in. It does not attach to a path.
path("/admin", () -> {
before(requireAuth);
get("/users", ...);
});
install(() -> new SpaController("/admin", ...)); // ← not authenticated
Same /admin or not, a route registered somewhere else is in a different block.
If you want them covered together, put them in the same block.
The opposite symptom — it applies where you did not want it to — does not happen.
That is what used to happen in the code this was ported from, where even the SPA login
screen under /admin was caught by the auth filter.
Also, adding a before after the routes are settled throws.
That is so "I added it and it does nothing" never gets through in silence.
Keep route definitions entirely inside the controller's initializer block.
A value that was never sent goes into the DB as null#
Symptom: it fails with Column 'title' cannot be null. The form has the field.
Cause: getString on a missing key is null (not an exception).
Data input = context.request().bodyAll();
String title = input.getString("title"); // null if it was not sent
Fix: run validation before you read it. required() also fails when the key is missing.
The session was not saved#
Symptom: you called put(), and on the next request it is gone.
Cause: you did not call save(). Nothing is saved automatically.
context.session().put("user_id", 42);
// 明示的に保存する(要件 F-S-02)。自動保存はしない
context.session().save();
Change it with session().put(...). session().data() is a read-only copy; writing to it throws.
You cannot get at the select result#
Symptom: row.getString("title") comes back empty.
Cause: the result is nested under the table name.
Data row = db.select(
SQL.select()
.from(Post.instance())
.where(Post.id.eq(1L))
).orElseThrow();
// SELECT の結果はテーブル名でネストする(要件 F-D-02)
String title = row.getData("post").getString("title");
// Column で引けば、途中の文字列が出てこない
String same = row.getString(Post.title);
Look it up with Column and you cannot get it wrong.
Catching a DB failure inside a transaction means it cannot commit#
Symptom: tx.commit() fails with TransactionException (DB_004) and nothing is saved.
Cause: a DB failure is a SqlExecuteException. Catch it inside a transaction and carry on, and
that Tx can no longer commit. commit() refuses and rolls everything back.
Fix: the only failure worth branching on is usually a unique violation. Catch just
DuplicateKeyException, outside the transaction. Let everything else fly up
(it becomes a 500, and the transaction rolls back).
try (DB db = BlogExample.db()) {
/*
* 0件は空(空リスト・空の Optional・件数 0)、失敗は SqlExecuteException(2.0)。
* 書かなければ上まで飛んで 500。トランザクションの中なら巻き戻る。
*/
List<Data> rows = db.selectList(SQL.select().from(Post.instance()));
// 分岐したい失敗は一意制約くらい。それだけを受け止める
try {
db.insert(SQL.insert(Post.instance()).value(Post.title, "hello"));
} catch (DuplicateKeyException ex) {
Log.info("もうあります");
}
}
"A transaction was left neither committed nor rolled back"#
ERROR コミットもロールバックもされていないトランザクションが残っていました。ロールバックして閉じます: main
Cause: you called db.begin() without wrapping it in try-with-resources,
and then returned partway through.
Fix: wrap it. jimble picks it up at the end of the execution and rolls it back, but that is cleaning up after an accident, not the right way to write it.
Which tables exist depends on which entry point you started#
{"error":"トランザクションのコミットに失敗しました。"}
Cause: MqQueue#install() or BatchTables.install() is only called from some of
your entry points.
When the web app, the batch and the scheduler each have their own main, it is easy for
each one to set up only what it thinks it needs. You end up creating the MQ table in the
batch entry point while it is the web app that pushes onto the queue.
The nasty part: on a machine where the batch has been run even once, it works. You will not see it locally. It shows up the first time you point at an empty database — CI, or a new environment.
Fix: Put the startup setup in one place and have every entry point call it.
public final class Bootstrap {
public static void load () {
Migration.install(); // before DBUtil.load
// if it cannot connect, SqlExecuteException (DB_007) stops the startup
DBUtil.load(Conf.conf().config(), Bootstrap.class);
BatchTables.install(DBUtil.getMainDB());
new MqQueue(NoticeExecutor.QUEUE_NAME).install();
}
}
Call the same thing from your tests. Set the tests up separately and you get the state where only the tests pass once an entry point changes.
Tip
Run it once against an empty database — that is the quickest way to check.
It is how we found examples/blog was broken.
The generated column order changes when you change products#
Symptom: codegen produces a diff although nothing was changed. Only the
column order differs.
Cause: the generated column order is the physical order. Where
ALTER TABLE ... ADD COLUMN puts the column differs between MySQL (which takes
AFTER body) and PostgreSQL (which always appends), so the same schema can come
out in a different order.
What to do: generate on the product you lead with, and run
codegenCheck there too. Types, nullability and comments all match, so nothing
but the order changes.
SSE does not arrive, or arrives all at once#
Cause: an nginx in between is buffering it up.
Fix: jimble attaches X-Accel-Buffering: no. If that is not enough,
set proxy_buffering off; on the nginx side.
The SSE loop never ends#
Cause: you are not checking sse.isOpen().
isOpen() tells you whether the lifetime is still running. It cannot detect a
disconnect (helidon does not tell it, and the write can block forever).
Check it inside every loop. SSE has the details.
You cannot read cookies in a WebSocket#
Cause: you are trying to read them after the upgrade.
The HTTP headers are visible at onUpgrade and nowhere else.
Fix: authenticate in onUpgrade. Return false and you get a 403.
An MQ message ran twice#
Cause: that is how it is. Exactly-once cannot be built.
Fix: write it so that running it again changes nothing. MQ has the details.
A HOCON comment breaks the config#
Key '/' may not be followed by token: '*'
Cause: you used /* */. HOCON comments are # or //.
jimbleRun says "no main manifest attribute"#
gradle-wrapper.jar has no Main-Class. Your gradlew and your
gradle-wrapper.jar do not match.
Today's gradlew starts like this.
exec java -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" ...
That needs a JAR with a Main-Class. Leave an old JAR (one written for -classpath)
lying around and gradlew and the JAR look like a matched pair while nothing works.
unzip -p gradle/wrapper/gradle-wrapper.jar META-INF/MANIFEST.MF
# Main-Class: org.gradle.wrapper.GradleWrapperMain ← if this is missing, that is your problem
To fix it, run this once in that project.
gradle wrapper --gradle-version 9.7.1
jimbleRun shells out to ./gradlew on every rebuild, so you see this on every save.
Once it works out that the wrapper is broken, it prints the reason and falls back to
the gradle on your PATH, so hot reload itself keeps going.
Gradle will not start#
* What went wrong: 25.0.4
Cause: Gradle 8 does not run on Java 25. (Using Java 25 as a toolchain works on 8 — it is Gradle itself that will not run.)
Fix: use Gradle 9 or later.
Kotlin comments nest#
If a /* turns up inside a block comment in build.gradle.kts,
a nested comment starts right there (Kotlin block comments nest).
The outer one never closes, and every line of code below it disappears. There is no error.
/*
* Reads docs/site/<lang>/*.md ← this /* opens a nested comment
*/
tasks.register("site") { ... } ← never registered, and never complains
When you write a path, avoid *, or use //.
docs/ in .gitignore applies at every level#
Symptom: the templates are missing from the repository you published.
Cause: write docs/ in .gitignore and git applies it to a docs/ at any level.
It drags jimble-docs/src/main/jte/docs/ in with it.
Fix: put a / in front and write /docs/.
Build scripts that break on Gradle 9#
Some things pass on Gradle 8 and break on 9.
| What you wrote | What happens |
|---|---|
val x by tasks.registering { } |
Deprecated in 9.6. In the Kotlin DSL it is a compile error. Use tasks.register("x") { } |
"...".formatted(...) |
A Java 15 method. From a 9.7 Kotlin script it cannot be resolved. Use a string template |
Run your build script at least once on the version you are actually going to use.
Traps that 2.0 removed#
These 1.x traps became a compile error or an exception in 2.0. See Moving to 2.0 for details.
context.request().getString("x")silently returnsnull→Requestis no longer aData; compile error- Missing
select'snullor the update family's-1→ a failure is aSqlExecuteException - Throwing away the return value of
DBUtil.load(...)and carrying on without a connection → an exception stops the startup - The code you ported from and jimble meant different things by
commit()→tx.commit()commits and ends. Usetx.checkpoint()to carry on session().data().put(...)is never saved →UnsupportedOperationExceptionrequired()lets through a field whose key was never sent → it fails