| … | … | @@ -1,5 +1,7 @@ |
| 1 | 1 | package to.favela.android |
| 2 | 2 | |
| 3 | import android.app.AlarmManager | |
| 4 | import android.app.PendingIntent | |
| 3 | 5 | import android.content.Context |
| 4 | 6 | import android.content.Intent |
| 5 | 7 | import android.net.ConnectivityManager |
| … | … | @@ -12,6 +14,7 @@ import com.facebook.react.bridge.ReactApplicationContext |
| 12 | 14 | import com.facebook.react.bridge.ReactContextBaseJavaModule |
| 13 | 15 | import com.facebook.react.bridge.ReactMethod |
| 14 | 16 | import com.facebook.react.modules.core.DeviceEventManagerModule |
| 17 | import kotlin.system.exitProcess | |
| 15 | 18 | |
| 16 | 19 | /** |
| 17 | 20 | * The JS handle on NodeService. Two verbs and one event — everything else about the node's lifecycle is |
| … | … | @@ -33,6 +36,57 @@ class FavelaNodeModule(private val ctx: ReactApplicationContext) : ReactContextB |
| 33 | 36 | * decides the entire suspend policy from this boolean, so a `true` that means "the request was |
| 34 | 37 | * accepted" would be the same class of lie as a health check that only proves a port is open. |
| 35 | 38 | */ |
| 39 | /** | |
| 40 | * Restart the whole PROCESS, and mean it. | |
| 41 | * | |
| 42 | * ⛔ WHY NOT `stopNode(); boot()`. That is what "restart" did, and it restarts a THREAD inside a | |
| 43 | * live process: `worklet.terminate()` then a fresh worklet. Measured on a handset — a staged bundle | |
| 44 | * pressed through that path never became the running one, while `adb shell am force-stop` followed | |
| 45 | * by a relaunch applied it every time. The difference between those two is the process, so the | |
| 46 | * process is what has to go. | |
| 47 | * | |
| 48 | * ⛔ AND IT IS NOT A LUXURY. `chooseBundle` runs at worklet start and nowhere else, so a restart | |
| 49 | * that does not really restart means NO update can ever be applied from the device — every one | |
| 50 | * would need a cable and somebody at a desk. That is not an update mechanism. | |
| 51 | * | |
| 52 | * How: schedule the launcher Activity a moment in the future, then exit. The alarm survives our | |
| 53 | * death — that is the whole point of handing it to the OS — and the new process starts clean, with | |
| 54 | * no dlopen'd addons, no half-torn-down corestore and no lingering swarm sockets from the old one. | |
| 55 | * | |
| 56 | * ⚠ `exitProcess`, not `finishAffinity()`: the point is to drop every native handle this process | |
| 57 | * holds. Anything gentler leaves exactly the state that made the thread-level restart useless. | |
| 58 | * | |
| 59 | * ⚠ Resolves BEFORE exiting, so the page that asked can show "restarting" rather than watching its | |
| 60 | * bridge die mid-call and reporting a failure for something that worked. | |
| 61 | */ | |
| 62 | @ReactMethod | |
| 63 | fun restartProcess(delayMs: Double, promise: Promise) { | |
| 64 | try { | |
| 65 | val intent = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName) | |
| 66 | if (intent == null) { | |
| 67 | promise.reject("no_launcher", "this package has no launcher activity to come back to") | |
| 68 | return | |
| 69 | } | |
| 70 | // NEW_TASK + CLEAR_TASK: come back to a fresh task rather than whatever was on the stack when | |
| 71 | // the process died, which would be a screen belonging to code that no longer exists. | |
| 72 | intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) | |
| 73 | val pending = PendingIntent.getActivity( | |
| 74 | ctx, RESTART_REQUEST, intent, | |
| 75 | PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE | |
| 76 | ) | |
| 77 | val at = System.currentTimeMillis() + delayMs.toLong().coerceAtLeast(150L) | |
| 78 | val alarms = ctx.getSystemService(Context.ALARM_SERVICE) as AlarmManager | |
| 79 | // ⚠ RTC_WAKEUP and set(), not setExact: this needs to happen "in a moment", not at a precise | |
| 80 | // instant, and exact alarms need a permission we have no reason to ask for. | |
| 81 | alarms.set(AlarmManager.RTC_WAKEUP, at, pending) | |
| 82 | promise.resolve(true) | |
| 83 | // Give the bridge a beat to deliver the resolve before the process stops existing. | |
| 84 | Handler(Looper.getMainLooper()).postDelayed({ exitProcess(0) }, 120) | |
| 85 | } catch (e: Throwable) { | |
| 86 | promise.reject("restart_failed", e.message, e) | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 36 | 90 | @ReactMethod |
| 37 | 91 | fun start(promise: Promise) { |
| 38 | 92 | try { |
| … | … | @@ -153,5 +207,8 @@ class FavelaNodeModule(private val ctx: ReactApplicationContext) : ReactContextB |
| 153 | 207 | const val EVENT_SERVICE_STOPPED = "favela-node-service-stopped" |
| 154 | 208 | private const val START_TIMEOUT_MS = 5000L |
| 155 | 209 | private const val POLL_MS = 50L |
| 210 | // A fixed request code so a second restart replaces the first pending relaunch rather than | |
| 211 | // queueing another one — two alarms would start the app twice. | |
| 212 | private const val RESTART_REQUEST = 4242 | |
| 156 | 213 | } |
| 157 | 214 | } |
| … | … | @@ -15,7 +15,7 @@ |
| 15 | 15 | import React, { useEffect, useState, useCallback, useRef } from 'react' |
| 16 | 16 | import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, Pressable } from 'react-native' |
| 17 | 17 | import { WebView } from 'react-native-webview' |
| 18 | import { startNode, stopNode } from '../lib/host/worklet' | |
| 18 | import { startNode, applyUpdate } from '../lib/host/worklet' | |
| 19 | 19 | import { parseShellMessage } from '../lib/host/shell-handoff' |
| 20 | 20 | |
| 21 | 21 | /** The splash and every failure share one layout — a phone should not have two ways to say "wait". */ |
| … | … | @@ -56,7 +56,10 @@ export default function App () { |
| 56 | 56 | ) |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | return <Surface client={client} onRestart={async () => { stopNode(); await boot() }} /> | |
| 59 | // ⛔ A PROCESS restart, not a thread one. `stopNode(); boot()` is what this used to do, and a staged | |
| 60 | // bundle pushed through it never became the running bundle — only killing the process applied it. | |
| 61 | // chooseBundle runs at worklet start and nowhere else, so this is the entire update mechanism. | |
| 62 | return <Surface client={client} onRestart={async () => { await applyUpdate() }} /> | |
| 60 | 63 | } |
| 61 | 64 | |
| 62 | 65 | /** |
| … | … | @@ -0,0 +1,3 @@ |
| 1 | import { addonsDirFor } from './lib/host/loader-core.js' | |
| 2 | import { preloadAddons } from './lib/host/preload-addons.js' | |
| 3 | export { addonsDirFor, preloadAddons } |
| … | … | @@ -0,0 +1,13 @@ |
| 1 | import process from 'bare-process' | |
| 2 | const storage = process.argv[process.argv.length - 1] | |
| 3 | const m = await import('./lib/node.js') | |
| 4 | const st = await m.boot(storage) | |
| 5 | console.log('BOOTED', st.url) | |
| 6 | // Give the boot-time OTA a moment, then drive the exact op the button drives. | |
| 7 | await new Promise((r) => setTimeout(r, 20000)) | |
| 8 | const fs = (await import('bare-fs')).default | |
| 9 | const { join } = await import('bare-path') | |
| 10 | const read = (f) => { try { return JSON.parse(fs.readFileSync(join(storage, f), 'utf8')) } catch { return null } } | |
| 11 | console.log('app-update.json :', JSON.stringify(read('app-update.json'))) | |
| 12 | console.log('bundles/current :', JSON.stringify(read('bundles/current.json'))) | |
| 13 | process.exit(0) |
| … | … | @@ -0,0 +1,44 @@ |
| 1 | // apply-plan.js — what "apply the update" means, separated from the RN glue that performs it. | |
| 2 | // | |
| 3 | // ⛔ THE DEFECT THIS FIXES. "restart" was `stopNode(); startNode()`: terminate the worklet, start a | |
| 4 | // new one, inside the SAME process. Measured on a handset — a staged bundle pressed through that | |
| 5 | // path never became the running one, while `adb shell am force-stop` followed by a relaunch applied | |
| 6 | // it every time. The only difference between those two is the process, so the process is what has | |
| 7 | // to go: dlopen'd addons, a half-torn-down corestore and lingering swarm sockets all outlive a | |
| 8 | // thread and none of them outlive a process. | |
| 9 | // | |
| 10 | // ⛔ AND IT IS THE WHOLE UPDATE MECHANISM. `chooseBundle` runs at worklet start and nowhere else. A | |
| 11 | // restart that does not really restart means no update can be applied from the device at all — | |
| 12 | // every one needs a cable and somebody at a desk, which is not an update mechanism. | |
| 13 | // | |
| 14 | // ⚠ HERE RATHER THAN IN worklet.js, because that file imports react-native at module scope and can | |
| 15 | // only run on a handset — #70 already names it as the one file exercised solely on a device. This is | |
| 16 | // the same split `boot-plan.js` makes: the decision is testable, the glue is thin. | |
| 17 | ||
| 18 | export const RESTART_DELAY_MS = 250 | |
| 19 | ||
| 20 | /** | |
| 21 | * Ask for a real restart, and be honest about what was actually obtained. | |
| 22 | * | |
| 23 | * ⚠ FALLS BACK, LOUDLY. On a device that refuses the alarm, the thread restart is still better than | |
| 24 | * nothing. What must not happen is quietly doing the weaker one and reporting success — that is | |
| 25 | * exactly how a restart button that did nothing survived this long. | |
| 26 | */ | |
| 27 | export async function planApply ({ native, restartNode, log = () => {} } = {}) { | |
| 28 | if (native && typeof native.restartProcess === 'function') { | |
| 29 | try { | |
| 30 | await native.restartProcess(RESTART_DELAY_MS) | |
| 31 | return { restarted: 'process', degraded: false } | |
| 32 | } catch (err) { | |
| 33 | // Reported rather than swallowed: if this is failing, every future update fails with it. | |
| 34 | log({ msg: 'process restart refused — falling back to the worklet', error: String((err && err.message) || err) }) | |
| 35 | } | |
| 36 | } else { | |
| 37 | log({ msg: 'this shell cannot restart its own process — updates will not apply until it can' }) | |
| 38 | } | |
| 39 | ||
| 40 | if (typeof restartNode === 'function') await restartNode() | |
| 41 | return { restarted: 'worklet', degraded: true } | |
| 42 | } | |
| 43 | ||
| 44 | export default { planApply, RESTART_DELAY_MS } |
| … | … | @@ -238,3 +238,20 @@ export async function restartNode () { |
| 238 | 238 | stopNode() |
| 239 | 239 | return startNode() |
| 240 | 240 | } |
| 241 | ||
| 242 | /** | |
| 243 | * Apply a staged bundle — by restarting the PROCESS, not the thread. | |
| 244 | * | |
| 245 | * The decision lives in `apply-plan.js` so it can be tested off-device; this is the glue that hands | |
| 246 | * it the real native module and the real worklet restart. See that file for why the process is what | |
| 247 | * has to go. | |
| 248 | */ | |
| 249 | export async function applyUpdate () { | |
| 250 | const { planApply } = await import('./apply-plan.js') | |
| 251 | return planApply({ | |
| 252 | native: NativeModules.FavelaNode, | |
| 253 | restartNode, | |
| 254 | log: (m) => console.log(JSON.stringify(m)) | |
| 255 | }) | |
| 256 | } | |
| 257 |
| … | … | @@ -73,7 +73,7 @@ let lastOtaNote = null |
| 73 | 73 | // implementation is not a reason to rewrite the tests that pin it. |
| 74 | 74 | // ⚠ Imported AND re-exported. `export ... from` alone re-exports without creating local bindings, so |
| 75 | 75 | // `boot()` below — which calls serveSurface and signIn — failed with "serveSurface is not defined". |
| 76 | import { inProcessClient, serveSurface, listenPreferring, signIn, PORT_FILE } from 'favela-host' | |
| 76 | import { inProcessClient, serveSurface, listenPreferring, signIn, PORT_FILE, bootAtproto } from 'favela-host' | |
| 77 | 77 | export { inProcessClient, serveSurface, listenPreferring, signIn, PORT_FILE } |
| 78 | 78 | |
| 79 | 79 | // ── boot ────────────────────────────────────────────────────────────────────────────────────── |
| … | … | @@ -131,6 +131,21 @@ export async function boot (storage) { |
| 131 | 131 | // failure it can read. |
| 132 | 132 | send({ t: 'ready', feed: state.feed, url: state.url }) |
| 133 | 133 | |
| 134 | // ⛔ THE DEVICE BECOMES A WRITER OF ITS DID HERE. Until this call the worklet reached exactly two hpp | |
| 135 | // entry points — mint and submit — so the phone could create an identity, publish it to the global | |
| 136 | // directory, and author nothing under it. The swarm replicates a DID's repo to every device that | |
| 137 | // boots it; a device that never boots it holds nothing. `lib/host/repair-store-backend.js` was | |
| 138 | // copied into this app assuming this existed, and had no callers. | |
| 139 | // | |
| 140 | // ⛔ AFTER `ready`, and non-fatal. Updating and signing are background concerns: a phone that cannot | |
| 141 | // boot its atproto half must still show its own records. favela-android's rule — "a ready the UI | |
| 142 | // cannot use is worse than a failure it can read". | |
| 143 | try { | |
| 144 | state.atproto = await bootAtproto(node, { log: (m, d) => stage(m, d) }) | |
| 145 | } catch (e) { | |
| 146 | stage('atproto boot failed', { error: (e && e.message) || String(e) }) | |
| 147 | } | |
| 148 | ||
| 134 | 149 | // ⛔ START OTA, and note this is what makes `lib/host/ota.js` exist at all. It was copied here with |
| 135 | 150 | // the rest of the host layer and nothing imported it — so bare-pack, which walks the MODULE graph |
| 136 | 151 | // from this entry, never reached it and `pgh-runtime` was absent from the bundle. The file's own |
| … | … | @@ -0,0 +1,10 @@ |
| 1 | // Probe: does a bundle with OFFLOADED addons dlopen them from disk at runtime? | |
| 2 | // Uses each addon for real work, so a load that silently no-ops cannot pass. | |
| 3 | import fs from 'bare-fs' // native: bare-fs.bare | |
| 4 | import crypto from 'hypercore-crypto' // native: sodium-native.bare | |
| 5 | import b4a from 'b4a' | |
| 6 | const st = fs.statSync('.') | |
| 7 | console.log('bare-fs OK isDirectory=' + st.isDirectory()) | |
| 8 | const h = crypto.data(b4a.from('offload-probe')) | |
| 9 | console.log('sodium-native OK digest=' + b4a.toString(h, 'hex').slice(0, 16)) | |
| 10 | console.log('PROBE PASS') |
| … | … | @@ -0,0 +1,49 @@ |
| 1 | // The phone's exact path, end to end: swarm only, no sidecar, cold store, no $HOME. | |
| 2 | // Resolve the release, fetch its tree, and prove the addons are really fetchable. | |
| 3 | import process from 'bare-process' | |
| 4 | const dir = process.argv[process.argv.length - 1] | |
| 5 | const REG = '1dd36bdfa9cb7f7d84adbd07f017e7200f7f90a3a546cd0761fac2a1f8a13e86' | |
| 6 | const t0 = Date.now() | |
| 7 | ||
| 8 | const { watchLatestRelease } = await import('pgh/resolve-release') | |
| 9 | const w = await watchLatestRelease({ | |
| 10 | registryKeyHex: REG, | |
| 11 | name: 'favela-surface-android', | |
| 12 | storeDir: dir + '/registry', | |
| 13 | onRelease: () => {} | |
| 14 | }) | |
| 15 | ||
| 16 | // wait for the watch to produce an entry | |
| 17 | let entry = null | |
| 18 | for (let i = 0; i < 60 && !entry; i++) { | |
| 19 | entry = w.latest() | |
| 20 | if (!entry) await new Promise((r) => setTimeout(r, 500)) | |
| 21 | } | |
| 22 | if (!entry) { console.log('RESOLVE: null after 30s'); await w.close(); process.exit(1) } | |
| 23 | console.log('RESOLVE +' + (Date.now() - t0) + 'ms version=' + entry.version + ' tree=' + entry.treeHash.slice(0, 16)) | |
| 24 | ||
| 25 | const { NetworkFetcher } = await import('pgh/network-fetcher') | |
| 26 | const f = new NetworkFetcher({ topic: entry.serverKey, destDir: dir + '/fetch', localRegistryDir: dir + '/reg-local' }) | |
| 27 | const t1 = Date.now() | |
| 28 | const tree = await f.getTree(entry.treeHash) | |
| 29 | console.log('getTree +' + (Date.now() - t1) + 'ms entries=' + tree.entries.length) | |
| 30 | for (const e of tree.entries) console.log(' kind=' + e.kind + ' ' + e.name) | |
| 31 | ||
| 32 | const addonDir = tree.entries.find((e) => e.name === 'addons' && e.kind === 1) | |
| 33 | if (!addonDir) { console.log('NO addons/ IN THE PUBLISHED TREE'); await f.close?.(); await w.close(); process.exit(1) } | |
| 34 | const t2 = Date.now() | |
| 35 | const sub = await f.getTree(addonDir.childHash) | |
| 36 | let bytes = 0 | |
| 37 | for (const e of sub.entries) bytes += 0 | |
| 38 | console.log('addons/ +' + (Date.now() - t2) + 'ms files=' + sub.entries.length) | |
| 39 | console.log(' first: ' + sub.entries.slice(0, 3).map((e) => e.name).join(', ')) | |
| 40 | ||
| 41 | // actually pull one addon's bytes — the thing a phone must be able to do | |
| 42 | const one = sub.entries[0] | |
| 43 | const t3 = Date.now() | |
| 44 | const content = await f.getContent(one.childHash) | |
| 45 | console.log('getContent(' + one.name + ') +' + (Date.now() - t3) + 'ms ' + content.length + ' bytes exec=' + one.exec) | |
| 46 | console.log('OTA PATH OK') | |
| 47 | try { await f.close() } catch {} | |
| 48 | await w.close() | |
| 49 | process.exit(0) |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-surface-android", |
| 3 | "version": "0.0.26", | |
| 3 | "version": "0.0.27", | |
| 4 | 4 | "description": "favela-surface-android \u2014 a React Native shell over a bare-kit worklet running a full favela-peer node (markdown records, no atproto) with favela-surface rendering its pages in a WebView.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "private": true, |
| … | … | @@ -0,0 +1,8 @@ |
| 1 | import { addonsDirFor } from './lib/host/loader-core.js' | |
| 2 | import { preloadAddons } from './lib/host/preload-addons.js' | |
| 3 | const dir = addonsDirFor(import.meta.url) | |
| 4 | const n = preloadAddons({ dir, say: (m) => console.log('[bootstrap] ' + m) }) | |
| 5 | console.log('[bootstrap] preloaded ' + n + ' staged addon(s) from ' + dir) | |
| 6 | // dynamic: resolves AFTER the preload. Demands an addon the "APK" does not have. | |
| 7 | const sodium = (await import('hypercore-crypto')).default | |
| 8 | console.log('APP RAN: sodium-native works, digest=' + sodium.data(Buffer.from('x')).toString('hex').slice(0, 12)) |
| … | … | @@ -0,0 +1,5 @@ |
| 1 | // Does a module inside a BUNDLE know where the bundle file is? | |
| 2 | console.log('import.meta.url = ' + import.meta.url) | |
| 3 | console.log('Bare.argv = ' + JSON.stringify(Bare.argv)) | |
| 4 | console.log('typeof Bare.Addon = ' + typeof Bare.Addon) | |
| 5 | console.log('URL available = ' + (typeof URL)) |
| … | … | @@ -0,0 +1,9 @@ |
| 1 | // Exactly the phone's path: swarm only, no sidecar, store outside $HOME. | |
| 2 | import process from 'bare-process' | |
| 3 | const dir = process.argv[process.argv.length - 1] | |
| 4 | const { makeGetLatestRelease } = await import('pgh/resolve-release') | |
| 5 | const REG = '1dd36bdfa9cb7f7d84adbd07f017e7200f7f90a3a546cd0761fac2a1f8a13e86' | |
| 6 | const get = makeGetLatestRelease({ registryKeyHex: REG, preferSidecar: false, storeDir: dir, settleMs: 20000 }) | |
| 7 | const e = await get('favela-surface-android') | |
| 8 | console.log('settleMs=20000 entry:', e ? JSON.stringify({ version: e.version, treeHash: (e.treeHash||'').slice(0,12), serverKey: e.serverKey ? e.serverKey.slice(0,12) + '…' : null }) : 'NULL') | |
| 9 | process.exit(0) |
| … | … | @@ -0,0 +1,58 @@ |
| 1 | // Applying a staged bundle — the one thing that must not need a cable. | |
| 2 | // | |
| 3 | // ⛔ THE DEFECT. "restart" was `stopNode(); startNode()`: terminate the worklet, start a new one, | |
| 4 | // inside the SAME process. Measured on a handset — a staged bundle pressed through that path never | |
| 5 | // became the running one, while `adb shell am force-stop` plus a relaunch applied it every time. | |
| 6 | // | |
| 7 | // ⛔ WHY IT IS NOT A DETAIL. `chooseBundle` runs at worklet start and nowhere else, so a restart that | |
| 8 | // does not really restart means NO update can be applied from the device: every one needs a cable | |
| 9 | // and somebody at a desk. That is a demo of an update mechanism, not one. | |
| 10 | // | |
| 11 | // ⚠ These pin the CHOICE, not the Kotlin. What Android does when asked cannot be asserted from here; | |
| 12 | // that it IS asked, and that a refusal is neither hidden nor fatal, is this file's business. | |
| 13 | ||
| 14 | import test from 'brittle' | |
| 15 | import { planApply, RESTART_DELAY_MS } from '../lib/host/apply-plan.js' | |
| 16 | ||
| 17 | test('★★ applying an update asks for a PROCESS restart', async (t) => { | |
| 18 | const asked = [] | |
| 19 | const r = await planApply({ | |
| 20 | native: { restartProcess: async (ms) => { asked.push(ms) } }, | |
| 21 | restartNode: async () => t.fail('the worklet restart must not run when the process one worked') | |
| 22 | }) | |
| 23 | t.is(r.restarted, 'process', 'the process, not the thread') | |
| 24 | t.alike(asked, [RESTART_DELAY_MS], 'asked exactly once') | |
| 25 | t.ok(RESTART_DELAY_MS >= 150, 'with enough delay for the reply to cross the bridge first') | |
| 26 | t.absent(r.degraded) | |
| 27 | }) | |
| 28 | ||
| 29 | test('⚠ a device that refuses falls back, and SAYS it was degraded', async (t) => { | |
| 30 | const said = [] | |
| 31 | let fell = 0 | |
| 32 | const r = await planApply({ | |
| 33 | native: { restartProcess: async () => { throw new Error('no alarm permission') } }, | |
| 34 | restartNode: async () => { fell++ }, | |
| 35 | log: (m) => said.push(m) | |
| 36 | }) | |
| 37 | t.is(r.restarted, 'worklet', 'the weaker restart still happens') | |
| 38 | t.is(fell, 1) | |
| 39 | t.ok(r.degraded, 'and the caller is told — silently doing the weaker one is how this hid') | |
| 40 | t.ok(/falling back/.test(said[0].msg), said[0].msg) | |
| 41 | }) | |
| 42 | ||
| 43 | // ⛔ An APK too old to restart itself. It must say so: this is the shell that can never apply an | |
| 44 | // update, and the whole point is that nothing else can tell. | |
| 45 | test('⛔ a shell with no such method says updates will not apply', async (t) => { | |
| 46 | const said = [] | |
| 47 | let fell = 0 | |
| 48 | const r = await planApply({ native: {}, restartNode: async () => { fell++ }, log: (m) => said.push(m) }) | |
| 49 | t.is(r.restarted, 'worklet') | |
| 50 | t.ok(r.degraded) | |
| 51 | t.is(fell, 1) | |
| 52 | t.ok(/cannot restart its own process/.test(said[0].msg), said[0].msg) | |
| 53 | }) | |
| 54 | ||
| 55 | test('⚠ no native module at all is still not a crash', async (t) => { | |
| 56 | const r = await planApply({ native: null, restartNode: async () => {} }) | |
| 57 | t.is(r.restarted, 'worklet') | |
| 58 | }) |
| … | … | @@ -0,0 +1,14 @@ |
| 1 | import process from 'bare-process' | |
| 2 | const dir = process.argv[process.argv.length - 1] | |
| 3 | const { watchLatestRelease } = await import('pgh/resolve-release') | |
| 4 | const t0 = Date.now() | |
| 5 | const w = await watchLatestRelease({ | |
| 6 | registryKeyHex: '1dd36bdfa9cb7f7d84adbd07f017e7200f7f90a3a546cd0761fac2a1f8a13e86', | |
| 7 | name: 'favela-surface-android', | |
| 8 | storeDir: dir, | |
| 9 | onRelease: (e) => { | |
| 10 | console.log('EVENT +' + (Date.now() - t0) + 'ms ' + e.version + ' serverKey=' + String(e.serverKey || '').slice(0, 12)) | |
| 11 | } | |
| 12 | }) | |
| 13 | console.log('watch open, initial latest =', w.latest() ? w.latest().version : 'null (cold — waiting for the event)') | |
| 14 | setTimeout(async () => { await w.close(); console.log('closed'); process.exit(0) }, 45000) |