Skip to content

Commit 1d24564

Browse files
mschmickingclaude
andcommitted
feat(cli): sweep the adapter markers a deleted script leaves behind
The javascript adapter keeps two bookkeeping states beside every script, on every javascript instance rather than only the one that runs it: javascript.<n>.scriptEnabled.<id> javascript.<n>.scriptProblem.<id> Both are created by the adapter's load(), which calls createActiveObject and createProblemObject before prepareScript checks common.engine to decide whether this instance should run the script at all. Every instance runs load() for every non-global script at startup and again on every source change, so all of them hold a pair for every script. Deletion, though, is gated on the engine: only the instance that owned the script at the moment it was deleted removes its own pair. Every other instance keeps one for a script that no longer exists, js-controller warns about it for the life of the system, and nothing in ioBroker ever collects it. This is independent of who deleted the script — the Admin UI leaves the identical residue. remove, rename and move now sweep both markers of the id they just deleted. The sweep runs after the object is gone and after the trash backup, and is best-effort: it warns rather than turning a completed delete into a failure. Order is enforced in ObjectsApi.deleteScriptMarker — value first, object second, never the reverse, because an object deleted out from under a surviving value is exactly the orphan being cleaned up. (The adapter's own cleanup gets this backwards, which is a likely source of the "state has no object" warnings.) remove also stops refusing an id whose script is already gone when markers remain, since otherwise pre-existing ones cannot be cleared by anything. It sweeps them, touches nothing else, and keeps the local file. doctor gains a read-only `markers` check that counts both kinds, names the orphans, and warns rather than fails — nothing here is broken. Both kinds are handled through MARKER_KINDS in types.ts rather than a pattern repeated per call site: an earlier draft covered only scriptEnabled and left eight orphaned scriptProblem states on a live instance while doctor reported it clean. Verified against ioBroker.javascript v8.9.2 on a live three-instance system, and covered by tests against the in-process fake server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 84dc84a commit 1d24564

12 files changed

Lines changed: 1014 additions & 38 deletions

AGENTS.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,22 @@ instruction from the user:
3131
4. **Deletion is explicit.** Only `remove`, `rename` and `move` delete anything, each
3232
requires `--yes`, and each writes the full object JSON to `.iobroker-sync/trash/`
3333
_before_ deleting. A failed backup aborts the operation.
34+
35+
The one thing they delete without backing it up is the pair of adapter markers
36+
belonging to the script being deleted — `javascript.<n>.scriptEnabled.<id>` and
37+
`javascript.<n>.scriptProblem.<id>` — and only after that script is gone. Those are
38+
adapter-generated derived state (`common.enabled` and `common.engine` are already in
39+
the trash copy), so there is nothing in them to lose. See `cleanUpScriptMarkers` in
40+
`commands/remove.ts` for why they have to be swept at all, and
41+
`ObjectsApi.deleteScriptMarker` for the ordering rule (value first, object second,
42+
never the reverse). The sweep is best-effort: it warns, it never fails the command.
43+
44+
**Both kinds or neither.** The adapter creates and deletes the two together, so code
45+
that handles only `scriptEnabled` cleans up half a mess and reports success. That is
46+
not hypothetical: the first version of this sweep shipped that way and left eight
47+
orphaned `scriptProblem` states on a live instance while `doctor` called it clean.
48+
`MARKER_KINDS` in `types.ts` is the single list; anything iterating markers iterates it.
49+
3450
5. **Copy-then-delete must verify first.** ioBroker has no native rename/move, so both
3551
are implemented as copy-verify-delete. The verification compares the actual source
3652
text. Checking only that "something exists at the new id" is not verification — a

README.md

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -108,29 +108,29 @@ If `logs` shows nothing, that is usually the adapter's own log level rather than
108108

109109
**Sync**
110110

