<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>I am LAZY bones?</title>
	<atom:link href="https://luy.li/feed/" rel="self" type="application/rss+xml" />
	<link>https://luy.li</link>
	<description>AN ancient AND boring SITE</description>
	<lastBuildDate>Sun, 13 Sep 2026 03:56:14 +0000</lastBuildDate>
	<language>zh-Hans</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	
	<item>
		<title>《一句顶一万句》读书笔记</title>
		<link>https://luy.li/2026/09/13/978-7-5354-3976-5/</link>
					<comments>https://luy.li/2026/09/13/978-7-5354-3976-5/#respond</comments>
		
		<dc:creator><![CDATA[bones7456]]></dc:creator>
		<pubDate>Sun, 13 Sep 2026 03:55:47 +0000</pubDate>
				<category><![CDATA[读书笔记]]></category>
		<guid isPermaLink="false">https://luy.li/?p=2597</guid>

					<description><![CDATA[<p>最近，我读的书是《一句顶一万句》。读这本书，不是因为朋友给我介绍说这本书好看，也不是因为这书得了很多奖非常畅销 [&#8230;]</p>
<p>The post <a href="https://luy.li/2026/09/13/978-7-5354-3976-5/">《一句顶一万句》读书笔记</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>最近，我读的书是《一句顶一万句》。读这本书，不是因为朋友给我介绍说这本书好看，也不是因为这书得了很多奖非常畅销，而是因为刘震云老师的江湖地位近几年特别高，很难让人不注意到他。所以我就找了一本刘老师的书来读。</p>
<p>这个开头，是不是还挺符合这本书的写作风格的？哈哈。<br />
<span id="more-2597"></span></p>
<p>在这个注意力高度分散的年代，各种短视频平台上，常常会有刘震云这种文人参加综艺的切片。起的标题往往是：刘震云怒怼XXX，点进去就会发现，文化人怼人确实厉害，不动声色地怼得对方哑口无言。而他说话的那种不慌不忙地、慢吞吞地感觉，就和读他的书的感觉是一致的。</p>
<p>这本书里，也大多都是短句，用词也并不深奥偏僻，甚至有点朴实。对话描写经常是说一句话，“又说”。。。“又说”。。。每句也都是短句。</p>
<p>但他就是用这种风格，写出了<strong>两个时代的同一种落寞</strong>。上部我认为大概是发生在民国时期后期或者刚建国时期的故事，下部却大概是世纪交接的年代。虽然时代不同，但主人公确是有一丝丝联系的，而且人物的关系网也都是最底层的劳动人民。可以看出，作者确实也是底层出身，观察非常细致，积累也非常深厚。</p>
<p>总之，在我看来这书挺艺术的。但一句顶一万句，那句话到底是什么话，直到最后也都没有说，突然就结束了，就有点难受。。。不过我也理解这是一种写作手法，哈哈！</p>
<p>剧情就不展开说了，不然算剧透，也不太好。听说有部同名电影，是刘震云担任编剧，还是他女儿执导的，我这就去找来看看。</p><p>The post <a href="https://luy.li/2026/09/13/978-7-5354-3976-5/">《一句顶一万句》读书笔记</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://luy.li/2026/09/13/978-7-5354-3976-5/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>给 Claude 的 5 小时窗口掐个点</title>
		<link>https://luy.li/2026/08/30/claude-5h/</link>
					<comments>https://luy.li/2026/08/30/claude-5h/#respond</comments>
		
		<dc:creator><![CDATA[bones7456]]></dc:creator>
		<pubDate>Sun, 30 Aug 2026 08:16:33 +0000</pubDate>
				<category><![CDATA[AI]]></category>
		<category><![CDATA[经验技巧]]></category>
		<guid isPermaLink="false">https://luy.li/?p=2592</guid>

					<description><![CDATA[<p>用 Claude Code 的都知道，额度是按 5 小时一个窗口算的，而这个窗口的起点，是你某个周期内发出的第 [&#8230;]</p>
<p>The post <a href="https://luy.li/2026/08/30/claude-5h/">给 Claude 的 5 小时窗口掐个点</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>用 <a href="https://claude.com/claude-code">Claude Code</a> 的都知道，额度是按 5 小时一个窗口算的，而这个窗口的起点，是你某个周期内发出的第一条消息。</p>
<p>为了充分利用这些窗口，我就想让这几个窗口卡在我自己定的点上——5:58、10:59、16:00、21:01，一天四个，首尾正好接得上。</p>
<p>办法也很土：到点了自动发一句 hello 过去，窗口就开了。但我平时是在MacBook和Mac mini上跑claude，&#8221;到点自动跑&#8221;这件事，在 macOS 上还真有个坑，值得写一篇，分享给有需要的朋友。<br />
<span id="more-2592"></span></p>
<h2>别用 cron，用 launchd</h2>
<p>第一反应肯定是 crontab，写四行就完事。但 Mac 是会睡觉的。cron 在机器睡眠期间错过的任务，醒来之后不会补——那一条 hello 就这么静悄悄地没了，窗口也就没开成。</p>
<p>launchd 不一样。<code>StartCalendarInterval</code> 错过的时间点，会在唤醒后立刻补执行一次。对&#8221;激活窗口&#8221;这个目的来说，这个行为正合适。</p>
<p>而且四个时间点不用建四个 plist，<code>StartCalendarInterval</code> 直接收一个数组：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">&lt;key&gt;StartCalendarInterval&lt;/key&gt;
    &lt;array&gt;
        &lt;dict&gt;
            &lt;key&gt;Hour&lt;/key&gt;&lt;integer&gt;5&lt;/integer&gt;
            &lt;key&gt;Minute&lt;/key&gt;&lt;integer&gt;58&lt;/integer&gt;
        &lt;/dict&gt;
        &lt;dict&gt;
            &lt;key&gt;Hour&lt;/key&gt;&lt;integer&gt;10&lt;/integer&gt;
            &lt;key&gt;Minute&lt;/key&gt;&lt;integer&gt;59&lt;/integer&gt;
        &lt;/dict&gt;
        &lt;dict&gt;
            &lt;key&gt;Hour&lt;/key&gt;&lt;integer&gt;16&lt;/integer&gt;
            &lt;key&gt;Minute&lt;/key&gt;&lt;integer&gt;0&lt;/integer&gt;
        &lt;/dict&gt;
        &lt;dict&gt;
            &lt;key&gt;Hour&lt;/key&gt;&lt;integer&gt;21&lt;/integer&gt;
            &lt;key&gt;Minute&lt;/key&gt;&lt;integer&gt;1&lt;/integer&gt;
        &lt;/dict&gt;
    &lt;/array&gt;</pre><p></p>
<p>plist 放 <code>~/Library/LaunchAgents/</code>，然后 load 一下就生效：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">plutil -lint ~/Library/LaunchAgents/com.lly.claude-keepalive.plist
launchctl load ~/Library/LaunchAgents/com.lly.claude-keepalive.plist
launchctl list | grep claude-keepalive</pre><p></p>
<p>最后那行输出里，中间那个数字是上次的退出码，是 0 就说明跑通了。plist 的其它字段没什么特别的，<a href="https://www.launchd.info/">launchd.info</a> 上都有，照抄即可。</p>
<h2>脚本本身：记得用 haiku</h2>
<p>被调起来的脚本没什么内容，核心就一行：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">&quot;$CLAUDE&quot; -p &quot;hello&quot; --model haiku &gt;&gt; &quot;$LOG&quot; 2&gt;&amp;1</pre><p></p>
<p>这里的 <code>--model haiku</code> 是关键。5 小时窗口是账号级的，任何模型的第一条消息都会把它打开，那当然挑最便宜的来开——总不能为了开个窗口，先请 Opus 出来打个招呼吧，哈哈。</p>
<h2>几个注意点</h2>
<p>1. <b>路径必须写全。</b> launchd 拉起来的进程不走你的 shell 配置，<code>~/.local/bin</code> 根本不在默认 PATH 里，脚本里直接写 <code>claude</code> 是找不到的。要么在 plist 的 <code>EnvironmentVariables</code> 里把路径补上，要么脚本里写死绝对路径。我两个都做了，保险。</p>
<p>2. <b>补跑是把双刃剑。</b> 前面夸 launchd 会补跑，但反过来说：如果 5:58 的时候机器是睡着的，八点你才掀开盖子，那次补跑就发生在八点，窗口也就从八点开始算——精心排的那套首尾相接的节奏，当天就错位了。这个绕不过去，属于&#8221;别漏掉&#8221;和&#8221;要准时&#8221;之间必须二选一。</p>
<p>3. <b>日志记得轮转。</b> 一天四次，每次都往同一个文件里追加 Claude 的回复，攒几个月也挺可观。脚本开头加一句超过 1MB 就 mv 成 .1，两行的事儿。</p>
<h2>两天后才发现：其实一直在假装成功</h2>
<p>上线两天后回头翻日志，好家伙——除了第一次，后面十次全认证失败，报错是 <code>OAuth session expired and could not be refreshed</code>。但脚本每次都记着&#8221;退出码=0&#8243;，日志表面干干净净，窗口压根没开成也看不出来。</p>
<p>根子是 launchd 起的进程摸不到交互终端那份浏览器登录状态，用 <code>launchctl start</code> 手动触发一次就能稳定复现。解法是换成不依赖这份易失效登录的长期令牌：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">claude setup-token   # 生成一个 sk-ant-oat01-... 开头的长期令牌，存进 .zshrc ，据说这个令牌能管1年。</pre><p></p>
<p>因为我把令牌存在了 <code>.zshrc</code> 里，但 launchd 不会加载它，脚本里得自己挖出来：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">eval &quot;$(grep '^export CLAUDE_CODE_OAUTH_TOKEN=' ~/.zshrc)&quot;</pre><p></p>
<p>一个教训：<b>退出码不能全信</b>，认证失败时它照样是 0。真要验证，就拿 <code>launchctl start</code> 手动戳一次，肉眼看日志内容。</p>
<h2>顺带</h2>
<p>这套东西是我让 Claude Code 自己写的——描述完需求，脚本、plist、load 命令一条龙全办了，中间它还顺手看了眼我已有的那个备份任务的 plist，把格式风格对齐了过来（<code>EnvironmentVariables</code> 那段的写法就是照抄我自己之前写的）。让 AI 给自己写定时唤醒脚本，这个循环还挺有意思的。</p><p>The post <a href="https://luy.li/2026/08/30/claude-5h/">给 Claude 的 5 小时窗口掐个点</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://luy.li/2026/08/30/claude-5h/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Claude Code 的记忆同步大法</title>
		<link>https://luy.li/2026/08/27/claude-config-sync/</link>
					<comments>https://luy.li/2026/08/27/claude-config-sync/#respond</comments>
		
		<dc:creator><![CDATA[bones7456]]></dc:creator>
		<pubDate>Thu, 27 Aug 2026 05:52:02 +0000</pubDate>
				<category><![CDATA[经验技巧]]></category>
		<category><![CDATA[编程相关]]></category>
		<guid isPermaLink="false">https://luy.li/?p=2587</guid>

					<description><![CDATA[<p>我有两台机器：一台 MacBook Pro，还有家里的 Mac mini。有一些项目，会在两边都写。 前几天晚 [&#8230;]</p>
<p>The post <a href="https://luy.li/2026/08/27/claude-config-sync/">Claude Code 的记忆同步大法</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>我有两台机器：一台 MacBook Pro，还有家里的 Mac mini。有一些项目，会在两边都写。</p>
<p>前几天晚上在家里那台上开 Claude CLI，聊到一半我才反应过来——它什么都不记得。。。那些&#8221;这个坑我们上个月踩过&#8221;&#8221;这个方案试过不行&#8221;的结论，全都留在公司那台上了。我这边还得从头解释一遍。</p>
<p>于是花了个把小时把这事儿彻底解决掉。方案本身不复杂，但中间挖出来的几个东西挺有意思，值得写一篇，分享给需要有需要的朋友。<br />
<span id="more-2587"></span></p>
<h2>先搞清楚：记忆到底存在哪？</h2>
<p>Claude Code 的<a href="https://docs.claude.com/en/docs/claude-code/memory">记忆</a>不是存在云端的，就是本地文件：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">~/.claude/projects/&lt;路径slug&gt;/memory/*.md      # 每个项目的记忆，一条一个文件
~/.claude/projects/&lt;路径slug&gt;/memory/MEMORY.md # 该项目的记忆索引
~/.claude/CLAUDE.md                            # 全局偏好</pre><p></p>
<p>关键在那个 slug。它是<b>项目绝对路径</b>转义出来的，规则很简单粗暴——非字母数字的字符统统换成短横线：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">re.sub(r'[^a-zA-Z0-9]', '-', path)</pre><p></p>
<p>所以我的 <code>/Users/luyang.li/dev/notchy</code> 就变成了 <code>-Users-luyang-li-dev-notchy</code>。注意用户名里那个点也被转成了横线，<code>@</code>、<code>_</code> 同理，全都一视同仁。</p>
<p>统计了一下，我这边一共有 111 条记忆。数量不算多，但都是真金白银试出来的结论，重新攒一遍的成本可不低。</p>
<h2>哪些能同步，哪些碰都别碰</h2>
<p>第一反应可能是&#8221;把整个 <code>~/.claude</code> 丢进 iCloud 不就完了&#8221;。<b>千万别。</b></p>
<p>我扫了一遍那个目录，能同步的其实就这么几样：</p>
<p>1. <code>projects/*/memory/</code> —— 各项目记忆<br />
2. <code>skills/</code> —— 自定义 skills<br />
3. <code>CLAUDE.md</code> —— 全局偏好<br />
4. <code>settings.json</code> —— 全局设置<br />
5. <code>mcp.json</code> —— MCP 配置</p>
<p>其余的全是本机运行时状态，同步过去只会互相打架：<code>history.jsonl</code>、<code>sessions/</code>、各项目下的 <code>*.jsonl</code> 会话记录（我这边光这项就 364M）、<code>file-history/</code>（30M）、<code>shell-snapshots/</code>、<code>plugins/</code> 缓存、<code>telemetry/</code>、还有 daemon 的认证文件。</p>
<p>更要命的是 <code>~/.claude.json</code>——注意是家目录根下那个，不是 <code>.claude/</code> 里面的。我打开一看，好家伙，某个 MCP server 的明文 access token 就躺在里面。这玩意儿要是跟着仓库推上去，那可就热闹了！<img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f605.png" alt="😅" class="wp-smiley" style="height: 1em; max-height: 1em;" /></p>
<p>顺带一提，<b>插件不用同步</b>。<code>settings.json</code> 里的 <code>enabledPlugins</code> 和 <code>extraKnownMarketplaces</code> 已经完整描述了装了哪些插件、从哪个 marketplace 拉，换台机器它自己会去下。我这边 <code>plugins/</code> 目录 14M，绝大部分是缓存和 marketplace 的 clone，同步纯属浪费。</p>
<h2>目录用软链接，单文件用拷贝</h2>
<p>方案就是建个私有 git 仓库 <code>~/dev/claude-config</code>，把要同步的东西搬进去，原地留软链接。</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">REPO=~/dev/claude-config
mkdir -p $REPO &amp;&amp; git -C $REPO init

# 以 skills 为例
mv ~/.claude/skills $REPO/skills
ln -s $REPO/skills ~/.claude/skills</pre><p></p>
<p>但这里有个分野，我一开始没想到：<b>目录可以软链接，单个文件不行。</b></p>
<p>原因是 Claude Code 自己会重写 <code>settings.json</code>（你 <code>/model</code> 切一次它就重写一遍）。而很多程序的&#8221;原子写&#8221;是先写临时文件再 rename——那一 rename，你的软链接就被替换成实体文件了，链接直接断掉，后面的改动再也进不了仓库。目录被这样整个替换的概率极低，所以目录安全。</p>
<p>于是最终是混合方案：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">skills/                     -&gt; 软链接，改动实时进仓库
projects/*/memory/          -&gt; 软链接，同上
CLAUDE.md / settings.json / mcp.json
                            -&gt; 拷贝，按 mtime 双向同步，每次跑脚本时自愈</pre><p></p>
<p>还有个细节让我挺满意的：我那 39 个 skills 里有 20 个本身就是指向另一个仓库的软链接。git 会把符号链接以 mode <code>120000</code> 原样存下来（存的是目标路径，不是内容），clone 到另一台机器上它还是软链接。推完我特意查了一下远端：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">gh api 'repos/bones7456/&lt;repo名&gt;/git/trees/main?recursive=1' \
  | python3 -c &quot;import json,sys; t=json.load(sys.stdin)['tree']; \
    b=[x for x in t if x['type']=='blob']; \
    print(len(b), '个 blob,', len([x for x in b if x['mode']=='120000']), '个符号链接')&quot;</pre><p></p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">548 个 blob, 20 个符号链接</pre><p></p>
<h2>两台机器用户名不一样怎么办</h2>
<p>还记得前面那个 slug 规则吗，坑来了。</p>
<p>我这台的用户名是 <code>luyang.li</code>，另一台是 <code>lly</code>。同一个项目 <code>~/dev/notchy</code>，在两台机器上算出来的 slug 完全不同：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">-Users-luyang-li-dev-notchy    # 这台
-Users-lly-dev-notchy          # 那台</pre><p></p>
<p>直接把仓库里的目录名照搬过去，记忆就对不上了。而且反过来从 slug 推路径也不行——横线可能对应 <code>/</code>，也可能对应 <code>.</code>、<code>@</code>，甚至本来就是横线，信息已经丢了。</p>
<p>解决办法是在仓库里存一份 <code>origins.tsv</code>，记录每份记忆对应的<b>原始绝对路径</b>：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">-Users-luyang-li-dev-notchy	/Users/luyang.li/dev/notchy
-Users-luyang-li-dev-shi	/Users/luyang.li/dev/shi</pre><p></p>
<p>安装脚本读这个文件，把路径里的 home 前缀换成本机的，再重算一遍 slug 建软链接。这样只要 home 之后的路径一致，两边就能对上同一份记忆。</p>
<p>（生成这个映射表也有讲究：我没去反推，而是拿 <code>~/.claude.json</code> 里 <code>projects</code> 字典的 key——那些本来就是绝对路径——正向算 slug 建的对照，14 个全中。）</p>
<h2>挂上 hooks，让它自己跑</h2>
<p>同步脚本写好了，接下来是自动化。Claude Code 的 <a href="https://docs.claude.com/en/docs/claude-code/hooks">hooks</a> 正好有两个合适的事件：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">&quot;hooks&quot;: {
  &quot;SessionStart&quot;: [
    { &quot;hooks&quot;: [{ &quot;type&quot;: &quot;command&quot;, &quot;command&quot;: &quot;\&quot;$HOME/dev/claude-config/sync.sh\&quot; pull&quot;, &quot;timeout&quot;: 30 }] }
  ],
  &quot;SessionEnd&quot;: [
    { &quot;hooks&quot;: [{ &quot;type&quot;: &quot;command&quot;, &quot;command&quot;: &quot;\&quot;$HOME/dev/claude-config/sync.sh\&quot; push&quot;, &quot;timeout&quot;: 30 }] }
  ]
}</pre><p></p>
<p>这里用 <code>$HOME</code> 而不是绝对路径，因为 <code>settings.json</code> 这个文件本身也会被同步到另一台机器上去，写死路径那边就废了。</p>
<h2>SessionEnd 到底什么时候触发？</h2>
<p>配完我就犯嘀咕：SessionEnd 具体什么时候跑？关终端算不算？</p>
<p>翻了下 settings 的 schema，它只在事件名枚举里出现，没有说明。与其猜，不如直接从本机的 CLI 二进制里找。我这个版本是 2.1.247：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">F=~/.local/share/claude/versions/2.1.247
grep -a -o -E '.{130}prompt_input_exit.{130}' &quot;$F&quot; | head -5</pre><p></p>
<p>第一条就中了：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">Vs=[&quot;clear&quot;,&quot;resume&quot;,&quot;logout&quot;,&quot;prompt_input_exit&quot;,&quot;other&quot;],Qs=s(()=&gt;a(Vs)),Zs=s(()=&gt;h().and(t({hook_event_name:n(&quot;SessionEnd&quot;),reason:Qs()})))</pre><p></p>
<p>紧挨着 <code>hook_event_name:"SessionEnd"</code> 的就是 <code>reason</code> 的枚举，一共五个值：</p>
<p>1. <code>clear</code> —— 执行 <code>/clear</code><br />
2. <code>resume</code> —— 切走去别的会话<br />
3. <code>logout</code> —— 执行 <code>/logout</code><br />
4. <code>prompt_input_exit</code> —— 从输入框正常退出<br />
5. <code>other</code> —— 兜底</p>
<p><b>最意外的是 <code>/clear</code> 也算 SessionEnd。</b>这意味着我的 push 不是&#8221;一天一次&#8221;，而是每次清上下文都会提交推送一遍，同步粒度比预想的细多了，挺好。</p>
<p>再往下挖，找到了执行时机：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">async shutdown(e=0,t=&quot;other&quot;,n){
  if(this.shutdownInProgress)return;
  this.shutdownInProgress=!0, process.exitCode=e, ...
  let{executeSessionEndHooks:r,getSessionEndHookTimeoutMs:o}=await import(&quot;...&quot;);
  let s=o();
  this.armShutdownFailsafe(Math.max(5000,s+5000),e);</pre><p></p>
<p>hooks 跑在 <code>process.exitCode</code> 设定之后、进程真正退出之前，而且有个失效保险：超过 <code>max(5秒, 超时+5秒)</code> 就强制退出。所以 SessionEnd hook 里别干重活，我这个只在本地 commit，真正的 <code>git push</code> 甩到后台跑。</p>
<p>那硬退出呢？我找到这么一行：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">process.on(&quot;SIGTERM&quot;, () =&gt; process.exit())</pre><p></p>
<p>裸的 <code>process.exit()</code>，不走 <code>shutdown()</code>，也就不跑 hook。<code>SIGKILL</code> 就更不用说了。（CLI 里 SIGTERM/SIGINT/SIGHUP 有好几处注册，分属不同上下文，我没法干净地判断直接关终端窗口走的是哪条路径，所以这条只能算&#8221;很可能&#8221;，不算证实。）</p>
<h2>所以 pull 也得先 commit</h2>
<p>上面这个结论直接推翻了我原本的脚本设计。</p>
<p>原来 <code>pull</code> 是这么写的：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">git pull --rebase --autostash</pre><p></p>
<p><code>--autostash</code> 会把未提交的改动 stash 起来、rebase 完再放回来——但放回来之后<b>它还是未提交状态</b>。万一某次 SessionEnd 没跑成（进程被硬杀了），那批记忆就一直悬在工作区里，没提交更没推送，另一台机器永远看不到。</p>
<p>改法很简单：<code>pull</code> 开头先 commit 一次。</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">pull)
    link_dirs
    sync_files
    commit_local &quot;recovered&quot;          # 先落袋为安
    if has_remote; then
        git -C &quot;$REPO&quot; pull --rebase --autostash -q || echo &quot;pull 失败，请手工查看&quot;
        n=$(unpushed_count)
        [ &quot;$n&quot; -gt 0 ] &amp;&amp; echo &quot;  &uarr; 补推 $n 个未推送的提交&quot;
        push_background
    fi
    sync_files                        # 把刚拉下来的内容拷出去
    exit 0
    ;;</pre><p></p>
<p>这样只要还能开一次会话，上次漏掉的东西就一定会被补上。提交信息前缀用 <code>recovered</code>，跟正常退出的 <code>sync</code> 区分开，翻 log 时一眼能看出这批改动是怎么进来的。</p>
<h2>最后</h2>
<p>整套跑下来，仓库 548 个文件、<code>.git</code> 才 2.9M，所有项目的记忆全接管了。现在的循环是这样：会话开始 pull，中间的改动经软链接实时落到仓库，会话结束（或者 <code>/clear</code>）commit 加后台 push。</p>
<p>顺带说一句，为什么不用 iCloud 或者 Dropbox——它俩当然也能跑，把仓库路径换成云盘目录、软链接照建就行，还省掉 hooks。但记忆这东西天然就是&#8221;一句话一个文件&#8221;，太适合版本化了；万一两台机器同时改了同一条，git 会明明白白报冲突让你处理，云盘则是悄悄给你生成一个&#8221;冲突副本&#8221;，你可能几个月都发现不了。。。</p>
<p>不说了，我去家里那台上 clone 一遍试试。要是 <code>origins.tsv</code> 那套换算真能对上，那 111 条记忆就算是彻底安全了——毕竟这里头有不少是<a href="https://luy.li/2026/08/16/ios-app-memory-leak/">当初排查内存泄露</a>那种熬了半天才熬出来的结论，再丢一次我可真要哭了！<img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f62d.png" alt="😭" class="wp-smiley" style="height: 1em; max-height: 1em;" /></p><p>The post <a href="https://luy.li/2026/08/27/claude-config-sync/">Claude Code 的记忆同步大法</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://luy.li/2026/08/27/claude-config-sync/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>一个iOS APP内存泄露排查案例</title>
		<link>https://luy.li/2026/08/16/ios-app-memory-leak/</link>
					<comments>https://luy.li/2026/08/16/ios-app-memory-leak/#respond</comments>
		
		<dc:creator><![CDATA[bones7456]]></dc:creator>
		<pubDate>Sun, 16 Aug 2026 06:06:27 +0000</pubDate>
				<category><![CDATA[流水帐]]></category>
		<guid isPermaLink="false">https://luy.li/?p=2568</guid>

					<description><![CDATA[<p>昨晚睡觉前，我打开了自己那个打鼾监测 App，早上其实发现它消失了，打开记录停在了 02:06，而我七点半才醒 [&#8230;]</p>
<p>The post <a href="https://luy.li/2026/08/16/ios-app-memory-leak/">一个iOS APP内存泄露排查案例</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>昨晚睡觉前，我打开了自己那个<a href="https://nightsnore.com/">打鼾监测 App</a>，早上其实发现它消失了，打开记录停在了 02:06，而我七点半才醒。我意识到，不妙，来活儿了～<br />
<span id="more-2568"></span></p>
<p><img fetchpriority="high" decoding="async" src="https://luy.li/wp-content/uploads/2026/08/02-session-cut-at-0206.webp" alt="记录停在 02:06:36，实际睡到 7:30" width="690" height="1500" class="alignnone size-full wp-image-2577" /></p>
<p>这种&#8221;整夜监测只录到一半&#8221;的问题最难受的地方在于：它不报错、不崩溃、不留任何痕迹。App 就是在某个时刻悄无声息地没了，第二天你只看到一条被截断的记录。</p>
<p>这篇写的就是把这件事查清楚的全过程——从设备里的 JetsamEvent 报告，到 Instruments 的 Generation 快照，最后落到一行 SwiftUI 代码上。中间还有一段完整的、被自己推翻的错误推断，作为教训一并写了进来。</p>
<h2>第一步：先确认它是&#8221;被杀的&#8221;还是&#8221;自己停的&#8221;</h2>
<p>这两种情况的修法完全不同，不能靠猜。</p>
<p>我的 App 在监测过程中每 5 分钟往 UserDefaults 写一次检查点，进程要是被强杀，下次启动时会拿检查点的时间去补 endTime（不然历史里就是一条 endTime 为空的孤儿记录）。所以判据现成的：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">开始 00:01:36
结束 02:06:36
差值 7500 秒 = 25 &times; 300</pre><p></p>
<p>正好是检查点间隔的整数倍。<b>如果是正常停止，endTime 会是任意时刻；只有走孤儿恢复才会精确落在检查点节拍上。</b> 结论：进程在 02:06:36 到 02:11:36 之间没走正常的停止流程就没了。</p>
<p>顺带说个同一晚的另一个 bug，跟主线无关但挺典型的：</p>
<p><img decoding="async" src="https://luy.li/wp-content/uploads/2026/08/01-audio-error-what.webp" alt="com.apple.coreaudio.avfaudio error 2003329396" width="690" height="1500" class="alignnone size-full wp-image-2571" /></p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">&gt;&gt;&gt; import struct; struct.pack('&gt;I', 2003329396)
b'what'</pre><p></p>
<p>CoreAudio 的错误码经常是四字符码，2003329396 就是 <code>'what'</code>。触发路径是：点开始 → 5 秒预备倒计时 → 我顺手按了锁屏键 → 倒计时在后台结束 → 这时才去开麦。而 <code>UIBackgroundModes: audio</code> 这个豁免只保&#8221;已经在录的继续录&#8221;，<b>不保&#8221;后台新开麦&#8221;</b>，系统直接拒了。改法很简单：把开麦挪到倒计时之前，会话在麦克风激活之后，锁屏就管不着了。</p>
<h2>JetsamEvent 报告怎么读</h2>
<p>iOS 上的查看路径是 <b>设置 → 隐私与安全性 → 分析与改进 → 分析数据</b>，往下翻找 <code>JetsamEvent-年-月-日-时分秒.ips</code>。这个文件是两段 JSON 拼起来的：第一行是头，剩下是正文。</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">import json
raw = open(&quot;JetsamEvent-2026-08-16-020855.ips&quot;).read()
d = json.loads(raw.split(&quot;\n&quot;, 1)[1])
PS = d[&quot;memoryStatus&quot;][&quot;pageSize&quot;]          # 16384
for p in sorted(d[&quot;processes&quot;], key=lambda x: -x[&quot;rpages&quot;])[:5]:
    print(f'{p[&quot;name&quot;]:24} {p[&quot;rpages&quot;] * PS / 1048576:8.1f} MB  prio={p[&quot;priority&quot;]}')</pre><p></p>
<p>几个关键字段：</p>
<p>1. <code>rpages × pageSize</code> 是各进程占的内存，注意 pageSize 现在是 16 KB 不是 4 KB。<br />
2. <code>physicalPages.internal</code> 是 <code>[clean, dirty]</code>，脏页才是真正赖着不走的那部分。<br />
3. <code>age</code> 单位是纳秒，用报告时间减一下就能反推每个进程什么时候启动的。<br />
4. 带 <code>reason</code> 字段的那几条才是这次事件里被杀的，其余是快照。<br />
5. <code>largestProcess</code> 直接告诉你当时谁最胖。</p>
<p>我这份 02:08:55 的报告里，<b>NightSnore 压根不在进程列表里</b>——说明它在这次事件之前就已经被杀了。当时整机也确实在雪崩：可用内存 162 MB、compressor 涨到 1.87 GB、最后三分钟有 172 个进程被杀了重启，连 SpringBoard 自己都撞了 per-process 上限。</p>
<p>真正的实锤在另一份 ips 文件里，因为我发现还有一份六天前的：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">NightSnore   rss = 1814.3 MB   dirty = 1734 MB   purgeable = 0
             prio = 100   states = [active]   age = 29.5 分钟
largestProcess: NightSnore</pre><p></p>
<p><b>监测跑了 29.5 分钟，进程涨到 1.8 GB，其中 1.73 GB 是脏内存。</b> 全机最大。那次系统没杀它，而是把 180 多个闲置进程 idle-exit 掉给它让路。</p>
<p>到这儿性质就清楚了：不是什么玄学的后台调度问题，就是漏内存漏到撑爆。</p>
<h2>证据不够的时候，别急着改代码</h2>
<p>这一节是我这次最大的教训。</p>
<p>拿到 1.8 GB 这个数之后，我去读音频管线的代码，很快&#8221;找到&#8221;了三处无上界结构：波形显示每来一个音频 buffer 就起一个 <code>Task { @MainActor }</code> 把整块样本捕获进去；两级 DispatchQueue 都是无条件 async，处理端一慢就会无限堆积。三处加起来的量级估算下来跟观测值挺接近。</p>
<p>于是我给队列加了背压、给波形加了节流。改完确实好了一些——但<b>这是推断，不是证据</b>，这里其实浪费了很多时间。</p>
<h2>Instruments 的 Generation 快照发现根因</h2>
<p>Xcode 的 Memory Report 只能告诉你&#8221;在涨&#8221;：</p>
<p><img decoding="async" src="https://luy.li/wp-content/uploads/2026/08/03-xcode-memory-report.webp" alt="三分钟涨了约 10 MB" width="1500" height="1106" class="alignnone size-full wp-image-2576" /><br />
(可以发现，除了内存一直涨，CPU也很高这个后面也一起修复了）</p>
<p>要知道&#8221;谁在涨&#8221;，得用 <a href="https://developer.apple.com/documentation/xcode/gathering-information-about-memory-use">Instruments 的 Allocations</a>，核心功能是 <b>Mark Generation</b>：</p>
<p>1. Product → Profile（⌘I），选 Allocations<br />
2. Recorder Settings 里面的 Recording mode 要选 Immediate<br />
3. 跑一分钟，点一下 Mark Generation<br />
4. 再跑三分钟，再点一次<br />
5. 展开第二个 Generation，看 <b>Growth</b> 列</p>
<p>Growth 只列&#8221;两次快照之间新增、且到现在仍然存活&#8221;的对象——这就是内存泄漏的定义。别的视图都会被大量正常的临时分配淹没，只有这个视图干净。</p>
<p><img loading="lazy" decoding="async" src="https://luy.li/wp-content/uploads/2026/08/04-instruments-generation-before.webp" alt="Generation B 增长 10.81 MiB / 61,631 个存活对象" width="1500" height="778" class="alignnone size-full wp-image-2575" /></p>
<p>结果一眼就能看出问题：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">UnknownObjectType                        5.71 MiB   25,584
_ContiguousArrayStorage&lt;AnyViewTrait&gt;    2.16 MiB    5,904
ImageProviderBox&lt;NamedImageProvider&gt;   553.50 KiB    5,904
LocalizedTextStorage                   461.25 KiB    5,904
AnyViewStorag&lt;Label&lt;Text, Image&gt;&gt;     369.00 KiB    5,904
TagIndexProjection&lt;Int&gt;                184.50 KiB    1,968
_TtCC5UIKit12_UITabButton&hellip;              96.00 KiB        6</pre><p></p>
<p>跟音频、跟 CoreML、跟我改了半天的那些队列，<b>一点关系都没有</b>。全是 SwiftUI，而且全是 TabView 标签栏的零件：<code>Label&lt;Text, Image&gt;</code> 是三个 tab 项，<code>NamedImageProvider</code> 是 tab 图标，<code>LocalizedTextStorage</code> 是 tab 标题，<code>TagIndexProjection&lt;Int&gt;</code> 是 <code>.tag()</code>，最后还有 <code>UITabButton</code> 本体。</p>
<p>UnknownObjectType：<br />
<img loading="lazy" decoding="async" src="https://luy.li/wp-content/uploads/2026/08/05-instruments-unknownobjecttype.webp" alt="展开 UnknownObjectType，全是 64/96/768 字节的小块" width="1500" height="1001" class="alignnone size-full wp-image-2574" /><br />
AnyViewTrait：<br />
<img loading="lazy" decoding="async" src="https://luy.li/wp-content/uploads/2026/08/06-instruments-anyviewtrait.webp" alt="展开 AnyViewTrait，全是 384 字节，时间戳密集" width="1500" height="943" class="alignnone size-full wp-image-2573" /></p>
<p>真正把结论钉死的是数字本身：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">5,904 = 3 &times; 1,968     （三个 tab）
1,968 &divide; 180 秒 = 10.9 次/秒</pre><p></p>
<p>10.9 次每秒——正好是音频 buffer 的到达频率。</p>
<h2>根因：根视图订阅了高频 @Published</h2>
<p>顺着这个频率回去看代码，一行就找到了：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">struct ContentView: View {
    @ObservedObject private var monitoringService = MonitoringService.shared
    // ...
    TabView(selection: $selectedTab) {
        MonitoringView().tabItem { Label(&quot;监测&quot;, systemImage: &quot;waveform.circle.fill&quot;) }.tag(0)
        SessionListView().tabItem { Label(&quot;历史&quot;, systemImage: &quot;clock.fill&quot;) }.tag(1)
        SettingsView().tabItem { Label(&quot;设置&quot;, systemImage: &quot;gearshape.fill&quot;) }.tag(2)
    }
}</pre><p></p>
<p><code>MonitoringService</code> 里有个 <code>@Published var audioSamples: [Float]</code>，监测时每秒更新约 11 次给波形图用。链条是这样的：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">audioSamples 每秒发布 11 次
  &rarr; ContentView.body 每秒重算 11 次
    &rarr; 整个 TabView 连同三个 .tabItem { Label(...) } 重建
      &rarr; SwiftUI 把标签项的内部存储泄漏掉</pre><p></p>
<p>一小时约 200 MB，整夜下来就是 GB 级。而且这跟屏幕亮不亮无关——锁屏后 SwiftUI 不渲染了，但 body 该重算还是重算。</p>
<p><b>根视图的 body 其实根本不依赖任何监测状态</b>，它只是需要在两个字段变化时做点事而已。那就别观察整个对象，改成订阅具体的 publisher：</p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">private let monitoringService = MonitoringService.shared   // 不再是 @ObservedObject

// ...
.onReceive(monitoringService.$finishedSessionForSummary) { _ in presentSummaryAfterPublish() }
.onReceive(monitoringService.$audioError)                { _ in presentSummaryAfterPublish() }</pre><p></p>
<p>同样口径复测：</p>
<p><img loading="lazy" decoding="async" src="https://luy.li/wp-content/uploads/2026/08/07-instruments-generation-after.webp" alt="Generation B 增长 581 KiB / 609 个存活对象" width="1500" height="905" class="alignnone size-full wp-image-2572" /></p>
<p></p><pre class="urvanov-syntax-highlighter-plain-tag">修之前              修之后
Growth       10.81 MiB          581 KiB
存活对象      61,631            609</pre><p></p>
<p>字节数降了 19 倍，<b>对象数降了 101 倍</b>。后者才是关键信号——&#8221;每帧泄一批&#8221;的模式没有了。剩下那 581 KiB 里 80% 是 4 个 CALayer 后备存储和 1 次 SQLite 页缓存增长，都是一次性的。</p>
<p>支持，内存泄露的问题算是解决了，再去看CPU使用率，也从 75% 降到了 5% 不到。</p>
<h2>几个坑</h2>
<p>1. <b><a href="https://developer.apple.com/documentation/combine/published">@Published</a> 是在 willSet 时发布的。</b> 换成 <code>.onReceive</code> 之后，闭包跑在属性真正写入之前，这时候回读那个属性拿到的还是旧值——比如 <code>audioError</code> 明明马上要变成 nil，读出来仍然非 nil，判断就全反了。要么用闭包参数带过来的新值，要么推到下一个主队列 turn 再处理。</p>
<p>2. <b>本进程不在 JetsamEvent 列表里，不代表这份报告没用。</b> 它恰恰说明进程在这次事件之前就已经被杀了，应该去找更早的那份。iOS 不会为每次 kill 都写报告，也会轮转，所以未必找得到。</p>
<p>3. <b>Xcode Organizer 的 Terminations / Memory 面板需要足够用户量。</b> 我的 App 用户少，那儿一直显示 Insufficient usage data。真要在生产环境拿这个数据，得自己接 <a href="https://developer.apple.com/documentation/metrickit">MetricKit</a>——<code>MXAppExitMetric.backgroundExitData</code> 会把后台退出按原因分桶（memoryResourceLimit / memoryPressure / cpuResourceLimit / appWatchdog / normal），<code>MXMemoryMetric.peakMemoryUsage</code> 给峰值内存，而且不依赖样本量。注意 payload 是按天生成、日终才交付的，今天出的事明天才读得到。</p>
<p>4. <b>有界即可控，无论多大。</b> 我中途一度去调小音频缓冲的上限、给队列加背压丢帧——那是让泄漏变慢，不是修泄漏，而且丢帧会漏掉真实的鼾声事件。判据应该是&#8221;有没有上界&#8221;，不是&#8221;占了多少&#8221;：有上限封顶的缓冲填到 80 MB 是可控的，无上界的队列涨到 10 MB 才是 bug。根因定位之后，那些基于错误推断做的改动我全部还原了。</p>
<p>5. <b>别给泄漏加兜底。</b> 我还写过&#8221;剩余内存不足就自动结束监测&#8221;的保护，听起来很负责，实际上根因修掉之后它只剩下误停正常会话的风险。也删了。</p>
<h2>回头看</h2>
<p>整件事的证据链是这样一条：<b>endTime 落在检查点整倍数上</b>（判定被杀）→ <b>JetsamEvent 报告</b>（拿到 1.8 GB 这个量级）→ <b>git log</b>（作废错误推断）→ <b>Instruments Generation</b>（点名到具体对象）→ <b>对象个数 = 3 × body 重算次数</b>（锁定根因）。</p>
<p>前面几步都是旁证，只有 Generation 快照那一步是真正指名道姓的。我在拿到它之前写的所有&#8221;修复&#8221;，事后看全是在给一个不存在的病开药。读代码能产生假设，但假设需要被证据杀死或者证实，中间那一段自己觉得&#8221;应该就是这个了&#8221;的笃定，最不可信。</p>
<p>不说了，我赶紧发新版本去了，<a href="https://nightsnore.com/">这APP</a>之前是下载就收费，刚改成免费试用+一次性买断的模式，结果免费用户就遇到了内存泄露，异常退出的大问题，实在是很不应该。。。对我的打击也实在是有点大！<img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f62d.png" alt="😭" class="wp-smiley" style="height: 1em; max-height: 1em;" /></p><p>The post <a href="https://luy.li/2026/08/16/ios-app-memory-leak/">一个iOS APP内存泄露排查案例</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://luy.li/2026/08/16/ios-app-memory-leak/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>《阿勒泰的角落》读书笔记</title>
		<link>https://luy.li/2026/08/16/978-7-5133-1253-0/</link>
					<comments>https://luy.li/2026/08/16/978-7-5133-1253-0/#respond</comments>
		
		<dc:creator><![CDATA[bones7456]]></dc:creator>
		<pubDate>Sun, 16 Aug 2026 00:44:27 +0000</pubDate>
				<category><![CDATA[读书笔记]]></category>
		<guid isPermaLink="false">https://luy.li/?p=2554</guid>

					<description><![CDATA[<p>我喜欢旅行，去世界各地旅行。有的人说：旅行就是从你呆腻了的地方去到别人呆腻了的地方，我觉得某种意义上，这是对的 [&#8230;]</p>
<p>The post <a href="https://luy.li/2026/08/16/978-7-5133-1253-0/">《阿勒泰的角落》读书笔记</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>我喜欢旅行，去世界各地旅行。有的人说：旅行就是从你呆腻了的地方去到别人呆腻了的地方，我觉得某种意义上，这是对的。人生在世不就是要多体验嘛！旅行就有很大的概率可以让你有一些新的体验。</p>
<p>而读书，也是这个道理。<br />
<span id="more-2554"></span></p>
<p>虽然你不是亲自去某个地方，但通过文字，你就能感受到笔者所描述的世界，而且这个成本更低。不仅成本低，你还可以进入到“非现实的世界”，比如你读历史，可以进入“过去的世界”；你读玄幻类的，可以进入“虚拟的世界”。在某种意义上，这些都是超越旅行的存在。</p>
<p>当然，我最近读的《阿勒泰的角落》进入的倒是一个“真实的世界”，而且这个世界，说起来我还去过，2022年我来新加坡之前，曾经开着房车游过新疆。我花了半个月时间，在北疆转了一圈，但新疆真的太大了，即使半个月对打工的牛马来说，已经很奢侈了，但我肯定不能说对阿勒泰很熟了。我只是禾木、阿勒泰这样转了几个景点。</p>
<p>而本书的作者，虽然不是新疆人，但却感觉是土生土长在新疆的，而且体感上，应该比我大不了几岁（这个后来我查资料证实了）。从她自己的描述来看，她的学历应该不算高，但文笔却出奇地好，文字看着很舒服，不堆砌辞藻，但却很有画面感，就像你的邻居在给你讲述生活细节一样。呈现给读者的绝大部分是朴实到有点朴素的文字，偶尔镶嵌了恰到好处的“高光”。</p>
<p>内容是关于她在新疆生活的方方面面，包括开店经商的种种经历、谈恋爱又分手的经历、多次搬家的经历以及日常生活的一些细节，但因为那种生活是绝大部分读者所不会经历的，所以读起来就会很有意思。她们一家子人，虽然不是逐水草而居的游牧民族，但不同时期也住在阿勒泰的不同角落，因此也发生了很多不同的故事。</p>
<p>这里就不剧透太多细节的内容了，总之这是一本读起来很轻松的书，就像一次旅行，所以推荐给大家。</p><p>The post <a href="https://luy.li/2026/08/16/978-7-5133-1253-0/">《阿勒泰的角落》读书笔记</a> first appeared on <a href="https://luy.li">I am LAZY bones?</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://luy.li/2026/08/16/978-7-5133-1253-0/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
