Repository navigation
fix: stop losing, duplicating, and exposing tracked commands - #325
Conversation
Correct command pairing, cursor handling, encryption fail-closed behavior, stale-socket fallback, hook duplicates, and owner-only permissions on tokens and local history. Co-authored-by: Le He <Annatar.He+github@gmail.com>
⏱️
|
| Scenario | base | head | Δ | p | |
|---|---|---|---|---|---|
Startup/floor |
192 µs ±21% | 243 µs ±22% | +26.29% | 0.023 | 🎲 |
Startup/version |
2.77 ms ±24% | 3.28 ms ±44% | ~ | 0.436 | ✅ |
Track/daemon/pre |
3.19 ms ±49% | 3.52 ms ±44% | ~ | 1.000 | ✅ |
Track/daemon/post |
3.76 ms ±39% | 3.54 ms ±31% | ~ | 1.000 | ✅ |
Track/direct/pre |
3.25 ms ±46% | 4.06 ms ±43% | ~ | 0.353 | ✅ |
Track/direct/post |
6.79 ms ±17% | 7.21 ms ±26% | ~ | 0.436 | ✅ |
Track/direct/post-sync |
8.92 ms ±32% | 9.59 ms ±34% | ~ | 0.739 | ✅ |
Binary size: 19.1 MiB → 19.1 MiB (+0.0%).
CPU, tail latency and memory
Per exec, base → head (Δ when significant).
| Scenario | p95 wall | user CPU | sys CPU | peak RSS |
|---|---|---|---|---|
Startup/floor |
236 µs → 352 µs (+49.01%) | 158 µs → 193 µs (+22.09%) | 6 µs → 8 µs | 9.2 MiB → 9.2 MiB |
Startup/version |
4.10 ms → 5.15 ms | 1.80 ms → 1.86 ms | 984 µs → 1.33 ms | 15.3 MiB → 15.3 MiB |
Track/daemon/pre |
5.36 ms → 5.08 ms | 1.93 ms → 2.14 ms | 1.32 ms → 1.47 ms | 16.2 MiB → 16.2 MiB |
Track/daemon/post |
5.15 ms → 4.56 ms | 2.19 ms → 2.23 ms | 1.56 ms → 1.29 ms | 16.1 MiB → 16.1 MiB |
Track/direct/pre |
4.78 ms → 6.24 ms | 1.94 ms → 2.36 ms | 1.29 ms → 1.70 ms | 16.0 MiB → 16.0 MiB |
Track/direct/post |
9.06 ms → 9.94 ms | 4.30 ms → 4.82 ms | 2.56 ms → 2.56 ms | 17.3 MiB → 17.4 MiB (+0.72%) |
Track/direct/post-sync |
13.16 ms → 12.96 ms | 5.87 ms → 6.21 ms | 3.19 ms → 3.40 ms | 19.1 MiB → 19.3 MiB (+1.01%) |
benchstat
goos: linux
goarch: amd64
pkg: github.com/malamtime/cli/perf
cpu: AMD EPYC
│ base │ head │
│ sec/op │ sec/op vs base │
Startup/floor-2 192.4µ ± 21% 243.0µ ± 22% +26.29% (p=0.023 n=10)
Startup/version-2 2.774m ± 24% 3.276m ± 44% ~ (p=0.436 n=10)
Track/daemon/pre-2 3.193m ± 49% 3.515m ± 44% ~ (p=1.000 n=10)
Track/daemon/post-2 3.762m ± 39% 3.537m ± 31% ~ (p=1.000 n=10)
Track/direct/pre-2 3.249m ± 46% 4.057m ± 43% ~ (p=0.353 n=10)
Track/direct/post-2 6.790m ± 17% 7.207m ± 26% ~ (p=0.436 n=10)
Track/direct/post-sync-2 8.919m ± 32% 9.585m ± 34% ~ (p=0.739 n=10)
geomean 2.773m 3.104m +11.91%
│ base │ head │
│ p50-sec/op │ p50-sec/op vs base │
Startup/floor-2 175.4µ ± 24% 225.4µ ± 15% +28.51% (p=0.023 n=10)
Startup/version-2 2.529m ± 28% 2.953m ± 50% ~ (p=0.631 n=10)
Track/daemon/pre-2 2.910m ± 50% 3.297m ± 40% ~ (p=0.912 n=10)
Track/daemon/post-2 3.586m ± 38% 3.401m ± 35% ~ (p=0.796 n=10)
Track/direct/pre-2 3.045m ± 52% 3.871m ± 35% ~ (p=0.247 n=10)
Track/direct/post-2 6.453m ± 16% 6.978m ± 27% ~ (p=0.529 n=10)
Track/direct/post-sync-2 8.429m ± 35% 9.308m ± 36% ~ (p=0.796 n=10)
geomean 2.583m 2.935m +13.64%
│ base │ head │
│ p95-sec/op │ p95-sec/op vs base │
Startup/floor-2 236.4µ ± 36% 352.3µ ± 42% +49.01% (p=0.015 n=10)
Startup/version-2 4.105m ± 30% 5.146m ± 49% ~ (p=0.393 n=10)
Track/daemon/pre-2 5.357m ± 41% 5.079m ± 71% ~ (p=0.853 n=10)
Track/daemon/post-2 5.148m ± 42% 4.561m ± 79% ~ (p=0.912 n=10)
Track/direct/pre-2 4.782m ± 26% 6.242m ± 51% ~ (p=0.436 n=10)
Track/direct/post-2 9.058m ± 32% 9.939m ± 36% ~ (p=0.481 n=10)
Track/direct/post-sync-2 13.16m ± 32% 12.96m ± 40% ~ (p=0.912 n=10)
geomean 3.959m 4.435m +12.01%
│ base │ head │
│ peak-rss-B │ peak-rss-B vs base │
Startup/floor-2 9.182Mi ± 1% 9.171Mi ± 2% ~ (p=0.971 n=10)
Startup/version-2 15.28Mi ± 0% 15.28Mi ± 0% ~ (p=0.897 n=10)
Track/daemon/pre-2 16.16Mi ± 0% 16.16Mi ± 0% ~ (p=0.540 n=10)
Track/daemon/post-2 16.09Mi ± 0% 16.07Mi ± 0% ~ (p=0.382 n=10)
Track/direct/pre-2 16.03Mi ± 0% 16.03Mi ± 0% ~ (p=0.698 n=10)
Track/direct/post-2 17.26Mi ± 0% 17.39Mi ± 0% +0.72% (p=0.000 n=10)
Track/direct/post-sync-2 19.09Mi ± 0% 19.29Mi ± 0% +1.01% (p=0.000 n=10)
geomean 15.26Mi 15.29Mi +0.22%
│ base │ head │
│ sys-sec/op │ sys-sec/op vs base │
Startup/floor-2 5.530µ ± 105% 7.655µ ± 112% ~ (p=0.218 n=10)
Startup/version-2 984.1µ ± 27% 1325.7µ ± 58% ~ (p=0.280 n=10)
Track/daemon/pre-2 1.319m ± 57% 1.473m ± 48% ~ (p=0.971 n=10)
Track/daemon/post-2 1.562m ± 52% 1.293m ± 56% ~ (p=0.684 n=10)
Track/direct/pre-2 1.293m ± 53% 1.696m ± 49% ~ (p=0.280 n=10)
Track/direct/post-2 2.559m ± 17% 2.564m ± 32% ~ (p=0.796 n=10)
Track/direct/post-sync-2 3.192m ± 37% 3.397m ± 52% ~ (p=0.912 n=10)
geomean 737.3µ 836.0µ +13.39%
│ base │ head │
│ user-sec/op │ user-sec/op vs base │
Startup/floor-2 158.0µ ± 16% 192.9µ ± 12% +22.09% (p=0.015 n=10)
Startup/version-2 1.796m ± 21% 1.864m ± 48% ~ (p=0.853 n=10)
Track/daemon/pre-2 1.931m ± 38% 2.138m ± 31% ~ (p=0.912 n=10)
Track/daemon/post-2 2.191m ± 33% 2.234m ± 22% ~ (p=0.912 n=10)
Track/direct/pre-2 1.937m ± 44% 2.358m ± 34% ~ (p=0.280 n=10)
Track/direct/post-2 4.299m ± 16% 4.822m ± 23% ~ (p=0.393 n=10)
Track/direct/post-sync-2 5.872m ± 31% 6.207m ± 29% ~ (p=0.579 n=10)
geomean 1.789m 1.984m +10.91%
Δ compares medians; ~ means no significant difference (Mann-Whitney U, p ≥ 0.05). A scenario is flagged when it is more than 10% and more than 250µs slower with p < 0.05. Startup/floor runs true, not shelltime: it is an A/A check of runner noise. Runner: linux/amd64, AMD EPYC. go1.27.1, 10 rounds × 100 execs per scenario and binary.
Codecov Report❌ Patch coverage is
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 1 file with indirect coverage changes 🚀 New features to boost your workflow:
|
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
/claude ultrareview |
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. 🤖 Generated with Claude Code |
|
/claude review |
|
@claude review |
|
Claude finished @AnnatarHe's task in 23s —— View job Code reviewI read the non-test diff against What I checked
Minor notes (non-blocking)
Overall this looks good to merge from my side. |
Keep owner-only config permissions alongside the iOS app approval sign-in flow. Co-authored-by: Le He <Annatar.He+github@gmail.com>
Fixes verified tracking, sync, encryption, and permission bugs in the ShellTime CLI and daemon. Each item below was confirmed in code (and, where practical, with a regression test) before the change.
Bugs fixed
1.
post.txtlines were corrupted once the file passed 512KBGetPostCommandsstoredbufio.Scanner.Bytes()slices. Those slices alias the scanner buffer. After the file grows pastMAX_BUFFER_SIZE(512KB),Scanshifts the buffer and earlier lines are overwritten.shelltime gcthen rewrote the history from those slices.Impact: command history on the daemon-less path was corrupted or lost.
Fix: copy each scanned line before storing it.
2. The command exactly at the cursor was uploaded again
BuildTrackingDataskipped onlyrecordingTime.Before(cursor). The command recorded at the cursor was included on every later flush. The txt store is not pruned on the daemon-less path, so that row stays on disk.Impact: duplicate uploads of the last synced command whenever no daemon is running.
Fix: skip recording times that are not strictly after the cursor.
3. Same-second commands were paired with the wrong start time
FindClosestCommandcomparedUnix()seconds. Two commands in the same second both had diff 0, so the first candidate won, including a pre that started later in that second. When nothing matched, the function returns a non-nil empty command;shelltime lstreated that as a hit and showed a zero start time.Impact: wrong durations in
shelltime lsand in sync payloads.Fix: compare
UnixNano.lsignores the empty sentinel viaIsNil().4.
config.local.*could not overridestorage.enginemergeConfigcopied every other config field and droppedStorage.Impact:
storage.engine: bolt(orfile) inconfig.local.yaml/config.local.tomlwas ignored.Fix: merge the
Storagepointer. Documented indocs/CONFIG.md.5. Encryption could send plaintext or an unreadable payload
With
encrypted: true, an empty public key skipped encryption and the plaintext was uploaded. AES key generation and RSA wrap errors were logged and then ignored, so the upload could go out with an empty wrapped key. Fetching the public key already failed closed.Impact: commands the user configured as encrypted could leave the machine unencrypted, or be stored in a form the server cannot decrypt.
Fix: return the error and do not upload when the key is missing, empty, or encryption fails.
6. A stale daemon socket dropped the command
IsSocketReadyis onlyos.Stat. A socket file left by a dead daemon still looks ready.trackthen failed to dial, returned that error, and the shell hook discards stderr.Impact: commands run while
/tmp/shelltime.sock(or a custom socket path) was stale were never saved.Fix: log the dial failure and fall back to the local txt store.
7. Sync with no token advanced the cursor, and extra endpoints were skipped
SendLocalDataToServerreturned nil without uploading whenTokenwas empty, and callers then calledSetCursor. It also returned before consideringendpointswhenever the primary token was empty. On the daemon, encryption plus an empty token failed the public-key fetch, nacked the message, andSavePostran again on each redelivery.Impact: commands recorded before
shelltime authwere permanently skipped after the user authenticated. Configured extra endpoints never received data if the primary token was blank. The daemon could duplicate the same post.Fix: do not advance the cursor when nothing would be uploaded. Send to configured endpoints even when the primary token is empty. When the daemon has neither a token nor endpoints, it acks the message and leaves the batch buffered.
8. A failed daemon upload duplicated the post
handlePubSubTrackPostsaved the post, then returned the HTTP error. The subscriber nacks and retries up to 3 times, and each delivery calledSavePostagain.Impact: one failed upload could append the same command up to four times, then drop the message. The cursor did not move, but the buffer was wrong.
Fix: log the send error and return nil. The row stays buffered once, the cursor does not move, and a later post retries the upload.
9. The API token was world-readable
shelltime authcreated~/.shelltimeas0755andconfig.yamlas0644.WriteFilealso does not change the mode of a file that already exists.Impact: other users on the machine could read the API token.
Fix: directory
0700, config0600, withchmodafter every write.10. Local command history and logs were world-readable
Pre/post/cursor files, gc rewrites, and
sync-pending.jsonlwere created0644. The log file was opened0755.Impact: other local users could read command history (including secrets typed into the shell) and the log.
Fix: create those files
0600and chmod them, becauseWriteFile/OpenFiledo not tighten an existing mode. The commands directory is0700. An existing loose log is tightened once.11. zsh and bash recorded phantom or duplicate commands
zsh
precmdusedfc -ln -1, which repeats the previous history entry on the first prompt and on an empty enter. bash leftLAST_COMMANDset, so an empty enter posted the same command again.Impact: startup and blank-enter prompts created extra command records.
Fix: remember the command from
preexecand clear it before the post hook returns.hooks installnow replaces a hook file whose bytes differ from the embedded script, so the nextshelltime hooks installorshelltime initpicks this up. Identical files are left untouched. A hand-edited script under~/.shelltime/hooksis replaced on that install.12.
track --sessionIdhelp called the id a unix timestampHooks pass
date +%Y%m%d%H%M%S, a per-shell id shared by the pre and post events.Fix: the help text matches that.
13. The socket permission error named the wrong mode
The daemon chmods the socket to
0777and logged a failure to set0755.Fix: the log now says
0777. The mode is unchanged (see below).14. A sync message that failed to encode was still published
json.Marshalfailure on a sync socket message was logged, then the handler continued and published the empty buffer.Fix: return without publishing.
15. Config docs named the wrong cleanup files
docs/CONFIG.mdlistedheartbeat.logandsync-pending.txt. The files on disk arecoding-heartbeat.data.logandsync-pending.jsonl.Fix: the doc matches the filenames the cleanup code deletes.
Tests
go test -timeout 3m -coverprofile=coverage.txt -covermode=atomic ./...,go vet ./..., and builds ofshelltimeandshelltime-daemonpass. New tests cover the scanner copy, nanosecond pairing, cursor equality, storage merge, empty and invalid encryption keys, stale-socket fallback, no-token cursor behavior, endpoints-only sync, auth file modes, and the hook scripts.Suspected issues left unchanged
0777. This is intentional (any local user must be able to connect). It also lets any local user inject track events into the daemon owner's history, and those events sync with the owner's token. Changing it would break multi-user machines; it needs a design change, not a chmod.gcTimeis never sent or applied by the CLI. It is loaded and documented as server retention days. The CLI does not put it on the wire. Left alone so the docs do not invent a server contract.shelltime syncadvances the local cursor when the Unix socket write succeeds, not when the server accepts the batch. The daemon nacks and drops the message after 3 retries. The circuit breaker only persists a payload once it is already open (10 consecutive failures). The daemon-owned track path does not advance the cursor until HTTP succeeds. Fixing the CLI sync path needs a request/response ack.SetCursorfailure after a successful upload still returns an error. A nack can then duplicate that post and resend it. This is a disk error, not the normal path.FindClosestCommandstill returns a non-nil empty command when nothing matches.lsnow checksIsNil(). Sync start time stays zero either way.strconv.Atoi. That is fine on the amd64 and arm64 builds Goreleaser ships. 32-bit is not a shipped arch.fileStore.Prunerewrites withWriteFile, so a crash mid-write can truncate the history file.gcalready renames a backup first. Making prune atomic would be a rewrite, not a one-line fix.precmddoes not clobber other hooks on zsh 5.9, and a precmd that runstruedoes not change the exit status the next prompt sees.fc -ln -1also does not add a leading space on zsh 5.9, so that was not a pairing bug. Fish only firesfish_postexecafter a real command.Need help on this PR? Tag
@codesmith-botwith what you need.