111-
| Command | Description |
112-
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
113-
| `init` | Write `.iobroker-sync.json`, verify the connection, create the script folder. Asks interactively when run without flags. `--types` also sets up TypeScript definitions. |
114-
| `types` | Set up editor intellisense (`log`, `schedule`, ...). `--force`, `--offline`. |
115-
| `login` / `logout` | Save or remove the password for this instance. Never stored in the project. |
116-
| `trust` | Accept the instance's current TLS certificate. Only needed after it changes. `--yes` skips the prompt. |
117-
| `doctor` | Check config, certificate, login, connection and a live round-trip, and say which one is wrong. Read-only, never prompts. Run this first when something looks broken. |
118-
| `pull [pattern]` | Download scripts to disk. Never deletes or overwrites local files. |
119-
| `push [pattern]` | Upload locally modified scripts. Never deletes remote objects. |
120-
| `status` | Show what changed, locally and remotely. |
121-
| `diff [pattern]` | Unified diff of local vs server. `--against <snapshot>` compares with a backup instead. |
122-
| `watch` | Push on save. `--pull` also applies remote changes. |
123-
| `logs [pattern]` | Stream the server log. `--level`, `--limit`. Read-only. |
124-
| `backup [pattern]` | Snapshot every script — source _and_ full object — to `.iobroker-sync/backup/<timestamp>/`. Read-only against the server. |
111+
| Command | Description |
112+
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
113+
| `init` | Write `.iobroker-sync.json`, verify the connection, create the script folder. Asks interactively when run without flags. `--types` also sets up TypeScript definitions. |
114+
| `types` | Set up editor intellisense (`log`, `schedule`, ...). `--force`, `--offline`. |
115+
| `login` / `logout` | Save or remove the password for this instance. Never stored in the project. |
116+
| `trust` | Accept the instance's current TLS certificate. Only needed after it changes. `--yes` skips the prompt. |
117+
| `doctor` | Check config, certificate, login, connection, a live round-trip and leftover adapter markers, and say which one is wrong. Read-only, never prompts. Run this first when something looks broken. |
118+
| `pull [pattern]` | Download scripts to disk. Never deletes or overwrites local files. |
119+
| `push [pattern]` | Upload locally modified scripts. Never deletes remote objects. |
120+
| `status` | Show what changed, locally and remotely. |
121+
| `diff [pattern]` | Unified diff of local vs server. `--against <snapshot>` compares with a backup instead. |
122+
| `watch` | Push on save. `--pull` also applies remote changes. |
123+
| `logs [pattern]` | Stream the server log. `--level`, `--limit`. Read-only. |
124+
| `backup [pattern]` | Snapshot every script — source _and_ full object — to `.iobroker-sync/backup/<timestamp>/`. Read-only against the server. |
125125

126126
**Lifecycle**
127127

128-
| Command | Description |
129-
| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
130-
| `list` | All scripts with instance and enabled state. |
131-
| `start` / `stop` / `restart` `<pattern>` | Toggle `common.enabled`. |
132-
| `new <path>` | Create a new script (disabled) plus any missing folders. |
133-
| `rename` / `move` / `remove` | Destructive. Require `--yes` and back up the object first. `remove` keeps the local file unless `--delete-local`. |
128+
| Command | Description |
129+
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
130+
| `list` | All scripts with instance and enabled state. |
131+
| `start` / `stop` / `restart` `<pattern>` | Toggle `common.enabled`. |
132+
| `new <path>` | Create a new script (disabled) plus any missing folders. |
133+
| `rename` / `move` / `remove` | Destructive. Require `--yes` and back up the object first. `remove` keeps the local file unless `--delete-local`. All three also clean up the `scriptEnabled`/`scriptProblem` states the old id leaves behind on every javascript instance. |
134134

135135
`--dry-run`, `--verbose`, `--json` and `-C <dir>` are global and work with every command.
136136
When in doubt, `--dry-run` shows what would happen and changes nothing.

docs/TROUBLESHOOTING.md

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
Things that look like bugs and are not. See the [README](../README.md) for the overview.
44

55
**Start with `iob-sync doctor`.** It checks the config, the certificate, the login, the
6-
connection and a live round-trip, and names the one that is wrong — including the two
7-
cases below, which are the ones that reliably send people down the wrong path. It is
8-
read-only and never prompts, so it is safe to run at any time.
6+
connection, a live round-trip and the adapter's leftover script markers, and names the one
7+
that is wrong — including the cases below, which are the ones that reliably send people
8+
down the wrong path. It is read-only and never prompts, so it is safe to run at any time.
99

1010
## Commands time out, but the connection "works"
1111

@@ -86,6 +86,48 @@ a script or move it to another javascript instance, it cannot — that is delibe
8686
sync bug cannot stop a running script. Use `start` / `stop` for `enabled`; instance moves
8787
must be done in Admin.
8888

