An unattended automation script is hard not because of writing it, but because of keeping it alive. This article is not about any specific business flow; it is about one question only: why do scripts crash mid-run, and how do you engineer them so they do not? Throughout, EasyClick serves as the concrete implementation example — a no-root Android phone automation platform whose scripts are written in JavaScript (official docs). Every function name used below comes from its public documentation, so you can verify each one yourself.
1. Where Scripts Die: Four Typical Failure Modes
Let us first reconstruct the scene. Unattended scripts die in ways that look endless — the log stops at a line, the UI stays on a page, the floating window disappears, the screen goes dark — but when you group them, almost all of them fall into four failure modes:
| Failure mode | What you see | Root cause in one sentence |
|---|---|---|
| Empty click | The log says “clicked”, but the UI does not respond and the task stalls at the next step | The script acted before the element appeared / became clickable |
| Lookup failure | The selector repeatedly matches nothing; the same step times out again and again | App update, layout change, or text change invalidated the old lookup condition |
| Service reclaimed | The accessibility service drops; nodes, clicks, and input all stop working | Memory pressure or vendor power-saving strategy reclaimed the background service |
| Environment change | The same script works on phone A but misbehaves on phone B | Resolution / DPI / display-scaling differences broke hardcoded coordinates |
The key conclusion up front: most mid-run crashes are not mysterious. A script crashes when it assumes “the UI is stable, the service is online, and the coordinates never change” — and on real phones, all four assumptions get broken at some point. Stability engineering therefore has essentially one job: for every assumption that can break, prepare a “detect + recover” path.
Let us break down each failure, with the principle and the engineering fix.
2. Failure 1: Empty Clicks — Operating Before the Element Appears
The principle: a script’s speed is a double-edged sword. A human taps only after seeing the button; a script has no idea how far the page has loaded. The most naive way is to sleep(3000) and then tap — if the network is slow, an extra ad slot appears, or an animation runs half a second longer, the element is not there yet and the click lands on empty space. “Recorded scripts run but are unstable” — this is the number one source of that.
The fix: replace “wait a fixed number of seconds” with “wait for the element to appear”. EasyClick’s selector provides waitExistNode(milliseconds): it waits at most N milliseconds, returns the moment the element appears, and only returns null on timeout (selector and node docs):
function main() {
// Wait for the "Claim Now" button to appear, at most 5 seconds; return as soon as it shows up
let node = text("立即领取").waitExistNode(5000);
if (node) {
node.click(); // random click within the node area
} else {
loge("wait timeout: target node did not appear within 5 seconds");
}
}
main();
If you do not want to use the ready-made wait function, hand-rolling a “polling” loop works on the same principle — check for the node every 500ms until it appears or the attempt cap is reached. Understanding this loop means understanding the essence of every wait-type API:
function main() {
let node = null;
// Polling instead of fixed sleep: check every 500ms, at most 10 times
for (let i = 0; i < 10; i++) {
node = text("立即领取").getOneNodeInfo(0); // 0 means no wait, check once immediately
if (node) {
break;
}
sleep(500);
}
if (node) {
node.click();
} else {
loge("node not found after 10 polling attempts");
}
}
main();
Comparison of the two approaches:
| Approach | Behavior | Issue |
|---|---|---|
sleep(3000) then act |
Waits a fixed 3 seconds | Either not enough (element not out yet) or wasted time |
waitExistNode(5000) |
Waits until the element appears, at most 5 seconds | Requires the node system to be available (full functionality in accessibility/proxy mode) |
Polling getOneNodeInfo(0) + sleep(500) |
Checks every 500ms, up to N times | Good when you want to control the rhythm yourself; same principle as waitExistNode |
Two extra details: first, use has() for “does it exist” and waitExistNode() for “wait until it appears” — they have different jobs; second, a node you obtained may become invalid after the page redraws, so check validity with the node’s isValid() and re-fetch when it is stale. Make “wait for the element, then act” a habit and the first guarantee of script stability is in place.
3. Failure 2: Element Lookup Failure — App Updates and Layout Changes
The principle: a selector matches attributes, and attributes change. An app release renames resource IDs, changes button text, or moves a control from container A into container B — and the old selector no longer matches. A script that bets on a single attribute is essentially betting that “this app will never change”.
The fix: node-first, with combined attributes. Lookup strategies ranked by stability:
| Priority | Strategy | Notes |
|---|---|---|
| 1 | id (resource ID) | Most stable, but may change after obfuscation or refactoring |
| 2 | text / desc | Semantically stable; breaks when the copy changes — fall back to textMatch / descMatch regexes |
| 3 | Combined attributes + cascading | Match text, class name, checked state, etc. together; or select the parent first, then the child |
| 4 | Coordinate fallback | Only when the node system is unavailable; pair with random clicks within the node area |
An example of combined attributes — constraining “text + class name + checked state” at once is far more robust than a single attribute:
function main() {
// Combined attributes: text (regex) + checked state + class name, all must match
let selector = textMatch(".*选择器.*")
.checked(true)
.clz("android.widget.CheckBox");
let node = selector.getOneNodeInfo(3000);
if (node) {
node.click();
} else {
loge("primary strategy failed, entering fallback strategy");
}
}
main();
Two more mechanisms to combine: cascading selection — when you cannot select the target directly, select the parent first and then the child via .child(); regex matching — textMatch, idMatch, clzMatch and the like tolerate small changes in copy and IDs. Before release, walk through the lookup conditions page by page in the node panel of the dev tool — far faster than guessing from logs after going live.
Key conclusion: use node positioning whenever you can, never coordinates; combine multiple attributes instead of betting on a single one. App updates are the norm — design for “lookup will break after an update” as something that is guaranteed to happen, and the script will survive version iterations.
4. Failure 3: Accessibility Service Reclaimed by the System — Vendors Kill Services Under Memory Pressure
The principle: Android reclaims background services when memory is tight, and Chinese vendors add even more aggressive “power-saving management” and “shady mode” (神隐模式) — the official FAQ even documents a case where a script stops on its own after about 20 minutes and the floating window disappears, caused precisely by shady/power-saving mode (FAQ docs). Once the accessibility service is reclaimed, nodes, clicks, and input all stop working, while the script may keep “happily” running — the symptom is “the script has not exited, but nothing is happening”.
The fix: detect + self-heal. Three global functions are the core (global module docs):
| Function | What it does |
|---|---|
isServiceOk() |
Whether the automation service is healthy, returns true/false |
startEnv() |
Starts the automation service environment |
daemonEnv(true) |
Guards the automation environment, trying to keep the automation service online (EC 6.7.0+) |
The official docs (AI-assisted programming docs) include an autoServiceStart template — a “check → start → recheck” loop — which works directly as a self-healing loop at the script entry:
// Official autoServiceStart template: loop-check, start the service if it is offline, at most time times
function autoServiceStart(time) {
for (let i = 0; i < time; i++) {
if (isServiceOk()) {
return true;
}
startEnv();
sleep(1000);
}
return isServiceOk();
}
function main() {
// At the entry, make sure the service is ready first, with 3 attempts
if (!autoServiceStart(3)) {
loge("service start failed");
return;
}
// Business logic...
}
main();
The advanced approach is to listen for service-reclaim events and bring the service back automatically. The global module supports listening for “accessibility service destroyed / interrupted” events (acc-service-destroy / acc-service-interrupt); run one self-heal pass whenever an event arrives:
function main() {
autoServiceStart(3);
// When the service is destroyed/interrupted by the system, try to recover automatically
observeEvent("acc-service-destroy", function (key, data) {
loge("accessibility service destroyed, starting self-heal: " + data);
autoServiceStart(3);
});
observeEvent("acc-service-interrupt", function (key, data) {
loge("accessibility service interrupted, starting self-heal: " + data);
autoServiceStart(3);
});
// Business logic...
}
main();
The device side must cooperate too: whitelist EC in the vendor’s background settings and disable power-saving/shady-mode restrictions; EC’s system settings support “auto-start on boot” (the auto_start_service parameter of setECSystemConfig), so the script can resume automatically after a device reboot. Service keep-alive is “script-side self-healing + device-side permission” — walk on both legs, or neither lasts long.
5. Failure 4: Resolution and Device Model Differences
The principle: physical resolution, DPI, notches/punch-holes, and display scaling all differ, so the same (x, y) is a different physical position on different models. EasyClick supports Android 5.0 through the latest systems — the broader the coverage, the more seriously you should treat the device-variation trap.
The fix: position by node, not by coordinate. Node positioning is natively “coordinate-adaptive” — a node’s bounds is the boundary the system computes for the current screen, so switching models or resolutions requires zero script changes. Better still, the node’s click() is itself a random click within the node area (the doc’s own words): the tap lands inside the node region at a slightly different position each time, which both removes the need to hand-compute coordinates and makes the operation less mechanical.
When coordinates are unavoidable (swipes, gestures, etc.), use relative coordinates — converted proportionally to the screen width and height instead of hardcoded pixels:
function main() {
let w = device.getScreenWidth();
let h = device.getScreenHeight();
// Swipe from 80% height to 20% height; works across devices
swipeToPoint(w / 2, h * 0.8, w / 2, h * 0.2, 500);
}
main();
Coordinates vs. nodes, in one table:
| Dimension | Hardcoded coordinates | Node positioning |
|---|---|---|
| Resolution changes | Everything breaks | Adaptive (bounds computed for the current screen) |
| Device differences | A separate set of coordinates per device | One script works everywhere |
| Layout tweaks | Off by a pixel and you tap the wrong thing | Accurate as long as the node is there |
| Operation pattern | Same pixel every time | Random within the node area, position not identical |
Key conclusion: coordinates are “the last fallback”, not “the default plan”. Treat “writing coordinates” as an operation that needs extra justification, and the script’s cross-device stability will improve immediately.
6. Safety-Net Design: Retry, Timeout, Logging, and Screenshot Evidence
The four failures above are about “how to prevent”. But engineering also has to accept a reality: any defense line can be breached. So the last layer of design is the safety net — write “failure” as a normal branch, not as an accident.
- Retry: wrap critical operations in a retry, try again at intervals on failure, and only enter the failure branch after the attempt cap:
// Generic retry: fn returning a truthy value means success, retry at most times times, 1 second apart
function retry(times, fn) {
for (let i = 1; i <= times; i++) {
try {
let result = fn();
if (result) {
return result;
}
} catch (e) {
loge("exception on attempt " + i + ": " + e);
}
sleep(1000);
}
return null;
}
function main() {
let node = retry(3, function () {
return text("立即领取").waitExistNode(3000);
});
if (node) {
node.click();
} else {
loge("still failing after 3 retries, entering failure-handling flow");
}
}
main();
- Timeout: every wait and retry needs an upper bound. The timeout parameter of
waitExistNode(5000)and the retry-count cap are the same principle — never allow the script to wait indefinitely. - Logging: persist logs with
setSaveLogEx(true, "/sdcard/aaa/", 1024 * 1024, "testlog"), record the scene in failure branches withloge, and registersetExceptionCallbackto listen for abnormal script termination. Logs are the first-hand material for post-mortems. - Screenshot evidence: in failure branches, save a screenshot of the screen locally; matched against the logs, it pinpoints the failing step within minutes.
- Alerting: for unattended scenarios, push failure messages to a DingTalk group with
sendDingDingMsg(url, secret, msg, atMobile, atAll)so a human is notified immediately. - Restart fallback: when an exception is hard to recover in place, restart the script with
restartScript(null, true, 3)(the docs explicitly warn: this method is powerful, so control carefully whether auto-restart is appropriate).
Key conclusion: stability = prevent (previous four sections) + contain (this section). Write a “failure branch” for every point that can fail, and the script moves from “luck-based” to “predictable”.
7. Stability Checklist (Review Before Release)
Going through the table below before release is far more efficient than firefighting after going live:
| Check item | Requirement | Section |
|---|---|---|
| Does every operation wait for the node first | Use waitExistNode / getOneNodeInfo(timeout), never a bare sleep | 2 |
| Any fixed-sleep dependencies | Only for simulating human rhythm | 2 |
| Lookup relying on a single attribute | At least combined attributes; regex/cascading when needed | 3 |
| Null return values handled | Null-check every node; go to fallback or failure branch when null | 3 |
| Service state checked at entry | autoServiceStart loop to start and verify | 4 |
| Service self-healing configured | observeEvent listening for acc-service-destroy / acc-service-interrupt | 4 |
| Device side allows background running | Disable power-saving/shady-mode limits, whitelist in background, configure auto-start on boot | 4 |
| Any hardcoded coordinates | Replace all with node positioning or relative coordinates | 5 |
| Multi-device validation done | Run through mainstream resolutions and system versions | 5 |
| Every wait and retry has an upper bound | Timeout parameters and attempt caps in place | 6 |
| Logs and screenshots kept | setSaveLogEx + screenshot in failure branches | 6 |
| Exception callback and alerting configured | setExceptionCallback + sendDingDingMsg | 6 |
8. FAQ
Q1: My script stops by itself after about ten minutes and the floating window disappears. Why?
A: The most common cause is vendor power-saving modes killing background processes (vivo Power Management and Xiaomi Shady Mode are known culprits); the second is the system reclaiming the accessibility service under memory pressure. Two layers of countermeasures: on the device, whitelist the EC app in the background and disable power-saving restrictions; in the script, self-heal with isServiceOk() checks and startEnv() restarts.
Q2: What is the difference between waitExistNode and a fixed sleep?
A: sleep(3000) means “wait 3 seconds whether or not the element is there”; waitExistNode(5000) means “wait until the element appears, at most 5 seconds” and continues the moment it shows up. A fixed wait is either too short or a waste of time — prefer waitExistNode for wait-type operations.
Q3: After an app update, the script cannot find a control. What should I do? A: First make the lookup update-proof: combine multiple attributes (id + text + class name) instead of a single one; use textMatch regexes where the copy may change; use cascading selectors (parent first, then child) when the parent structure changes. Finally keep coordinates as a fallback and log everything, so after an update you can quickly see which level of the lookup failed.
Q4: Can the script recover automatically after the system reclaims the accessibility service?
A: Not by default — the script has to heal itself: check with isServiceOk() and restart with startEnv(). The official autoServiceStart template is exactly such a “check-start-recheck” loop; you can also listen for the acc-service-destroy / acc-service-interrupt events and bring the service back up automatically.
Q5: The same script behaves differently on different phones. How do I handle this? A: Prefer node-based positioning, which adapts to resolution differences natively; when coordinates are unavoidable, convert them proportionally to screen width and height instead of hardcoding pixels; run the stability checklist across mainstream resolutions and models before release.
Q6: After a script dies, how do I reconstruct which step went wrong? A: Leave evidence: persist logs with setSaveLogEx, record abnormal termination with setExceptionCallback, save a screenshot in the failure branch, and send alerts with sendDingDingMsg when needed. Matching logs against screenshots quickly pinpoints the failing step.
Q7: Is a fixed sleep completely banned? A: No. Prefer waitExistNode when waiting for an element; sleep fits “human-rhythm” scenarios such as simulating a human interval between two taps. The principle: use conditional waiting over time-based waiting whenever you can.
Q8: What if the script gets stuck waiting forever? A: Put a cap on every wait: pass a timeout in milliseconds to waitExistNode, set a retry-count limit on loops; combine with setExceptionCallback to catch exceptions and restartScript to restart the script when necessary. For unattended operation, add run monitoring that restarts the script when it times out.
Q9: Why click randomly within the node area? A: First, coordinate adaptivity — the node area is computed by the system for the current screen, so switching devices requires no script changes. Second, less mechanical behavior — each tap lands at a slightly different position instead of the same pixel every time.
Q10: How is a stability check different from functional testing? A: Functional testing verifies that a flow can run through; a stability check verifies that the script will not die during long unattended runs, covering engineering items like waiting, timeouts, service reclaim, exception branches, and log evidence. It is the last gate before release.
About EasyClick: An AI-agent platform for phone automation covering three ecosystems — Android (no root), iOS (no jailbreak), and HarmonyOS Next — offering script development, Apple device cluster control, local central-control screen mirroring, and a cloud control system. → Explore all products
Ready to build it for real?
Every approach in this article can be built on the EasyClick phone automation platform — full documentation, developer tools and cluster/cloud-control products, free to try.