Repository navigation
Make the onlyoffice settings take effect: formats and the Save button - #387
Merged
Merged
Conversation
chadek
force-pushed
the
fixOnlyofficeSettings
branch
from
September 5, 2026 09:22
50c330c to
2d03c5d
Compare
AutoConfig::autoConfigIfNeeded() runs from boot(), so on every request, and it
re-applied syncSupportedFormats() against a hardcoded list. Anything the admin
had enabled that the list did not mention was switched back off by the next page
load:
admin enables pdf/docm/html editing: pdf=True docm=True html=True
-> loading the files app once: pdf=False docm=False html=False
The list had also gone stale. It predated 9.x and named twelve editable formats
where the bundled server declares twenty-nine - and three of the twelve (doc,
ppt, xls) it cannot edit at all, only auto-convert.
Formats are now seeded once, at initial auto-config, from the format matrix the
bundled server ships, and never touched again; from there the admin owns them.
Seeding still earns its keep, because the connector leaves the lossy-editable
formats (odt, ods, odp, csv, rtf, txt) off by default - that was the point of
doing it at all. Default openers stay a curated ten rather than following
capability, since making OnlyOffice the default handler for .txt or .html would
fight Nextcloud's own apps.
A fresh install now seeds 29 editable formats where it seeded 12.
Reading the matrix out of the package is BundledFormats, which is also where the
unit test can get at it without the connector installed.
tests/rig/formats_test.py covers the three things end to end: the seed a fresh
install gets, an admin choice surviving page loads, and the setting reaching the
editor - the connector's own config response, where a format decision shows up
as editorConfig.mode.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
"Keep intermediate versions when editing" sets editorConfig.customization.forcesave, which gives the editor a Save button. Pressing it sends a forceSaveStart socket command the dispatcher had no handler for, so the command was dropped and the button did nothing - the setting looked broken from the settings UI. The new handler answers with the two-part reply the client expects (forceSaveStart then forceSave). Both carry the same timestamp, because the client stores the one from forceSaveStart and ignores a forceSave whose time does not match it. The save is synchronous, so a failure is reported as an error rather than as a save that starts and never lands, and a press with nothing typed since the last write is answered NotModified - the editor's own state for that - rather than as a save that did not happen. It saves a snapshot rather than flushing. flushChanges() is written for a session that is ending: it consumes the change list and, when no session is left, closes the document. Driving that from the Save button meant doing it to a document people are still typing into - the change list is the only record of what was typed, since Editor.bin stays at the version the document was opened at, so a session opening next would render the document from before the save and the eventual flush would write a file that had lost the earlier edits. Also adds `documentserver:flush --snapshot`, which is that same write on its own: for cron at a chosen interval, or to get every open document on disk on demand. Covered by tests/XHRCommand/ForceSaveTest.php for the reply protocol and tests/rig/forcesave_test.py end to end - what the editor was told, the file on disk, a session opened afterwards, and the file again once everyone has left. flush_live_test gains the --snapshot step. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
chadek
force-pushed
the
fixOnlyofficeSettings
branch
from
September 7, 2026 13:18
2d03c5d to
434d257
Compare
documentserver_change_doc_id is unique over (change_index, document_id). Uniqueness does not care about the order but every query does, and the leading column was the one nothing filters on: both `SELECT MAX(change_index) WHERE document_id = ?` and the delete behind deleteIndex had to fall back to an index that only narrows by document and then scan what it found. That was affordable when the max was read once per save. It is read several times per save now, plus once per snapshot, so the same pair the other way round turns the read into a lookup - and costs nothing, because it replaces an index rather than adding one. Measured on the rig with 2001 rows: the old order plans "Using where; Using index" over all of them, the new one "Select tables optimized away". The app version has to move for any migration to run at all - Nextcloud only replays them when the declared version increases, and the tag v0.3.3 still carried 0.3.2 inside - so it goes up here, with the first change that needs it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
The build downloads ~600 MB of native code, including the x2t binary the web server executes, and took whatever arrived. Pin the sha256 of the asset for ds_version and check it before anything reads the file; a pinned hash also catches a release re-uploaded under the same tag, which the digest the API reports alongside the url cannot. Override ds_sha256 together with ds_version, or set it empty to build unverified. Two smaller things in the same step. The asset url is now matched against browser_download_url rather than the first https://...x86_64.rpm anywhere in the response, which need not be an asset of the release at all. And the extracted tree is no longer chmod 777: that shipped a world-writable document server, so any local account could replace the converter the web server runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
Its one constructor argument is a string with a default, and Nextcloud's container resolves builtin-typed parameters by *parameter name* before falling back to that default - so a service registered under the name "path" would be handed to it as a file path. Registering it removes the coupling; the constructor argument stays for the tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
Seeding runs from AutoConfig::autoConfig(), which only runs while the connector has no document server url - so it reaches new installs and nothing else. An instance configured before the seed was read from the bundled package keeps what the old hardcoded list wrote: twelve editable formats, three of which (doc, ppt, xls) the bundled server cannot edit at all, and none of the twenty-nine it can. The decision of whether an install may be re-seeded is deliberately conservative. The old code force-wrote the matrix on every request, so an install that ran it holds exactly the hardcoded lists - except where the admin *disabled* something, which survived because the write was an AND against what was already there. That asymmetry is what makes this safe to decide: anything other than the two lists exactly is a choice somebody made, and is left alone. AutoConfig is resolved from the container inside run(), behind an isEnabledForUser guard, rather than injected: building it needs the connector's own AppConfig class, so with onlyoffice disabled an eagerly injected one makes `occ upgrade` die with `Class "OCA\Onlyoffice\AppConfig" does not exist`. Verified on the rig, which is also where formats_test now drives two real `occ upgrade` runs - repair steps are not part of `occ maintenance:repair`, so an app's installed version being behind info.xml is the only thing that carries them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
Assembling a document is a converter run, and forceSaveStart is the one place a client asks for one directly: the periodic write is floored at autosave_interval, a command is whatever arrives on the socket. A client that sends forceSaveStart after every change turns each keystroke into an x2t run, because a stored change is exactly what stops the write being skipped as unmodified. SaveHandler::saveSnapshotThrottled() puts a floor under a write somebody asked for, kept separate from saveSnapshotIfDue() because `autosave_interval 0` must not silence the Save button - the admin turned off the periodic write, not the feature. A session that cannot edit has nothing to save and no Save button either, so forceSaveStart from one is not the editor asking. It is answered the way an unmodified document is, so nothing hangs, and not acted on. No other command handler checks isReadOnly - read-only is enforced in the browser only - but this one hands out converter runs, so it checks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
The command sweeps every open document and it is the thing an admin runs before a backup or from cron. Returning at the first failure meant one document the converter chokes on left every document after it unwritten, silently, since the exit code was all that said so - the opposite of what somebody running it before a backup is asking for. Each document is attempted now, failures are reported on stderr as well as logged, and the exit code says whether the sweep went cleanly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
The job called saveSnapshot() directly, so it ignored autosave_interval - including the 0 that is documented as turning the periodic write off - and turned one cron pass into a converter run for every open document on a busy instance. This is the periodic write arriving by another road, so it goes through the same interval the editing path uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
getSessionsForDocument() counted every row for the document. Rows are only deleted by cleanSessions(), which runs from the background job, so between two job runs the table holds every session that ever stopped polling - a browser that was killed, a laptop that was closed. Counting those meant a document was never seen to be empty: SessionCloser::sessionLeft() would not write the file when the last editor left, and the write fell back to whenever the job next ran, which is exactly the wait nextcloud#100 exists to remove. It also made expireSession() do nothing observable. The cutoff is the one cleanSessions() deletes by, so this only stops counting a session that was already on its way out; one that is genuinely polling says so every Channel::SEEN_INTERVAL seconds, six times over inside the timeout. expireSession() now backdates past that cutoff instead of writing zero. Zero happens to be far enough in the past against a real clock, but it says nothing about why and only works while the clock is large - which a test with a small clock found out. The docblocks on removeSession() and sessionLeft() say why a departing editor's row is deleted and not expired, because the two look interchangeable and are not: the poll that session left behind keeps running server-side for up to Channel::TIMEOUT and marks it as seen every Channel::SEEN_INTERVAL, so an expired row revives itself out of its own orphaned request and the document is never disposed of. Deleting is what makes that marking a no-op. A client that only stopped co-authoring is the other case - its page is still there and still polling, and reviving is what should happen. harness.sessions() counts participants the same way, because every test that calls it means "who is still editing", not "how many rows have not been garbage-collected yet". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
popMessage() counts how often a claimed slot is found empty, and inc() is the only atomic counter IMemcache offers - it takes no TTL, and no Nextcloud backend gives one to a key it creates. cleanupChannel() removes only the read and write pointers, so a counter for a slot that is never resolved - the session ended while a push was in flight - sat in the cache for the lifetime of the process. The messages themselves already expire; their bookkeeping now gets the same lifetime where the backend supports it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
The handler took whatever string arrived on the socket. The editor's own limit is a maxlength on a textarea, which is no limit at all for anything that is not the editor, and the history is capped at a hundred *messages* with nothing said about their size - so a scripted client could park as much in a document's appdata as the request limit allows. Capped at sdkjs c_oAscMaxCellOrCommentLength, the editor's own number, and cut by characters rather than bytes: truncating mid-sequence would put invalid UTF-8 in the history file, and json_encode() returns false on that, which would lose the whole backlog rather than one message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
The branch that appends the close beacon reads the file itself, so it skipped the existence check createFileResponse() does. On a build where api.js was never rendered from its .tpl, file_get_contents() on a missing file turned a 404 into a warning and a 200 with nothing in it. The setup-check branch above still runs either way, because that is the one that explains to the user why the document server is not there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
Nothing authenticates /session-closed but the session id, and it holds a worker for the grace period, so cap what one caller can spend: twenty a minute is generous next to what an editor does - a session says goodbye once - and low enough that guessing at session ids is not free. Once, though, is what the page was not doing. pagehide and unload both fire for the same departure and both sent a beacon, so the server held a worker through the grace period a second time only to find the session already gone. The frame now remembers the id it has said goodbye for, the way the host half already did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
addChangesForDocument() reads the max inside its own transaction and retries a batch that collides, so only it knows where the changes landed. SaveChangesCommand worked the numbers out from a max it read beforehand, outside that transaction - which is wrong exactly when a save raced another one, the case the retry exists for. Nothing on the wire moves: Change::formatForClient() does not emit the change index, and sdkjs never reads startIndex out of the broadcast either. This is the guess removed, not a protocol change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
…index sdkjs keeps this as its synced position and derives absolute positions from it by adding its own offsets: on the wire `deleteIndex += this.changesIndex`, and locally `Changes.length = SyncIndex + deleteIndex`, where a length is plainly a count. We sent getMaxChangeIndexForDocument(), which is count - 1, so every position the client derived came back one too low. Logging the incoming values on the rig showed the arithmetic exactly: deleteIndex == maxBefore + nchanges, twice over (350 = 266 + 84, 330 = 246 + 84). It hid because a save that carries changes still derives a position past the end of the store, so the delete matches nothing - probe runs showed "rows before=247, rows after=247" and passed. But sdkjs also sends a saveChanges with an *empty* change array when all it has to say is a deleteIndex (`0 < aChanges.length || null !== deleteIndex`), and there the off-by-one is a request to delete one change that should have survived. That is the intermittent lost marker in leave_test: one change, gone from the file and from the document when it is reopened. The guard around the delete was wrong too. The key is absent from most saves and -1 when the client explicitly has nothing to discard - sdkjs guards its own arithmetic with `-1 !== this.deleteIndex` - and both reached deleteChangesByIndex(), because the key was read unguarded and the test was truthiness. -1 is truthy in PHP, so it asked for `change_index >= -1`: every change in the document. A document-wide cut is the right semantic, for the record - sdkjs converts to an absolute document index on purpose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
chadek
force-pushed
the
fixOnlyofficeSettings
branch
from
September 15, 2026 03:40
600cb2f to
09660f6
Compare
This was referenced Sep 15, 2026
chadek
added a commit
to chadek/documentserver_community
that referenced
this pull request
Sep 15, 2026
The bundled document server is Euro-Office rather than ONLYOFFICE DocumentServer as of nextcloud#386, which is a change of what the app ships rather than a fix to how it ships it, so the minor moves. It also carries the two migrations added since v0.3.3 - Nextcloud only replays those when the declared version increases - and the repair step that hands an install configured before nextcloud#387 the format matrix a fresh one is seeded with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
chadek
added a commit
that referenced
this pull request
Sep 16, 2026
The bundled document server is Euro-Office rather than ONLYOFFICE DocumentServer as of #386, which is a change of what the app ships rather than a fix to how it ships it, so the minor moves. It also carries the two migrations added since v0.3.3 - Nextcloud only replays those when the declared version increases - and the repair step that hands an install configured before #387 the format matrix a fresh one is seeded with. Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Started as two onlyoffice admin settings that say something is happening and, on this server, are not: the format matrix and the editor's Save button. It then grew a review pass over this branch and over master, and now also carries fourteen fixes found that way — including one that loses a change under simultaneous editing.
Rebased on master — the other two commits this branch used to carry are in already, by way of #396, whose versions are better, so they are gone from here. The chat fix landed separately as #389.
Sixteen commits, each doing one thing.
1. Stop overwriting the admin's format settings on every request
AutoConfig::autoConfigIfNeeded()runs fromboot()— so on every request — and re-appliedsyncSupportedFormats()against a hardcoded list. Anything the admin enabled that the list did not mention was switched back off by the next page load:The list had also gone stale. It predated 9.x and named twelve editable formats where the bundled server declares twenty-nine — and three of the twelve (
doc,ppt,xls) it cannot edit at all, only auto-convert.Formats are now seeded once, at initial auto-config, from the format matrix the bundled server ships, and never touched again; from there the admin owns them. Seeding still earns its keep, because the connector leaves the lossy-editable formats (
odt,ods,odp,csv,rtf,txt) off by default — that was the point of doing it at all. Default openers stay a curated ten rather than following capability, since making OnlyOffice the default handler for.txtor.htmlwould fight Nextcloud's own apps.A fresh install now seeds 29 editable formats where it seeded 12.
Reading the matrix out of the package is
lib/OnlyOffice/BundledFormats.php, whose path is injectable so the unit test does not need the document server to have been built.2. Make the editor's Save button work
"Keep intermediate versions when editing" sets
editorConfig.customization.forcesave, which gives the editor a Save button. Pressing it sends aforceSaveStartsocket command the dispatcher had no handler for, so the command was dropped and the button did nothing — the setting looked broken from the settings UI.The new handler answers with the two-part reply the client expects (
forceSaveStartthenforceSave). Both carry the same timestamp, because the client stores the one fromforceSaveStartand ignores aforceSavewhose time does not match it. The save is synchronous, so a failure is reported as an error rather than as a save that starts and never lands, and a press with nothing typed since the last write is answeredNotModified— the editor's own state for that — rather than as a save that did not happen.It snapshots rather than flushes.
flushChanges()is written for a session that is ending: it consumes the change list and, when no session is left, closes the document. Driving that from the Save button meant doing it to a document people are still typing into — the change list is the only record of what was typed, sinceEditor.binstays at the version the document was opened at, so a session opening next would render the document from before the save and the eventual flush would write a file that had lost the earlier edits.Also adds
documentserver:flush --snapshot, which is that same write on its own: for cron at a chosen interval, or to get every open document on disk on demand.The rest came out of reviewing this branch and the release before it. Each is its own commit.
Losing work
changesIndexwas the highest index where sdkjs wants a countThis is the one worth reading. sdkjs keeps this number as its synced position and derives absolute positions from it by adding its own offsets — on the wire
deleteIndex += this.changesIndex, and locallyChanges.length = SyncIndex + deleteIndex, where a length is plainly a count. We sentgetMaxChangeIndexForDocument(), which iscount - 1, so every position the client derived came back one too low.Logging the incoming values on the rig showed the arithmetic exactly, twice over:
It hides because a save that carries changes still derives a position past the end of the store, so the delete matches nothing — those two probe runs showed
rows before=247, rows after=247and passed. But sdkjs also sends asaveChangeswith an empty change array when all it has to say is adeleteIndex(0 < aChanges.length || null !== deleteIndex), and there the off-by-one is a request to delete one change that should have survived. One change, gone from the file and from the document when it is reopened.The guard around the delete was wrong too. The key is absent from most saves and
-1when the client explicitly has nothing to discard — sdkjs guards its own arithmetic with-1 !== this.deleteIndex— and both reacheddeleteChangesByIndex(), because the key was read unguarded and the test was truthiness.-1is truthy in PHP, so it asked forchange_index >= -1: every change in the document. Never observed firing, but it is one client message away.A document-wide cut is the right semantic, for the record — sdkjs converts to an absolute document index on purpose.
A document was never seen to be empty
getSessionsForDocument()counted every row for the document. Rows are only deleted bycleanSessions(), which runs from the background job, so between two job runs the table holds every session that ever stopped polling — a browser that was killed, a laptop that was closed. Counting those meant the last editor leaving never looked like the last editor leaving:SessionCloser::sessionLeft()did not write the file, and the write fell back to whenever cron next ran, which is exactly the wait #100 exists to remove. It also madeexpireSession()do nothing observable.Two notes from getting this wrong on the way:
expireSession()backdates past the cutoff now instead of writing zero. Zero happens to be far enough in the past against a real clock, but it says nothing about why — and a test with a small clock found it out.Channel::TIMEOUTand marks it as seen everyChannel::SEEN_INTERVAL, so an expired row revives itself out of its own orphaned request. I tried expiring — every unit test passed, and the rig failed with "B lost the document server when A left".Saving
documentserver:flushfinishes its sweep. Returning at the first failure meant one document the converter chokes on left every document after it unwritten, silently — the opposite of what somebody running it before a backup wants. Each is attempted now, failures go to stderr as well as the log, and the exit code says whether it went cleanly.autosave_interval. It calledsaveSnapshot()directly, so it ignored the interval — including the0documented as turning the periodic write off — and turned one cron pass into a converter run for every open document.forceSaveStartis the one place a client asks for one directly: the periodic write is floored atautosave_interval, a command is whatever arrives on the socket. A client sending it after every change turns each keystroke into an x2t run. The floor is deliberately not an app setting, becauseautosave_interval 0must not silence the button.Hardening
maxlengthon a textarea, which is no limit for anything that is not the editor. Capped at sdkjsc_oAscMaxCellOrCommentLengthand cut by characters, because truncating mid-sequence puts invalid UTF-8 in the history file andjson_encode()returnsfalseon that — losing the whole backlog rather than one message./session-closedbut the session id and it holds a worker for the grace period.pagehideandunloadboth fired for the same departure, so the server held a worker through the grace period twice.inc()is the only atomic counterIMemcacheoffers and it takes no TTL, andcleanupChannel()removed only the read and write pointers — so a counter for a slot never resolved sat in the cache for the life of the process.api.jsanswers 404 instead of a warning and a 200 with nothing in it.Build and schema
ds_sha256is pinned and checked first; a pinned hash also catches a release re-uploaded under the same tag, which the digest the API reports alongside the url cannot. The asset url is matched againstbrowser_download_urlrather than the firsthttps://...x86_64.rpmanywhere in the response, and the extracted tree is no longerchmod 777— that shipped a world-writable document server, so any local account could replace the converter.(change_index, document_id)led with the column nothing filters on. Measured on the rig with 2001 rows: the old order plansUsing where; Using indexover all of them, the new oneSelect tables optimized away.v0.3.3still carried0.3.2inside — so neither the index migration above nor Drop the database indexes that duplicate a wider one #395's would have run on an upgrade.AutoConfigfrom the container behind anisEnabledForUserguard rather than taking it as a constructor argument: withonlyofficedisabled, an eagerly injected one makesocc upgradedie withClass "OCA\Onlyoffice\AppConfig" does not exist.BundledFormatsis registered rather than autowired — its one constructor argument is a string, and the container resolves builtin-typed parameters by parameter name before falling back to the default.Testing
phpunit, green in CI: 86 tests, 232 assertions. New files:
SaveChangesCommandTestchangesIndexis a count;-1and an absent key delete nothing; a later chunk does not re-apply the deleteSessionManagerTest(+)expireSessiondrops it at once and a poll revives itSessionCloserTestChangeStoreTestFlushChangesTestCleanupTestChatMessageTestForceSaveTest(+)SaveHandlerTest(+)autosave_interval 0Every one of these was run against the unfixed code first, so they fail for the reason they exist.
tests/rig, all ten green on a Nextcloud 34 rig with the bundled server:leaveis the one that matters here: it was failing intermittently, roughly two runs in six, with one marker missing from the file and from the reopened document. That is thechangesIndexoff-by-one. It has passed three consecutive times since.formats_testgained a fourth section that drives two realocc upgraderuns, because repair steps are not part ofocc maintenance:repair— an app's installed version being behindinfo.xmlis the only thing that carries them.Notes for reviewers
occboots this app, so anocccall made while the connector is unconfigured re-runs auto-configuration. That is whyformats_testdeletes the connector's url last and puts it back afterwards; it is also why clearing the config withocc config:app:deleteto test a fresh install does not work the way you would expect.server/Common/config/default.jsonis not involved in any of this — nothing inlib/reads it. It belongs to the Node DocService this app replaces, so it can never be the reason a setting appears not to work.Channel::getResponse()re-creates a session whose row it cannot find — so aresetstraight after a test that died mid-edit can be followed by rows appearing from nowhere. Wait it out, or restart the app container, before trusting a session count.