89+
## `doctor` warns about orphaned markers
90+
91+
Nothing is broken, and no script is affected.
92+
93+
The javascript adapter keeps two bookkeeping states beside every script, on **every**
94+
javascript instance — not only the one that runs it:
95+
96+
```
97+
javascript.<n>.scriptEnabled.<script id>
98+
javascript.<n>.scriptProblem.<script id>
99+
```
100+
101+
Both are created by the adapter's `load()`, which calls `createActiveObject` and
102+
`createProblemObject` _before_ `prepareScript` checks `common.engine` to decide whether
103+
this instance should actually run the script. Every instance runs `load()` for every
104+
non-global script at startup, and again on every source change. So on a three-instance
105+
system, ten scripts mean sixty of these states, and that is normal.
106+
107+
Deletion, however, _is_ gated on the engine: only the instance that owned the script at
108+
the moment it was deleted removes its own pair. The pairs on the other instances stay for
109+
the life of the system, js-controller complains about them, and nothing in ioBroker ever
110+
collects them.
111+
112+
This has nothing to do with who did the deleting — the Admin UI leaves exactly the same
113+
residue. Verified against ioBroker.javascript v8.9.2.
114+
115+
`iob-sync doctor` lists them by id, both kinds. To clear one script's leftovers:
116+
117+
```bash
118+
iob-sync remove script.js.diag.retired-check --yes
119+
```
120+
121+
`remove` sweeps the markers even when the script itself is already gone from the server —
122+
in that case it deletes nothing else and leaves your local file alone. `rename` and `move`
123+
sweep the old id's markers as they go, so this does not accumulate from normal use.
124+
125+
One detail worth knowing if you clean these up by hand: delete the **state value first,
126+
then the object**. The reverse order leaves a value with no object behind it, which is the
127+
shape js-controller actually warns about — and it is the order the adapter's own cleanup
128+
uses (`delObject` then `delState`), so that warning may well have come from ioBroker
129+
itself rather than from anything you did.
130+
89131
## A push is refused as a conflict
90132

91133
Local and remote both changed since the last sync. Inspect with `iob-sync diff`, then

src/client/objects.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66
import {
77
FolderObject,
88
IoBrokerObject,
9+
MARKER_KINDS,
10+
MarkerKind,
911
ObjectsApi,
1012
ObjectViewResult,
1113
ScriptCommon,
14+
ScriptMarkerEntry,
1215
ScriptObject,
1316
SocketClient,
1417
} from '../types';
@@ -17,6 +20,43 @@ const SCRIPT_NAMESPACE = 'script.js.';
1720
// Verified endkey used by the Admin UI itself to bound a getObjectView range scan.
1821
const VIEW_ENDKEY = `${SCRIPT_NAMESPACE}香`;
1922

