From 22e31222b33934a3417642bfbb8a5c536d93084f Mon Sep 17 00:00:00 2001 From: will wade Date: Sun, 28 Jun 2026 22:23:24 +0100 Subject: [PATCH 1/4] fix(analytics): send crashes via captureException so they reach Error Tracking Fixes #2. PostHog Error Tracking only surfaces events sent by PostHog.captureException (which produce $exception events); our previous capture("crash", ...) calls were invisible to it (showed in Events only). - installCrashHandler: if opted in, call PostHog.captureException(throwable, props) live (with engine_log_tail + locale + thread), then chain. The crash file is now written ONLY for the deferred (not-yet-opted-in) case. - flushPendingCrash: rebuild a synthetic Throwable from the saved stack trace (parseStackTrace) and send via captureException so deferred crashes also land in Error Tracking. - PostHog debug=true on debug builds so the capture/flush/HTTP-send path is visible in logcat during end-to-end verification. - Debug Diagnostics UI (debug builds only, Settings > Privacy): 'Send test exception' (non-destructive captureException) and 'Crash app' (real throw on a worker thread to exercise the full handler). RFC 0009. - First JVM unit tests (RFC 0011): parseStackTrace + scrub. The scrub tests caught a real bug - the Windows home-path replacement used the string overload of Regex.replace where backslashes are escape chars, so the C:\Users\ path was mis-emitted and PII leaked. Switched to the lambda overload; all 5 tests pass. No SDK bump needed: posthog-android 3.51.0 already pulls in posthog core 6.21.0, which exposes captureException. Signed-off-by: will wade --- .../at/dasher/android/AnalyticsService.kt | 102 +++++++++++++++--- .../java/at/dasher/android/SettingsScreen.kt | 16 +++ .../android/AnalyticsServiceCrashTest.kt | 65 +++++++++++ 3 files changed, 169 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/at/dasher/android/AnalyticsServiceCrashTest.kt diff --git a/app/src/main/java/at/dasher/android/AnalyticsService.kt b/app/src/main/java/at/dasher/android/AnalyticsService.kt index 6b971fe..0bda022 100644 --- a/app/src/main/java/at/dasher/android/AnalyticsService.kt +++ b/app/src/main/java/at/dasher/android/AnalyticsService.kt @@ -67,6 +67,9 @@ object AnalyticsService { captureApplicationLifecycleEvents = false captureScreenViews = false captureDeepLinks = false + // Verbose SDK logs on debug builds so the crash-report path can be + // verified end-to-end via logcat (capture -> flush -> HTTP send). + debug = BuildConfig.DEBUG } PostHogAndroid.setup(ctx, config) PostHog.identify(anonId(ctx)) @@ -113,6 +116,10 @@ object AnalyticsService { // ── Crash reporting (RFC 0009) ───────────────────────────────────────────── // JVM-level capture only in v1: a native SIGSEGV inside libdasher.so is not seen // by Thread.setDefaultUncaughtExceptionHandler; a signal shim is a follow-up. + // + // Crash reports go via PostHog.captureException so they arrive as `$exception` + // events in PostHog Error Tracking (a plain `capture("crash", …)` is invisible + // to Error Tracking — it only shows in Events/Insights). /** Install the uncaught-exception handler. Call once from Application.onCreate. */ fun installCrashHandler(context: Context) { @@ -120,7 +127,16 @@ object AnalyticsService { val previous = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> try { - writeCrashFile(context, thread.name, throwable) + if (initialized) { + // Opted in: send now as an `$exception` event (best-effort; the SDK's + // disk queue is the real safety net if the process dies mid-send). + PostHog.captureException(throwable, crashProperties(thread.name, snapshotEngineLog())) + PostHog.flush() + } else { + // Not opted in yet: defer to a crash file, flushed on a future launch + // if the user later opts in. + writeCrashFile(context, thread.name, throwable) + } } catch (_: Throwable) { /* never throw in a crash handler */ } previous?.uncaughtException(thread, throwable) } @@ -128,31 +144,53 @@ object AnalyticsService { /** * On launch: if a pending crash file exists, send it (only if opted in — RFC 0009) - * and delete it. Crash files older than 7 days are discarded regardless. + * and delete it. Crash files older than 7 days are discarded regardless. The + * deferred crash is rebuilt into a synthetic Throwable so it lands in Error Tracking + * as an `$exception` event, not a custom-named one. */ fun flushPendingCrash(context: Context) { val file = java.io.File(context.filesDir, CRASH_FILE) if (!file.exists()) return val age = System.currentTimeMillis() - file.lastModified() if (age > CRASH_MAX_AGE_MS) { file.delete(); return } - if (optedIn(context)) { + if (optedIn(context) && initialized) { try { - val text = file.readText() - // Minimal envelope: lines "key=value", stack/engine tail after blank line. - val props = mutableMapOf() - val (header, body) = text.split("\n\n", limit = 2) + val raw = file.readText() + val (header, body) = raw.split("\n\n", limit = 2) .let { it.first() to (it.getOrNull(1) ?: "") } + val props = mutableMapOf() header.split('\n').forEach { ln -> val idx = ln.indexOf('=') if (idx > 0) props[ln.substring(0, idx)] = ln.substring(idx + 1) } - if (body.isNotBlank()) props["stack_trace"] = body - capture("crash", props) + val originalType = props.remove("exception_type")?.toString() ?: "Throwable" + // Body is "\n--- engine log ---\n". + val parts = body.split("\n--- engine log ---\n", limit = 2) + val stackStr = parts[0] + val engineTail = parts.getOrNull(1) ?: "" + if (engineTail.isNotBlank()) props["engine_log_tail"] = engineTail + props["deferred"] = true + val synthetic = DeferredCrashException(originalType) + synthetic.stackTrace = parseStackTrace(stackStr) + PostHog.captureException(synthetic, props) + PostHog.flush() } catch (_: Throwable) { } } file.delete() } + /** Properties attached to every crash `$exception`: thread, versions, locale, engine log tail. */ + private fun crashProperties(thread: String, engineLogTail: String): Map { + val props = mutableMapOf( + "thread" to thread, + "app_version" to appVersion(), + "os_version" to "Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})", + "locale" to (appContext?.let { LocaleHelper.currentLanguageTag(it) } ?: "system") + ) + if (engineLogTail.isNotBlank()) props["engine_log_tail"] = engineLogTail + return props + } + private fun writeCrashFile(context: Context, threadName: String, t: Throwable) { val sw = java.io.StringWriter() t.printStackTrace(java.io.PrintWriter(sw)) @@ -164,21 +202,57 @@ object AnalyticsService { append("app_version=").append(appVersion()).append('\n') append("os_version=Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})\n") } - // stack_trace carries the JVM stack + the engine log tail (separated) so a - // maintainer can reconstruct what DasherCore was doing when the process died. val body = if (engineTail.isNotBlank()) "$stack\n--- engine log ---\n$engineTail" else stack java.io.File(context.filesDir, CRASH_FILE).writeText("$header\n\n$body") } + /** Best-effort parse of a printed JVM stack trace back into StackTraceElement frames. */ + internal fun parseStackTrace(s: String): Array { + val frame = Regex("""\s*at\s+([\w.$]+)\.([\w$<>]+)\(([^:)]*?)(?::(\d+))?\)""") + val frames = mutableListOf() + for (line in s.lineSequence()) { + val m = frame.matchEntire(line) ?: continue + val cls = m.groupValues[1] + val method = m.groupValues[2] + val file = m.groupValues[3].ifBlank { null } + val n = m.groupValues[4].toIntOrNull() + ?: if (file.equals("Native Method", true)) -2 else -1 + frames.add(StackTraceElement(cls, method, file, n)) + } + if (frames.isEmpty()) frames.add(StackTraceElement("deferred", "unknown", null, -1)) + return frames.toTypedArray() + } + + /** Synthetic throwable used to replay a deferred crash; carries the original type name. */ + private class DeferredCrashException(val originalType: String) : + Throwable("deferred crash: $originalType") + /** Scrub home-directory path segments and emails; respect RFC 0001's no-PII promise. */ - private fun scrub(s: String): String { + internal fun scrub(s: String): String { var out = s out = Regex("""(/Users/|/home/)([^/\\]+)""").replace(out) { "${it.groupValues[1]}" } - out = Regex("""C:\\Users\\([^\\]+)""").replace(out, "C:\\Users\\") - out = Regex("""[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}""").replace(out, "") + // Lambda form (not the string-replacement overload): backslashes in the + // replacement must be literal, not treated as regex escape characters. + out = Regex("""C:\\Users\\([^\\]+)""").replace(out) { "C:\\Users\\" } + out = Regex("""[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}""").replace(out) { "" } return out } + // ── Debug: end-to-end crash-report verification (debug builds only) ──────── + + /** Non-destructive: send a synthetic test exception so it appears in Error Tracking. */ + fun sendTestException() { + if (!initialized) return + val t = RuntimeException("Dasher test exception (debug trigger)") + PostHog.captureException(t, crashProperties("main", snapshotEngineLog()) + mapOf("test" to true)) + PostHog.flush() + } + + /** Destructive: throw on a worker thread so the real UncaughtExceptionHandler path fires. */ + fun triggerRealCrash() { + Thread({ throw RuntimeException("Dasher real crash (debug trigger)") }, "DasherCrashTest").start() + } + private fun anonId(context: Context): String = prefs(context).getString(KEY_ANON_ID, null) ?: UUID.randomUUID().toString().also { prefs(context).edit().putString(KEY_ANON_ID, it).apply() diff --git a/app/src/main/java/at/dasher/android/SettingsScreen.kt b/app/src/main/java/at/dasher/android/SettingsScreen.kt index 95426f0..e810724 100644 --- a/app/src/main/java/at/dasher/android/SettingsScreen.kt +++ b/app/src/main/java/at/dasher/android/SettingsScreen.kt @@ -186,6 +186,22 @@ private fun PrivacyContent(context: android.content.Context) { OutlinedButton(onClick = { AnalyticsService.resetId(context) }) { Text("Reset anonymous ID") } + + // Debug-only crash-report verification (RFC 0009). Lets a maintainer exercise + // the full $exception path end-to-end without writing code. Debug builds only. + if (BuildConfig.DEBUG) { + androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text("Diagnostics (debug)", style = MaterialTheme.typography.titleMedium) + Text("Send a crash to PostHog Error Tracking to verify the pipeline.", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { AnalyticsService.sendTestException() }, + enabled = optedIn + ) { Text("Send test exception") } + OutlinedButton(onClick = { AnalyticsService.triggerRealCrash() }) { Text("Crash app") } + } + } } } diff --git a/app/src/test/java/at/dasher/android/AnalyticsServiceCrashTest.kt b/app/src/test/java/at/dasher/android/AnalyticsServiceCrashTest.kt new file mode 100644 index 0000000..194aee9 --- /dev/null +++ b/app/src/test/java/at/dasher/android/AnalyticsServiceCrashTest.kt @@ -0,0 +1,65 @@ +package at.dasher.android + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Crash-report helpers (RFC 0009). These are the pieces of the deferred-crash + * reconstruction path that are testable without PostHog or the network — the + * live `captureException` path is exercised manually via the debug Diagnostics + * UI (see SettingsScreen.PrivacyContent). + */ +class AnalyticsServiceCrashTest { + + @Test + fun parseStackTrace_readsClassMethodFileLine() { + val printed = """ + java.lang.RuntimeException: boom + at com.foo.Bar.baz(Bar.java:42) + at com.foo.Qux.run(Qux.kt:7) + """.trimIndent() + + val frames = AnalyticsService.parseStackTrace(printed) + + assertEquals(2, frames.size) + assertEquals("com.foo.Bar", frames[0].className) + assertEquals("baz", frames[0].methodName) + assertEquals("Bar.java", frames[0].fileName) + assertEquals(42, frames[0].lineNumber) + assertEquals("com.foo.Qux", frames[1].className) + assertEquals("Qux.kt", frames[1].fileName) + assertEquals(7, frames[1].lineNumber) + } + + @Test + fun parseStackTrace_handlesNativeMethod() { + val printed = "at com.foo.Bar.native(Native Method)" + val frames = AnalyticsService.parseStackTrace(printed) + assertEquals(1, frames.size) + assertEquals(-2, frames[0].lineNumber) // Thread.NOT_SUPPORTED_INSTRUCTION-ish sentinel + } + + @Test + fun parseStackTrace_emptyInputFallsBackToPlaceholder() { + val frames = AnalyticsService.parseStackTrace("not a stack trace") + assertTrue("expected at least one frame", frames.isNotEmpty()) + } + + @Test + fun scrub_stripsUnixAndWindowsHomePaths() { + val raw = "at com.foo.Bar.open(/Users/jane/secret.txt) at C:\\Users\\bob\\x.txt" + val out = AnalyticsService.scrub(raw) + assertTrue("unix home stripped", out.contains("/Users/")) + assertTrue("windows home stripped", out.contains("C:\\Users\\")) + assertTrue("no PII left", !out.contains("jane") && !out.contains("bob")) + } + + @Test + fun scrub_stripsEmails() { + val raw = "contact: user@example.com, other.name+tag@sub.example.org" + val out = AnalyticsService.scrub(raw) + assertTrue(out.contains("")) + assertTrue("emails gone", !out.contains("user@example.com") && !out.contains("sub.example.org")) + } +} From 8c10722d4230312516a3a9de57d8f847566dd08a Mon Sep 17 00:00:00 2001 From: will wade Date: Sun, 28 Jun 2026 22:35:22 +0100 Subject: [PATCH 2/4] chore: drop in-app crash test buttons; add standalone PostHog verifier script Per review, the in-app "Send test exception" / "Crash app" diagnostics buttons don't belong in the shipped app. Removed both UI buttons (SettingsScreen) and the AnalyticsService.sendTestException / triggerRealCrash methods that backed them. The captureException fix, the scrub-bug fix, the unit tests, and the PostHog debug=true logging all remain. Adds scripts/verify_posthog_exceptions.py: a one-off developer tool that sends a single synthetic $exception to the same PostHog project via the official Python SDK (posthog.capture_exception), so the project's Error Tracking can be verified independently of the app/emulator. Tagged test=true for filtering. Signed-off-by: will wade --- .../at/dasher/android/AnalyticsService.kt | 15 ----- .../java/at/dasher/android/SettingsScreen.kt | 16 ----- scripts/verify_posthog_exceptions.py | 67 +++++++++++++++++++ 3 files changed, 67 insertions(+), 31 deletions(-) create mode 100644 scripts/verify_posthog_exceptions.py diff --git a/app/src/main/java/at/dasher/android/AnalyticsService.kt b/app/src/main/java/at/dasher/android/AnalyticsService.kt index 0bda022..66ed433 100644 --- a/app/src/main/java/at/dasher/android/AnalyticsService.kt +++ b/app/src/main/java/at/dasher/android/AnalyticsService.kt @@ -238,21 +238,6 @@ object AnalyticsService { return out } - // ── Debug: end-to-end crash-report verification (debug builds only) ──────── - - /** Non-destructive: send a synthetic test exception so it appears in Error Tracking. */ - fun sendTestException() { - if (!initialized) return - val t = RuntimeException("Dasher test exception (debug trigger)") - PostHog.captureException(t, crashProperties("main", snapshotEngineLog()) + mapOf("test" to true)) - PostHog.flush() - } - - /** Destructive: throw on a worker thread so the real UncaughtExceptionHandler path fires. */ - fun triggerRealCrash() { - Thread({ throw RuntimeException("Dasher real crash (debug trigger)") }, "DasherCrashTest").start() - } - private fun anonId(context: Context): String = prefs(context).getString(KEY_ANON_ID, null) ?: UUID.randomUUID().toString().also { prefs(context).edit().putString(KEY_ANON_ID, it).apply() diff --git a/app/src/main/java/at/dasher/android/SettingsScreen.kt b/app/src/main/java/at/dasher/android/SettingsScreen.kt index e810724..95426f0 100644 --- a/app/src/main/java/at/dasher/android/SettingsScreen.kt +++ b/app/src/main/java/at/dasher/android/SettingsScreen.kt @@ -186,22 +186,6 @@ private fun PrivacyContent(context: android.content.Context) { OutlinedButton(onClick = { AnalyticsService.resetId(context) }) { Text("Reset anonymous ID") } - - // Debug-only crash-report verification (RFC 0009). Lets a maintainer exercise - // the full $exception path end-to-end without writing code. Debug builds only. - if (BuildConfig.DEBUG) { - androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text("Diagnostics (debug)", style = MaterialTheme.typography.titleMedium) - Text("Send a crash to PostHog Error Tracking to verify the pipeline.", - style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton( - onClick = { AnalyticsService.sendTestException() }, - enabled = optedIn - ) { Text("Send test exception") } - OutlinedButton(onClick = { AnalyticsService.triggerRealCrash() }) { Text("Crash app") } - } - } } } diff --git a/scripts/verify_posthog_exceptions.py b/scripts/verify_posthog_exceptions.py new file mode 100644 index 0000000..63bd799 --- /dev/null +++ b/scripts/verify_posthog_exceptions.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +""" +Verify that your PostHog project receives `$exception` events in Error Tracking. + +This is a one-off developer tool — it sends a single synthetic exception straight +to PostHog via the official Python SDK (the same `$exception` event shape the +Android app's `PostHog.captureException` produces), so you can confirm your +project's Error Tracking is enabled and ingesting, *without* needing the app or +a device. Run it after enabling Error Tracking in Project Settings. + +Usage: + pip install posthog + python scripts/verify_posthog_exceptions.py + +Then check PostHog -> Error Tracking (and Activity) for the test exception, +tagged `test=true, source=python-verification-script` so it can be filtered out +or deleted afterwards. +""" + +import sys + +try: + from posthog import Posthog +except ImportError: + sys.exit("posthog not installed. Run: pip install posthog") + +# Same project + host the Android app uses (AnalyticsService.kt). +POSTHOG_KEY = "phc_ubtNRuCT7Zqo4dVrVWRnJRYE9m9WqGeTyK7zVDKQ968" +POSTHOG_HOST = "https://eu.i.posthog.com" + + +def main() -> int: + print(f"PostHog host : {POSTHOG_HOST}") + print(f"Project key : {POSTHOG_KEY[:12]}...{POSTHOG_KEY[-4:]}") + print("This will send ONE synthetic exception to your PostHog project.") + if input("Proceed? [y/N] ").strip().lower() not in ("y", "yes"): + print("Aborted.") + return 1 + + client = Posthog(project_api_key=POSTHOG_KEY, host=POSTHOG_HOST) + try: + try: + raise RuntimeError("Dasher verification: synthetic crash from Python script") + except Exception as exc: + event_id = client.capture_exception( + exc, + distinct_id="posthog-verification-script", + properties={ + "source": "python-verification-script", + "test": True, + }, + ) + client.flush() + print(f"\nSent. event id: {event_id}") + print("Now check:") + print(" 1. PostHog -> Activity (the $exception event should appear)") + print(" 2. PostHog -> Error Tracking (the exception should land here") + print(" if Error Tracking is enabled in") + print(" Project Settings)") + print("Tagged test=true so you can filter/delete it.") + return 0 + finally: + client.shutdown() + + +if __name__ == "__main__": + raise SystemExit(main()) From e0a95c950df1f83cf6f4d2e9f57efee4c714bdfb Mon Sep 17 00:00:00 2001 From: will wade Date: Sun, 28 Jun 2026 22:43:15 +0100 Subject: [PATCH 3/4] fix(scripts): correct truncated PostHog key in verifier script The verifier script was missing the trailing 'J' on the PostHog project key, so its test events were silently dropped by PostHog (the SDK returns a client-generated event id before the server validates the key, which is why it looked like it sent). The app's AnalyticsService TOKEN was already correct. Fixes the verification path. Signed-off-by: will wade --- scripts/verify_posthog_exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify_posthog_exceptions.py b/scripts/verify_posthog_exceptions.py index 63bd799..e09901a 100644 --- a/scripts/verify_posthog_exceptions.py +++ b/scripts/verify_posthog_exceptions.py @@ -25,7 +25,7 @@ sys.exit("posthog not installed. Run: pip install posthog") # Same project + host the Android app uses (AnalyticsService.kt). -POSTHOG_KEY = "phc_ubtNRuCT7Zqo4dVrVWRnJRYE9m9WqGeTyK7zVDKQ968" +POSTHOG_KEY = "phc_ubtNRuCT7Zqo4dVrVWRnJRYE9m9WqGeTyK7zVDKQ968J" POSTHOG_HOST = "https://eu.i.posthog.com" From 258d9712ec3a3722f2b924e53690958424c8a584 Mon Sep 17 00:00:00 2001 From: will wade Date: Sun, 28 Jun 2026 22:51:38 +0100 Subject: [PATCH 4/4] chore: drop the PostHog verifier script from the repo Per review - the standalone Python verifier was useful for one-off setup confirmation but doesn't belong in the app repo. Removes scripts/verify_posthog_exceptions.py. Signed-off-by: will wade --- scripts/verify_posthog_exceptions.py | 67 ---------------------------- 1 file changed, 67 deletions(-) delete mode 100644 scripts/verify_posthog_exceptions.py diff --git a/scripts/verify_posthog_exceptions.py b/scripts/verify_posthog_exceptions.py deleted file mode 100644 index e09901a..0000000 --- a/scripts/verify_posthog_exceptions.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python3 -""" -Verify that your PostHog project receives `$exception` events in Error Tracking. - -This is a one-off developer tool — it sends a single synthetic exception straight -to PostHog via the official Python SDK (the same `$exception` event shape the -Android app's `PostHog.captureException` produces), so you can confirm your -project's Error Tracking is enabled and ingesting, *without* needing the app or -a device. Run it after enabling Error Tracking in Project Settings. - -Usage: - pip install posthog - python scripts/verify_posthog_exceptions.py - -Then check PostHog -> Error Tracking (and Activity) for the test exception, -tagged `test=true, source=python-verification-script` so it can be filtered out -or deleted afterwards. -""" - -import sys - -try: - from posthog import Posthog -except ImportError: - sys.exit("posthog not installed. Run: pip install posthog") - -# Same project + host the Android app uses (AnalyticsService.kt). -POSTHOG_KEY = "phc_ubtNRuCT7Zqo4dVrVWRnJRYE9m9WqGeTyK7zVDKQ968J" -POSTHOG_HOST = "https://eu.i.posthog.com" - - -def main() -> int: - print(f"PostHog host : {POSTHOG_HOST}") - print(f"Project key : {POSTHOG_KEY[:12]}...{POSTHOG_KEY[-4:]}") - print("This will send ONE synthetic exception to your PostHog project.") - if input("Proceed? [y/N] ").strip().lower() not in ("y", "yes"): - print("Aborted.") - return 1 - - client = Posthog(project_api_key=POSTHOG_KEY, host=POSTHOG_HOST) - try: - try: - raise RuntimeError("Dasher verification: synthetic crash from Python script") - except Exception as exc: - event_id = client.capture_exception( - exc, - distinct_id="posthog-verification-script", - properties={ - "source": "python-verification-script", - "test": True, - }, - ) - client.flush() - print(f"\nSent. event id: {event_id}") - print("Now check:") - print(" 1. PostHog -> Activity (the $exception event should appear)") - print(" 2. PostHog -> Error Tracking (the exception should land here") - print(" if Error Tracking is enabled in") - print(" Project Settings)") - print("Tagged test=true so you can filter/delete it.") - return 0 - finally: - client.shutdown() - - -if __name__ == "__main__": - raise SystemExit(main())