23+
const JS_NAMESPACE = 'javascript.';
24+
25+
/** `javascript.*.<kind>.*` — every instance's markers of one kind, for every script. */
26+
function markerPattern(kind: MarkerKind): string {
27+
return `${JS_NAMESPACE}*.${kind}.*`;
28+
}
29+
30+
/**
31+
* Splits a marker id into the script it belongs to and which kind it is:
32+
* `javascript.2.scriptEnabled.common.garage` -> `script.js.common.garage`, scriptEnabled.
33+
*
34+
* Returns null for anything that is not one — including ids with something other than
35+
* a bare instance number in front of the kind, so that a state someone else parked
36+
* under `javascript.` cannot be mistaken for ours and deleted.
37+
*/
38+
export function parseMarkerId(stateId: string): { scriptId: string; kind: MarkerKind } | null {
39+
if (!stateId.startsWith(JS_NAMESPACE)) {
40+
return null;
41+
}
42+
for (const kind of MARKER_KINDS) {
43+
const infix = `.${kind}.`;
44+
const kindAt = stateId.indexOf(infix);
45+
if (kindAt === -1) {
46+
continue;
47+
}
48+
const instance = stateId.slice(JS_NAMESPACE.length, kindAt);
49+
if (!/^\d+$/.test(instance)) {
50+
continue;
51+
}
52+
const suffix = stateId.slice(kindAt + infix.length);
53+
if (suffix) {
54+
return { scriptId: `${SCRIPT_NAMESPACE}${suffix}`, kind };
55+
}
56+
}
57+
return null;
58+
}
59+
2060
export class AdminObjectsApi implements ObjectsApi {
2161
constructor(private readonly socket: SocketClient) {}
2262

@@ -111,4 +151,89 @@ export class AdminObjectsApi implements ObjectsApi {
111151
async deleteObject(id: string): Promise<void> {
112152
await this.socket.emit('delObject', [id]);
113153
}
154+
155+
/**
156+
* Reads the value side and the object side separately and unions them, because a
157+
* marker can exist as either half alone and the halves are what we need to tell apart.
158+
*
159+
* One half failing is tolerated: `getStates` and `getForeignObjects` are separate
160+
* commands with separate histories across Admin versions, and a partial answer here
161+
* still beats reporting nothing. Only a total failure propagates.
162+
*/
163+
async listScriptMarkers(): Promise<ScriptMarkerEntry[]> {
164+
const perKind = await Promise.all(MARKER_KINDS.map((kind) => this.listMarkersOfKind(kind)));
165+
return perKind.flat().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
166+
}
167+
168+
private async listMarkersOfKind(kind: MarkerKind): Promise<ScriptMarkerEntry[]> {
169+
const pattern = markerPattern(kind);
170+
const [values, objects] = await Promise.allSettled([
171+
this.readMarkerValues(pattern),
172+
this.socket.emit<Record<string, unknown> | null>('getForeignObjects', [pattern, 'state']),
173+
]);
174+
175+
if (values.status === 'rejected' && objects.status === 'rejected') {
176+
throw values.reason;
177+
}
178+
179+
const valueIds = new Set(values.status === 'fulfilled' ? Object.keys(values.value ?? {}) : []);
180+
const objectIds = new Set(
181+
objects.status === 'fulfilled' ? Object.keys(objects.value ?? {}) : [],
182+
);
183+
184+
const entries: ScriptMarkerEntry[] = [];
185+
for (const id of new Set([...valueIds, ...objectIds])) {
186+
const parsed = parseMarkerId(id);
187+
// The pattern is a wildcard match, so it can catch ids that merely look the part
188+
// (`javascript.0.scriptEnabled` with no suffix, or a non-numeric instance).
189+
// parseMarkerId is the authority on what is really ours.
190+
if (parsed?.kind !== kind) {
191+
continue;
192+
}
193+
entries.push({
194+
id,
195+
scriptId: parsed.scriptId,
196+
kind,
197+
hasValue: valueIds.has(id),
198+
hasObject: objectIds.has(id),
199+
});
200+
}
201+
return entries;
202+
}
203+
204+
/**
205+
* `getStates` is the current command; `getForeignStates` is its deprecated alias and
206+
* the only one older Admin builds answer. Trying both costs one extra round trip on
207+
* instances that need it and nothing on instances that do not.
208+
*/
209+
private async readMarkerValues(pattern: string): Promise<Record<string, unknown> | null> {
210+
try {
211+
return await this.socket.emit<Record<string, unknown> | null>('getStates', [pattern]);
212+
} catch {
213+
return this.socket.emit<Record<string, unknown> | null>('getForeignStates', [pattern]);
214+
}
215+
}
216+
217+
async deleteScriptMarker(entry: ScriptMarkerEntry): Promise<void> {
218+
if (!parseMarkerId(entry.id)) {
219+
// Belt and braces: this API is reachable from command code, and the whole point
220+
// of it is deleting things, so it refuses anything outside its own namespace.
221+
throw new Error(`Refusing to delete "${entry.id}": not a script marker.`);
222+
}
223+
224+
// Value first. If this throws we stop here on purpose — deleting the object while
225+
// the value survives would manufacture the exact orphan this code exists to remove.
226+
if (entry.hasValue) {
227+
await this.socket.emit('delState', [entry.id]);
228+
}
229+
230+
if (entry.hasObject) {
231+
// Admin's delState is documented to take the object with it. Verify rather than
232+
// assume: on an instance where it does not, the object has to go separately.
233+
const remaining = await this.socket.emit<IoBrokerObject | null>('getObject', [entry.id]);
234+
if (remaining) {
235+
await this.socket.emit('delObject', [entry.id]);
236+
}
237+
}
238+
}
114239
}

0 commit comments

Comments
 (0)